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}