Skip to main content

aion/durability/
executor.rs

1//! `LiveExecutor` trait and resume-live handoff glue.
2
3use aion_core::{ActivityError, ActivityId, Payload, TimerId, WorkflowError, WorkflowId};
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6
7use crate::durability::{
8    Command, CorrelationKey, DurabilityError, Recorder, Resolution, ResolveOutcome, Resolver,
9};
10
11/// Live outcome produced by AE after running an activity for real.
12#[derive(Clone, Debug, PartialEq)]
13pub enum LiveActivityOutcome {
14    /// The activity completed successfully with an opaque result payload.
15    Completed(Payload),
16    /// The activity reached its terminal failure state after live retry policy was exhausted.
17    Failed(ActivityError),
18}
19
20/// Live outcome produced by AE after spawning and awaiting a child workflow for real.
21#[derive(Clone, Debug, PartialEq)]
22pub enum LiveChildOutcome {
23    /// The concrete child workflow completed successfully.
24    Completed {
25        /// Identifier AE assigned to the child workflow instance.
26        child_workflow_id: WorkflowId,
27        /// Package version AE resolved for the child at spawn time.
28        package_version: aion_core::PackageVersion,
29        /// Opaque child result payload.
30        result: Payload,
31    },
32    /// The concrete child workflow failed terminally.
33    Failed {
34        /// Identifier AE assigned to the child workflow instance.
35        child_workflow_id: WorkflowId,
36        /// Package version AE resolved for the child at spawn time.
37        package_version: aion_core::PackageVersion,
38        /// Terminal child workflow error.
39        error: WorkflowError,
40    },
41}
42
43impl LiveChildOutcome {
44    fn package_version(&self) -> aion_core::PackageVersion {
45        match self {
46            Self::Completed {
47                package_version, ..
48            }
49            | Self::Failed {
50                package_version, ..
51            } => package_version.clone(),
52        }
53    }
54
55    fn child_workflow_id(&self) -> WorkflowId {
56        match self {
57            Self::Completed {
58                child_workflow_id, ..
59            }
60            | Self::Failed {
61                child_workflow_id, ..
62            } => child_workflow_id.clone(),
63        }
64    }
65}
66
67/// Outcome returned by [`resolve_or_execute_live`] for commands at the AD/AE seam.
68#[derive(Clone, Debug, PartialEq)]
69pub enum HandoffOutcome {
70    /// A world-touching command produced a replayed or live resolution.
71    Resolved(Resolution),
72    /// The workflow was durably completed; completion has no AD `Resolution` shape.
73    WorkflowCompleted,
74}
75
76/// AE-provided live side-effect executor.
77///
78/// AD owns replay, the resolver, and event recording. AE owns actual world interaction (activity
79/// dispatch, timer wheel/durable timer scheduling, signal wait plumbing, and child workflow
80/// process management) and supplies an object-safe implementation of this trait, such as a
81/// beamr-backed executor, without AD depending on those runtime crates.
82///
83/// AD calls these methods only after [`Resolver`] returns [`ResolveOutcome::ResumeLive`]. While
84/// recorded history can satisfy a command, the executor must not be touched. Command-issued events
85/// are recorded by [`resolve_or_execute_live`] through the single per-workflow [`Recorder`]:
86/// activity scheduled/started/outcome, timer started, child workflow started, and workflow
87/// completed. Asynchronous arrival events that are not workflow-issued commands (`TimerFired`,
88/// `SignalReceived`, `ChildWorkflowCompleted`, and `ChildWorkflowFailed`) are recorded by AT/AE
89/// services when they occur, but still through that same recorder instance so the recorder remains
90/// the only sequence-head authority.
91#[async_trait]
92pub trait LiveExecutor: Send + Sync {
93    /// Runs an activity for real at the resume-live point.
94    ///
95    /// # Errors
96    ///
97    /// Returns a durability error when the live runtime cannot produce a recordable outcome.
98    async fn run_activity(
99        &self,
100        activity_type: String,
101        input: Payload,
102    ) -> Result<LiveActivityOutcome, DurabilityError>;
103
104    /// Starts or awaits a timer for real at the resume-live point.
105    ///
106    /// The AE implementation arms runtime timer machinery and persists any durable timer row; AD
107    /// records only the command-issued `TimerStarted` history event.
108    ///
109    /// # Errors
110    ///
111    /// Returns a durability error when the live runtime cannot start or await the timer.
112    async fn start_timer(
113        &self,
114        timer_id: TimerId,
115        fire_at: DateTime<Utc>,
116    ) -> Result<(), DurabilityError>;
117
118    /// Awaits a signal for real at the resume-live point.
119    ///
120    /// # Errors
121    ///
122    /// Returns a durability error when the live runtime cannot deliver a signal payload.
123    async fn await_signal(&self, name: String, index: usize) -> Result<Payload, DurabilityError>;
124
125    /// Spawns and awaits a child workflow for real at the resume-live point.
126    ///
127    /// # Errors
128    ///
129    /// Returns a durability error when the live runtime cannot produce a recordable child outcome.
130    async fn spawn_child(
131        &self,
132        workflow_type: String,
133        input: Payload,
134    ) -> Result<LiveChildOutcome, DurabilityError>;
135}
136
137/// Resolves a command from history or hands it off to AE live execution and records the outcome.
138///
139/// This function is the resume-live handoff glue for AD-007. It first asks the resolver to satisfy
140/// the command from history. If the resolver returns a recorded resolution, that resolution is
141/// returned immediately and no [`LiveExecutor`] method is invoked. Only when the resolver reports
142/// [`ResolveOutcome::ResumeLive`] does this function call AE, then append command-issued events
143/// through the supplied single-writer [`Recorder`]. The `recorded_at` value is supplied by the
144/// caller so this module does not consult wall-clock time for workflow-visible history timestamps.
145///
146/// # Errors
147///
148/// Returns resolver non-determinism/history-shape errors, live executor errors, or recorder/store
149/// errors. Sequence conflicts from the recorder are surfaced directly as hard durability errors.
150pub async fn resolve_or_execute_live(
151    resolver: &mut Resolver,
152    recorder: &mut Recorder,
153    executor: &dyn LiveExecutor,
154    command: Command,
155    recorded_at: DateTime<Utc>,
156) -> Result<HandoffOutcome, DurabilityError> {
157    match resolver.resolve(command.clone())? {
158        ResolveOutcome::Recorded(resolution) => Ok(HandoffOutcome::Resolved(resolution)),
159        ResolveOutcome::ResumeLive => {
160            execute_live_and_record(recorder, executor, command, recorded_at).await
161        }
162    }
163}
164
165async fn execute_live_and_record(
166    recorder: &mut Recorder,
167    executor: &dyn LiveExecutor,
168    command: Command,
169    recorded_at: DateTime<Utc>,
170) -> Result<HandoffOutcome, DurabilityError> {
171    match command {
172        Command::RunActivity {
173            key,
174            activity_type,
175            input,
176        } => {
177            let activity_id = activity_id_from_key(&key)?;
178            recorder
179                .record_activity_scheduled(
180                    recorded_at,
181                    activity_id.clone(),
182                    activity_type.clone(),
183                    input.clone(),
184                    // No SDK-level task-queue selection yet (NSTQ-4); the single-schedule seam
185                    // records the named default task queue.
186                    String::from(aion_core::DEFAULT_TASK_QUEUE),
187                    // No SDK-level node selection yet (NODE-4); the single-schedule seam records
188                    // no node affinity (`None` = genuine current value).
189                    None,
190                )
191                .await?;
192            recorder
193                // NOI-0: the single-schedule executor runs each activity once, so its start,
194                // completion, and failure all belong to attempt 1 (one-based), matching the `1`
195                // recorded on `ActivityFailed` below.
196                .record_activity_started(recorded_at, activity_id.clone(), 1)
197                .await?;
198            let outcome = executor.run_activity(activity_type, input).await?;
199            match outcome {
200                LiveActivityOutcome::Completed(result) => {
201                    recorder
202                        .record_activity_completed(recorded_at, activity_id, result.clone(), 1)
203                        .await?;
204                    Ok(HandoffOutcome::Resolved(Resolution::ActivityCompleted(
205                        result,
206                    )))
207                }
208                LiveActivityOutcome::Failed(error) => {
209                    ensure_terminal_activity_error(&error)?;
210                    recorder
211                        .record_activity_failed(recorded_at, activity_id, error.clone(), 1)
212                        .await?;
213                    Ok(HandoffOutcome::Resolved(
214                        Resolution::ActivityFailedTerminal(error),
215                    ))
216                }
217            }
218        }
219        Command::StartTimer { key, fire_at } => {
220            let timer_id = timer_id_from_key(&key)?;
221            recorder
222                .record_timer_started(recorded_at, timer_id.clone(), fire_at)
223                .await?;
224            executor.start_timer(timer_id, fire_at).await?;
225            Ok(HandoffOutcome::Resolved(Resolution::TimerFired))
226        }
227        Command::AwaitSignal { key } => {
228            let (name, index) = signal_from_key(&key)?;
229            let payload = executor.await_signal(name, index).await?;
230            Ok(HandoffOutcome::Resolved(Resolution::SignalDelivered(
231                payload,
232            )))
233        }
234        Command::SendSignal { .. } => Err(DurabilityError::HistoryShape {
235            reason: "send-signal live execution is owned by the NIF signal bridge".to_owned(),
236        }),
237        Command::AwaitChild { .. } => Err(DurabilityError::HistoryShape {
238            reason: "await-child live execution is owned by the NIF child bridge".to_owned(),
239        }),
240        Command::SpawnChild {
241            key,
242            workflow_type,
243            input,
244        } => {
245            child_from_key(&key)?;
246            let outcome = executor
247                .spawn_child(workflow_type.clone(), input.clone())
248                .await?;
249            recorder
250                .record_child_workflow_started(
251                    recorded_at,
252                    outcome.child_workflow_id(),
253                    workflow_type,
254                    input,
255                    outcome.package_version(),
256                )
257                .await?;
258            match outcome {
259                LiveChildOutcome::Completed { result, .. } => {
260                    Ok(HandoffOutcome::Resolved(Resolution::ChildCompleted(result)))
261                }
262                LiveChildOutcome::Failed { error, .. } => {
263                    Ok(HandoffOutcome::Resolved(Resolution::ChildFailed(error)))
264                }
265            }
266        }
267        Command::CompleteWorkflow { result } => {
268            recorder
269                .record_workflow_completed(recorded_at, result)
270                .await?;
271            Ok(HandoffOutcome::WorkflowCompleted)
272        }
273    }
274}
275
276fn ensure_terminal_activity_error(error: &ActivityError) -> Result<(), DurabilityError> {
277    if error.is_retryable() {
278        return Err(DurabilityError::HistoryShape {
279            reason: "live activity failure must be terminal before AD can record a terminal \
280                     resolution"
281                .to_owned(),
282        });
283    }
284    Ok(())
285}
286
287fn activity_id_from_key(key: &CorrelationKey) -> Result<ActivityId, DurabilityError> {
288    match key {
289        CorrelationKey::Activity(ordinal) => Ok(ActivityId::from_sequence_position(*ordinal)),
290        other => Err(DurabilityError::HistoryShape {
291            reason: format!("RunActivity requires an activity correlation key, got {other:?}"),
292        }),
293    }
294}
295
296fn timer_id_from_key(key: &CorrelationKey) -> Result<TimerId, DurabilityError> {
297    match key {
298        CorrelationKey::Timer(timer_id) => Ok(timer_id.clone()),
299        other => Err(DurabilityError::HistoryShape {
300            reason: format!("StartTimer requires a timer correlation key, got {other:?}"),
301        }),
302    }
303}
304
305fn signal_from_key(key: &CorrelationKey) -> Result<(String, usize), DurabilityError> {
306    match key {
307        CorrelationKey::Signal { name, index } => Ok((name.clone(), *index)),
308        other => Err(DurabilityError::HistoryShape {
309            reason: format!("AwaitSignal requires a signal correlation key, got {other:?}"),
310        }),
311    }
312}
313
314fn child_from_key(key: &CorrelationKey) -> Result<u64, DurabilityError> {
315    match key {
316        CorrelationKey::Child(ordinal) => Ok(*ordinal),
317        other => Err(DurabilityError::HistoryShape {
318            reason: format!("SpawnChild requires a child correlation key, got {other:?}"),
319        }),
320    }
321}
322
323#[cfg(test)]
324#[path = "executor_tests.rs"]
325mod executor_tests;