sayiir_runtime/execution/
lifecycle.rs1use bytes::Bytes;
4use sayiir_core::error::WorkflowError;
5use sayiir_core::snapshot::{
6 ExecutionPosition, SignalKind, TaskHint, WorkflowSnapshot, WorkflowSnapshotState,
7};
8use sayiir_core::workflow::{ConflictPolicy, WorkflowStatus};
9pub use sayiir_persistence::PrepareRunOutcome;
10use sayiir_persistence::{SignalStore, SnapshotStore};
11
12use super::helpers::ResumeParkedPosition;
13use crate::error::RuntimeError;
14
15pub async fn check_existing_instance<B>(
34 instance_id: &str,
35 definition_hash: &sayiir_core::DefinitionHash,
36 backend: &B,
37 conflict_policy: ConflictPolicy,
38) -> Result<Option<(WorkflowStatus, Option<Bytes>)>, RuntimeError>
39where
40 B: SnapshotStore,
41{
42 sayiir_core::validate_instance_id(instance_id)?;
43 if matches!(conflict_policy, ConflictPolicy::TerminateExisting) {
44 return Ok(None);
45 }
46 match backend.load_snapshot(instance_id).await {
47 Ok(existing) => {
48 if existing.definition_hash != *definition_hash {
49 return Err(WorkflowError::DefinitionMismatch {
50 expected: *definition_hash,
51 found: existing.definition_hash,
52 }
53 .into());
54 }
55 match conflict_policy {
56 ConflictPolicy::Fail => {
57 Err(RuntimeError::InstanceAlreadyExists(instance_id.to_string()))
58 }
59 ConflictPolicy::UseExisting => {
60 let output = existing.state.completed_output().cloned();
61 let status = existing.state.as_status();
62 Ok(Some((status, output)))
63 }
64 ConflictPolicy::TerminateExisting => unreachable!(),
65 }
66 }
67 Err(sayiir_persistence::BackendError::NotFound(_)) => Ok(None),
68 Err(e) => Err(e.into()),
69 }
70}
71
72#[tracing::instrument(
84 name = "lifecycle.prepare_run",
85 skip(input_bytes, backend),
86 fields(%instance_id),
87)]
88pub async fn prepare_run<B>(
89 instance_id: &str,
90 definition_hash: sayiir_core::DefinitionHash,
91 input_bytes: Bytes,
92 first_task: TaskHint,
93 backend: &B,
94 conflict_policy: ConflictPolicy,
95) -> Result<PrepareRunOutcome, RuntimeError>
96where
97 B: SnapshotStore + SignalStore,
98{
99 sayiir_core::validate_instance_id(instance_id)?;
100 if matches!(conflict_policy, ConflictPolicy::TerminateExisting) {
101 match backend.load_snapshot(instance_id).await {
103 Ok(_existing) => {
104 tracing::info!("terminating existing instance before restart");
105 backend.delete_snapshot(instance_id).await?;
106 backend
107 .clear_signal(instance_id, SignalKind::Cancel)
108 .await?;
109 backend.clear_signal(instance_id, SignalKind::Pause).await?;
110 }
111 Err(sayiir_persistence::BackendError::NotFound(_)) => {}
112 Err(e) => return Err(e.into()),
113 }
114 }
115 let mut snapshot =
116 WorkflowSnapshot::with_initial_input(instance_id, definition_hash, input_bytes);
117 #[cfg(feature = "otel")]
118 {
119 snapshot.trace_parent = crate::trace_context::current_trace_parent();
120 }
121 snapshot.update_position(ExecutionPosition::AtTask {
122 task_id: first_task.id,
123 });
124 snapshot.set_task_hint(&first_task);
125 backend.save_snapshot(&mut snapshot).await?;
126 Ok(PrepareRunOutcome::Fresh(Box::new(snapshot)))
127}
128
129#[tracing::instrument(
140 name = "lifecycle.prepare_resume",
141 skip(backend),
142 fields(%instance_id),
143)]
144pub async fn prepare_resume<B>(
145 instance_id: &str,
146 definition_hash: &sayiir_core::DefinitionHash,
147 backend: &B,
148) -> Result<ResumeOutcome, RuntimeError>
149where
150 B: SignalStore,
151{
152 sayiir_core::validate_instance_id(instance_id)?;
153 tracing::debug!("preparing workflow resume");
154 let mut snapshot = backend.load_snapshot(instance_id).await?;
155
156 if snapshot.definition_hash != *definition_hash {
158 return Err(WorkflowError::DefinitionMismatch {
159 expected: *definition_hash,
160 found: snapshot.definition_hash,
161 }
162 .into());
163 }
164
165 if let Some(status) = snapshot.state.as_terminal_status() {
167 if snapshot.state.is_paused() {
168 return Ok(ResumeOutcome::Paused(status));
169 }
170 return Ok(ResumeOutcome::AlreadyTerminal(status));
171 }
172
173 let parked = ResumeParkedPosition::extract(&snapshot);
177 if let Some(status) = parked.resolve(&mut snapshot, instance_id, backend).await? {
178 return Ok(ResumeOutcome::NotReady(status));
179 }
180
181 let input_bytes = get_resume_input(&snapshot)?;
183 Ok(ResumeOutcome::Ready {
184 snapshot: Box::new(snapshot),
185 input_bytes,
186 })
187}
188
189#[derive(Debug)]
191pub enum ResumeOutcome {
192 Ready {
194 snapshot: Box<WorkflowSnapshot>,
196 input_bytes: Bytes,
198 },
199 AlreadyTerminal(WorkflowStatus),
201 Paused(WorkflowStatus),
203 NotReady(WorkflowStatus),
205}
206
207pub fn get_resume_input(snapshot: &WorkflowSnapshot) -> Result<Bytes, RuntimeError> {
215 match &snapshot.state {
216 WorkflowSnapshotState::InProgress {
217 completed_tasks, ..
218 } => {
219 if completed_tasks.is_empty() {
220 snapshot.initial_input_bytes().ok_or_else(|| {
221 WorkflowError::ResumeError(
222 "no completed tasks and initial input not stored".into(),
223 )
224 .into()
225 })
226 } else {
227 snapshot.get_last_task_output().ok_or_else(|| {
228 WorkflowError::ResumeError("no task results available".into()).into()
229 })
230 }
231 }
232 _ => Err(WorkflowError::ResumeError("workflow not in progress".into()).into()),
233 }
234}
235
236#[tracing::instrument(
248 name = "lifecycle.finalize",
249 skip_all,
250 fields(instance_id = %snapshot.instance_id),
251)]
252pub async fn finalize_execution<B>(
253 result: Result<Bytes, RuntimeError>,
254 snapshot: &mut WorkflowSnapshot,
255 backend: &B,
256) -> Result<(WorkflowStatus, Option<Bytes>), RuntimeError>
257where
258 B: SnapshotStore,
259{
260 tracing::debug!("finalizing workflow execution");
261 match result {
262 Ok(output) => {
263 tracing::info!(instance_id = %snapshot.instance_id, "workflow completed");
264 snapshot.mark_completed(output.clone());
265 backend.save_snapshot(snapshot).await?;
266 Ok((WorkflowStatus::Completed, Some(output)))
267 }
268 Err(RuntimeError::Workflow(WorkflowError::Waiting { wake_at })) => {
269 let delay_id = match &snapshot.state {
270 WorkflowSnapshotState::InProgress {
271 position: ExecutionPosition::AtDelay { delay_id, .. },
272 ..
273 } => *delay_id,
274 WorkflowSnapshotState::InProgress {
275 position: ExecutionPosition::AtFork { fork_id, .. },
276 ..
277 } => *fork_id,
278 _ => sayiir_core::TaskId::default(),
279 };
280 tracing::info!(
281 instance_id = %snapshot.instance_id,
282 %delay_id,
283 %wake_at,
284 "workflow parked at delay"
285 );
286 Ok((WorkflowStatus::Waiting { wake_at, delay_id }, None))
287 }
288 Err(RuntimeError::Workflow(WorkflowError::AwaitingSignal {
289 signal_id,
290 signal_name,
291 wake_at,
292 })) => {
293 tracing::info!(
294 instance_id = %snapshot.instance_id,
295 %signal_id,
296 %signal_name,
297 ?wake_at,
298 "workflow parked at signal"
299 );
300 Ok((
301 WorkflowStatus::AwaitingSignal {
302 signal_id,
303 signal_name,
304 wake_at,
305 },
306 None,
307 ))
308 }
309 Err(RuntimeError::Workflow(WorkflowError::Cancelled { .. })) => {
310 tracing::info!(instance_id = %snapshot.instance_id, "workflow cancelled");
311 if let Ok(cancelled_snapshot) = backend.load_snapshot(&snapshot.instance_id).await
313 && let Some((reason, cancelled_by)) =
314 cancelled_snapshot.state.cancellation_details()
315 {
316 return Ok((
317 WorkflowStatus::Cancelled {
318 reason,
319 cancelled_by,
320 },
321 None,
322 ));
323 }
324 Ok((
326 WorkflowStatus::Cancelled {
327 reason: None,
328 cancelled_by: None,
329 },
330 None,
331 ))
332 }
333 Err(RuntimeError::Workflow(WorkflowError::Paused { .. })) => {
334 tracing::info!(instance_id = %snapshot.instance_id, "workflow paused");
335 if let Ok(paused_snapshot) = backend.load_snapshot(&snapshot.instance_id).await
337 && let Some((reason, paused_by)) = paused_snapshot.state.pause_details()
338 {
339 return Ok((WorkflowStatus::Paused { reason, paused_by }, None));
340 }
341 Ok((
342 WorkflowStatus::Paused {
343 reason: None,
344 paused_by: None,
345 },
346 None,
347 ))
348 }
349 Err(e) => {
350 tracing::error!(instance_id = %snapshot.instance_id, error = %e, "workflow failed");
351 snapshot.mark_failed(e.to_string());
352 let _ = backend.save_snapshot(snapshot).await;
353 Ok((WorkflowStatus::Failed(e.to_string()), None))
354 }
355 }
356}