Skip to main content

aion/durability/
command.rs

1//! `Command` and `Resolution` types at the AD seam.
2
3use aion_core::{ActivityError, Payload, WithTimeoutOutcome, WorkflowError, WorkflowId};
4use chrono::{DateTime, Utc};
5
6use crate::durability::{CorrelationKey, SignalDelivery};
7
8/// World-touching workflow intent presented to the durability resolver.
9#[derive(Clone, Debug, PartialEq)]
10pub enum Command {
11    /// Run an activity identified by a deterministic activity scheduling key.
12    RunActivity {
13        /// Correlation key that must match the recorded activity schedule.
14        key: CorrelationKey,
15        /// Activity type selected by workflow code.
16        activity_type: String,
17        /// Opaque activity input payload.
18        input: Payload,
19    },
20    /// Await the next matching signal delivery.
21    AwaitSignal {
22        /// Correlation key naming the signal and occurrence index to deliver.
23        key: CorrelationKey,
24    },
25    /// Send a signal to another workflow.
26    SendSignal {
27        /// Correlation key naming the sent signal occurrence.
28        key: CorrelationKey,
29        /// Delivery selected by workflow code.
30        delivery: SignalDelivery,
31    },
32    /// Start or await a timer identified by a deterministic timer key.
33    StartTimer {
34        /// Correlation key that must match the recorded timer start.
35        key: CorrelationKey,
36        /// UTC instant at which the timer becomes eligible to fire.
37        fire_at: DateTime<Utc>,
38    },
39    /// Spawn a child workflow identified by a deterministic child scheduling key.
40    SpawnChild {
41        /// Correlation key that must match the recorded child-workflow start.
42        key: CorrelationKey,
43        /// Child workflow type selected by workflow code.
44        workflow_type: String,
45        /// Opaque child workflow input payload.
46        input: Payload,
47    },
48    /// Await a previously spawned child workflow's terminal outcome.
49    AwaitChild {
50        /// Child workflow identity returned by [`Command::SpawnChild`].
51        child_workflow_id: WorkflowId,
52    },
53    /// Complete the current workflow with a terminal result payload.
54    ///
55    /// Workflow completion is terminal intent rather than a cursor-matched replay family, so it has
56    /// no correlation key in AD-004.
57    CompleteWorkflow {
58        /// Opaque workflow result payload.
59        result: Payload,
60    },
61}
62
63impl Command {
64    /// Returns the correlation key for cursor-matched commands.
65    ///
66    /// [`Command::AwaitChild`] returns `None` because its replay identity is the awaited child
67    /// workflow id, matched against recorded child terminal events rather than a positional key.
68    /// [`Command::CompleteWorkflow`] returns `None` because completion is terminal intent, not a
69    /// recorded event family consumed by [`crate::durability::HistoryCursor`].
70    #[must_use]
71    pub const fn key(&self) -> Option<&CorrelationKey> {
72        match self {
73            Self::RunActivity { key, .. }
74            | Self::AwaitSignal { key }
75            | Self::SendSignal { key, .. }
76            | Self::StartTimer { key, .. }
77            | Self::SpawnChild { key, .. } => Some(key),
78            Self::AwaitChild { .. } | Self::CompleteWorkflow { .. } => None,
79        }
80    }
81}
82
83/// Recorded outcome produced by resolving a command against history.
84#[derive(Clone, Debug, PartialEq)]
85pub enum Resolution {
86    /// A recorded activity completion with its result payload.
87    ActivityCompleted(Payload),
88    /// A recorded terminal activity failure.
89    ActivityFailedTerminal(ActivityError),
90    /// A recorded timer firing.
91    TimerFired,
92    /// A recorded timer start without a terminal timer outcome yet.
93    TimerStarted,
94    /// A recorded signal delivery with its payload.
95    SignalDelivered(Payload),
96    /// A recorded timer cancellation.
97    TimerCancelled,
98    /// A recorded `with_timeout` terminal outcome with an optional result payload.
99    WithTimeout {
100        /// Recorded timeout outcome.
101        outcome: WithTimeoutOutcome,
102        /// JSON-encoded BEAM term payload for completed operation results.
103        result: Option<Payload>,
104    },
105    /// A recorded successful signal send.
106    SignalSent,
107    /// A recorded child workflow start with its child identifier.
108    ChildStarted(WorkflowId),
109    /// A recorded child workflow completion with its result payload.
110    ChildCompleted(Payload),
111    /// A recorded child workflow failure.
112    ChildFailed(WorkflowError),
113}
114
115/// Outcome of passing a [`Command`] through the durability resolver.
116#[derive(Clone, Debug, PartialEq)]
117pub enum ResolveOutcome {
118    /// The command was satisfied from recorded history without invoking live side effects.
119    Recorded(Resolution),
120    /// Recorded history is exhausted; ownership must hand off to live execution.
121    ResumeLive,
122}
123
124#[cfg(test)]
125mod tests {
126    use aion_core::{ActivityErrorKind, TimerId};
127    use chrono::{TimeZone, Utc};
128    use serde_json::json;
129
130    use super::{Command, Resolution};
131    use crate::durability::CorrelationKey;
132
133    fn payload(label: &str) -> Result<aion_core::Payload, Box<dyn std::error::Error>> {
134        Ok(aion_core::Payload::from_json(&json!({ "label": label }))?)
135    }
136
137    #[test]
138    fn command_keys_round_trip_for_cursor_matched_variants()
139    -> Result<(), Box<dyn std::error::Error>> {
140        let activity_key = CorrelationKey::Activity(1);
141        let signal_key = CorrelationKey::Signal {
142            name: "ready".to_owned(),
143            index: 0,
144        };
145        let timer_key = CorrelationKey::Timer(TimerId::anonymous(2));
146        let child_key = CorrelationKey::Child(3);
147        let fire_at = Utc
148            .timestamp_opt(10, 0)
149            .single()
150            .ok_or_else(|| "invalid timestamp".to_owned())?;
151        let commands = vec![
152            (
153                Command::RunActivity {
154                    key: activity_key.clone(),
155                    activity_type: "activity".to_owned(),
156                    input: payload("activity-input")?,
157                },
158                Some(activity_key),
159            ),
160            (
161                Command::AwaitSignal {
162                    key: signal_key.clone(),
163                },
164                Some(signal_key),
165            ),
166            (
167                Command::StartTimer {
168                    key: timer_key.clone(),
169                    fire_at,
170                },
171                Some(timer_key),
172            ),
173            (
174                Command::SpawnChild {
175                    key: child_key.clone(),
176                    workflow_type: "child".to_owned(),
177                    input: payload("child-input")?,
178                },
179                Some(child_key),
180            ),
181            (
182                Command::AwaitChild {
183                    child_workflow_id: aion_core::WorkflowId::new_v4(),
184                },
185                None,
186            ),
187            (
188                Command::CompleteWorkflow {
189                    result: payload("workflow-result")?,
190                },
191                None,
192            ),
193        ];
194
195        for (command, expected_key) in commands {
196            assert_eq!(command.key(), expected_key.as_ref());
197        }
198        Ok(())
199    }
200
201    #[test]
202    fn resolutions_round_trip_by_equality() -> Result<(), Box<dyn std::error::Error>> {
203        let activity_error = aion_core::ActivityError {
204            kind: ActivityErrorKind::Terminal,
205            message: "terminal".to_owned(),
206            details: None,
207        };
208        let workflow_error = aion_core::WorkflowError {
209            message: "child failed".to_owned(),
210            details: None,
211        };
212        let resolutions = vec![
213            Resolution::ActivityCompleted(payload("activity-result")?),
214            Resolution::ActivityFailedTerminal(activity_error),
215            Resolution::TimerFired,
216            Resolution::SignalDelivered(payload("signal")?),
217            Resolution::ChildStarted(aion_core::WorkflowId::new_v4()),
218            Resolution::ChildCompleted(payload("child-result")?),
219            Resolution::ChildFailed(workflow_error),
220        ];
221
222        for resolution in resolutions {
223            let cloned = resolution.clone();
224            assert_eq!(cloned, resolution);
225        }
226        Ok(())
227    }
228}