Skip to main content

aion_core/
status.rs

1//! Workflow status projection from authoritative event history.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Event, RunId};
6
7/// Projected lifecycle status for a workflow execution.
8///
9/// Status must be obtained only by projecting from event history with
10/// [`status_from_events`], never assigned directly or stored as an independent
11/// mutable field. Event history remains authoritative for every workflow state.
12#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
13pub enum WorkflowStatus {
14    /// The workflow has not recorded a terminal lifecycle event.
15    Running,
16    /// The workflow recorded a [`Event::WorkflowCompleted`] terminal event.
17    Completed,
18    /// The workflow recorded a [`Event::WorkflowFailed`] terminal event.
19    Failed,
20    /// The workflow recorded a [`Event::WorkflowCancelled`] terminal event.
21    Cancelled,
22    /// The workflow recorded a [`Event::WorkflowTimedOut`] terminal event.
23    TimedOut,
24    /// The workflow recorded a [`Event::WorkflowContinuedAsNew`] terminal event.
25    ContinuedAsNew,
26}
27
28impl WorkflowStatus {
29    /// Returns whether this status represents a terminal workflow execution state.
30    #[must_use]
31    pub const fn is_terminal(self) -> bool {
32        match self {
33            Self::Running => false,
34            Self::Completed
35            | Self::Failed
36            | Self::Cancelled
37            | Self::TimedOut
38            | Self::ContinuedAsNew => true,
39        }
40    }
41}
42
43/// Projects workflow status from an event history.
44///
45/// The last terminal workflow lifecycle event determines the projected status.
46/// Histories without a terminal workflow event are considered running.
47/// When a history contains multiple runs for continue-as-new, a later
48/// [`Event::WorkflowStarted`] begins the current run and supersedes earlier
49/// terminal events from the previous run.
50#[must_use]
51pub fn status_from_events(events: &[Event]) -> WorkflowStatus {
52    events
53        .iter()
54        .rev()
55        .find_map(|event| match event {
56            // A run start and a reopen both put the run in Running. A reopen
57            // supersedes the run's prior terminal event under this same
58            // last-lifecycle-event-wins scan.
59            Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => {
60                Some(WorkflowStatus::Running)
61            }
62            Event::WorkflowCompleted { .. } => Some(WorkflowStatus::Completed),
63            Event::WorkflowFailed { .. } => Some(WorkflowStatus::Failed),
64            Event::WorkflowCancelled { .. } => Some(WorkflowStatus::Cancelled),
65            Event::WorkflowTimedOut { .. } => Some(WorkflowStatus::TimedOut),
66            Event::WorkflowContinuedAsNew { .. } => Some(WorkflowStatus::ContinuedAsNew),
67            Event::SearchAttributesUpdated { .. }
68            | Event::ActivityScheduled { .. }
69            | Event::ActivityStarted { .. }
70            | Event::ActivityCompleted { .. }
71            | Event::ActivityFailed { .. }
72            | Event::ActivityCancelled { .. }
73            | Event::TimerStarted { .. }
74            | Event::TimerFired { .. }
75            | Event::TimerCancelled { .. }
76            | Event::WithTimeoutCompleted { .. }
77            | Event::SignalReceived { .. }
78            | Event::SignalSent { .. }
79            | Event::ChildWorkflowStarted { .. }
80            | Event::ChildWorkflowCompleted { .. }
81            | Event::ChildWorkflowFailed { .. }
82            | Event::ChildWorkflowCancelled { .. }
83            | Event::ScheduleCreated { .. }
84            | Event::ScheduleUpdated { .. }
85            | Event::SchedulePaused { .. }
86            | Event::ScheduleResumed { .. }
87            | Event::ScheduleDeleted { .. }
88            | Event::ScheduleTriggered { .. } => None,
89        })
90        .unwrap_or(WorkflowStatus::Running)
91}
92
93/// Returns the terminal lifecycle event of the run's current lease, or `None`
94/// when the run is not currently terminal — either it never recorded a terminal
95/// event, or a later [`Event::WorkflowReopened`] reopened it.
96///
97/// This is the single reset-aware terminal predicate every site derives from:
98/// close-time is its `recorded_at`, the terminal outcome is a match on the
99/// returned event, and "is the run terminal now" is `is_some()`. Scanning back
100/// from the end it stops at the first reset point (a run start or a reopen), so
101/// terminality is scoped to "since the last reopen point" — a run holds exactly
102/// one terminal event per lease.
103#[must_use]
104pub fn current_lease_terminal(events: &[Event]) -> Option<&Event> {
105    events
106        .iter()
107        .rev()
108        .find_map(|event| match event {
109            Event::WorkflowCompleted { .. }
110            | Event::WorkflowFailed { .. }
111            | Event::WorkflowCancelled { .. }
112            | Event::WorkflowTimedOut { .. }
113            | Event::WorkflowContinuedAsNew { .. } => Some(Some(event)),
114            // Reset points: the current lease has no terminal before them.
115            Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. } => Some(None),
116            Event::SearchAttributesUpdated { .. }
117            | Event::ActivityScheduled { .. }
118            | Event::ActivityStarted { .. }
119            | Event::ActivityCompleted { .. }
120            | Event::ActivityFailed { .. }
121            | Event::ActivityCancelled { .. }
122            | Event::TimerStarted { .. }
123            | Event::TimerFired { .. }
124            | Event::TimerCancelled { .. }
125            | Event::WithTimeoutCompleted { .. }
126            | Event::SignalReceived { .. }
127            | Event::SignalSent { .. }
128            | Event::ChildWorkflowStarted { .. }
129            | Event::ChildWorkflowCompleted { .. }
130            | Event::ChildWorkflowFailed { .. }
131            | Event::ChildWorkflowCancelled { .. }
132            | Event::ScheduleCreated { .. }
133            | Event::ScheduleUpdated { .. }
134            | Event::SchedulePaused { .. }
135            | Event::ScheduleResumed { .. }
136            | Event::ScheduleDeleted { .. }
137            | Event::ScheduleTriggered { .. } => None,
138        })
139        .flatten()
140}
141
142/// Returns the slice of `events` belonging to the run identified by `run_id`:
143/// from that run's [`Event::WorkflowStarted`] up to (but excluding) the next
144/// run's start, or an empty slice when the run is absent.
145///
146/// Scoping [`current_lease_terminal`] to a `run_segment` answers "is this
147/// particular run currently terminal" without a separate bespoke scan per call
148/// site.
149#[must_use]
150pub fn run_segment<'a>(events: &'a [Event], run_id: &RunId) -> &'a [Event] {
151    let Some(start) = events.iter().position(
152        |event| matches!(event, Event::WorkflowStarted { run_id: id, .. } if id == run_id),
153    ) else {
154        return &[];
155    };
156    let end = events[start + 1..]
157        .iter()
158        .position(|event| matches!(event, Event::WorkflowStarted { .. }))
159        .map_or(events.len(), |offset| start + 1 + offset);
160    &events[start..end]
161}
162
163#[cfg(test)]
164mod tests {
165    use std::collections::HashMap;
166
167    use chrono::{DateTime, Utc};
168    use serde_json::json;
169
170    use super::{WorkflowStatus, current_lease_terminal, run_segment, status_from_events};
171    use crate::{
172        ActivityId, Event, EventEnvelope, Payload, RunId, ScheduleId, SearchAttributeValue,
173        WorkflowError, WorkflowId,
174    };
175
176    fn recorded_at(offset: i64) -> DateTime<Utc> {
177        DateTime::from_timestamp(1_700_000_000 + offset, 0).unwrap_or_default()
178    }
179
180    fn envelope(seq: u64) -> EventEnvelope {
181        EventEnvelope {
182            seq,
183            recorded_at: recorded_at(i64::try_from(seq).unwrap_or(0)),
184            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
185        }
186    }
187
188    fn payload(label: &str) -> Result<Payload, crate::PayloadError> {
189        Payload::from_json(&json!({ "label": label }))
190    }
191
192    fn workflow_started(seq: u64) -> Result<Event, crate::PayloadError> {
193        Ok(Event::WorkflowStarted {
194            envelope: envelope(seq),
195            workflow_type: String::from("checkout"),
196            input: payload("input")?,
197            run_id: RunId::new(uuid::Uuid::from_u128(1)),
198            parent_run_id: None,
199            package_version: crate::PackageVersion::new("a".repeat(64)),
200        })
201    }
202
203    fn workflow_error(message: &str) -> WorkflowError {
204        WorkflowError {
205            message: String::from(message),
206            details: None,
207        }
208    }
209
210    #[test]
211    fn empty_history_projects_to_running() {
212        assert_eq!(status_from_events(&[]), WorkflowStatus::Running);
213    }
214
215    #[test]
216    fn replacement_start_projects_continue_as_new_chain_running() -> Result<(), crate::PayloadError>
217    {
218        let parent_run_id = RunId::new(uuid::Uuid::from_u128(7));
219        let events = vec![
220            workflow_started(1)?,
221            Event::WorkflowContinuedAsNew {
222                envelope: envelope(2),
223                input: payload("replacement")?,
224                workflow_type: None,
225                parent_run_id: parent_run_id.clone(),
226            },
227            Event::WorkflowStarted {
228                envelope: envelope(3),
229                workflow_type: String::from("checkout"),
230                input: payload("replacement")?,
231                run_id: RunId::new(uuid::Uuid::from_u128(1)),
232                parent_run_id: Some(parent_run_id),
233                package_version: crate::PackageVersion::new("a".repeat(64)),
234            },
235        ];
236
237        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
238        Ok(())
239    }
240
241    #[test]
242    fn completed_terminal_event_projects_to_completed() -> Result<(), Box<dyn std::error::Error>> {
243        let events = vec![
244            workflow_started(1)?,
245            Event::WorkflowCompleted {
246                envelope: envelope(2),
247                result: payload("result")?,
248            },
249        ];
250
251        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
252        Ok(())
253    }
254
255    #[test]
256    fn failed_terminal_event_projects_to_failed() -> Result<(), Box<dyn std::error::Error>> {
257        let events = vec![
258            workflow_started(1)?,
259            Event::WorkflowFailed {
260                envelope: envelope(2),
261                error: workflow_error("failed"),
262            },
263        ];
264
265        assert_eq!(status_from_events(&events), WorkflowStatus::Failed);
266        Ok(())
267    }
268
269    #[test]
270    fn cancelled_terminal_event_projects_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
271        let events = vec![
272            workflow_started(1)?,
273            Event::WorkflowCancelled {
274                envelope: envelope(2),
275                reason: String::from("caller requested cancellation"),
276            },
277        ];
278
279        assert_eq!(status_from_events(&events), WorkflowStatus::Cancelled);
280        Ok(())
281    }
282
283    #[test]
284    fn timed_out_terminal_event_projects_to_timed_out() -> Result<(), Box<dyn std::error::Error>> {
285        let events = vec![
286            workflow_started(1)?,
287            Event::WorkflowTimedOut {
288                envelope: envelope(2),
289                timeout: String::from("execution"),
290            },
291        ];
292
293        assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
294        Ok(())
295    }
296
297    #[test]
298    fn continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
299        let events = vec![
300            workflow_started(1)?,
301            Event::WorkflowContinuedAsNew {
302                envelope: envelope(2),
303                input: payload("continued-input")?,
304                workflow_type: Some(String::from("checkout-v2")),
305                parent_run_id: RunId::new(uuid::Uuid::from_u128(2)),
306            },
307        ];
308
309        assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
310        Ok(())
311    }
312
313    #[test]
314    fn workflow_status_terminality_classifies_running_and_terminal_statuses() {
315        assert!(!WorkflowStatus::Running.is_terminal());
316        assert!(WorkflowStatus::Completed.is_terminal());
317        assert!(WorkflowStatus::Failed.is_terminal());
318        assert!(WorkflowStatus::Cancelled.is_terminal());
319        assert!(WorkflowStatus::TimedOut.is_terminal());
320        assert!(WorkflowStatus::ContinuedAsNew.is_terminal());
321    }
322
323    #[test]
324    fn started_then_continued_as_new_projects_status() -> Result<(), Box<dyn std::error::Error>> {
325        let events = vec![
326            workflow_started(1)?,
327            Event::WorkflowContinuedAsNew {
328                envelope: envelope(2),
329                input: payload("continued-input")?,
330                workflow_type: None,
331                parent_run_id: RunId::new(uuid::Uuid::from_u128(3)),
332            },
333        ];
334
335        assert_eq!(status_from_events(&events), WorkflowStatus::ContinuedAsNew);
336        Ok(())
337    }
338
339    #[test]
340    fn non_terminal_history_projects_to_running() -> Result<(), Box<dyn std::error::Error>> {
341        let events = vec![
342            workflow_started(1)?,
343            Event::SearchAttributesUpdated {
344                envelope: envelope(2),
345                workflow_id: WorkflowId::new(uuid::Uuid::nil()),
346                attributes: HashMap::from([(
347                    String::from("customer_id"),
348                    SearchAttributeValue::String(String::from("customer-123")),
349                )]),
350            },
351            Event::ActivityScheduled {
352                envelope: envelope(3),
353                activity_id: ActivityId::from_sequence_position(3),
354                activity_type: String::from("charge-card"),
355                input: payload("activity-input")?,
356                task_queue: String::from("default"),
357                node: None,
358            },
359        ];
360
361        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
362        Ok(())
363    }
364
365    #[test]
366    fn schedule_events_do_not_change_workflow_status() -> Result<(), Box<dyn std::error::Error>> {
367        let events = vec![
368            workflow_started(1)?,
369            Event::SchedulePaused {
370                envelope: envelope(2),
371                schedule_id: ScheduleId::new(uuid::Uuid::from_u128(2)),
372            },
373        ];
374
375        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
376        Ok(())
377    }
378
379    #[test]
380    fn projection_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
381        let events = vec![
382            workflow_started(1)?,
383            Event::WorkflowCompleted {
384                envelope: envelope(2),
385                result: payload("result")?,
386            },
387        ];
388
389        let first = status_from_events(&events);
390        let second = status_from_events(&events);
391
392        assert_eq!(first, second);
393        Ok(())
394    }
395
396    #[test]
397    fn last_terminal_lifecycle_event_determines_status() -> Result<(), Box<dyn std::error::Error>> {
398        let events = vec![
399            workflow_started(1)?,
400            Event::WorkflowCompleted {
401                envelope: envelope(2),
402                result: payload("result")?,
403            },
404            Event::WorkflowTimedOut {
405                envelope: envelope(3),
406                timeout: String::from("execution"),
407            },
408        ];
409
410        assert_eq!(status_from_events(&events), WorkflowStatus::TimedOut);
411        Ok(())
412    }
413
414    fn run_id() -> RunId {
415        RunId::new(uuid::Uuid::from_u128(1))
416    }
417
418    fn workflow_reopened(seq: u64, reopened: Vec<ActivityId>) -> Event {
419        Event::WorkflowReopened {
420            envelope: envelope(seq),
421            run_id: run_id(),
422            reopened,
423        }
424    }
425
426    #[test]
427    fn reopen_after_failure_projects_running() -> Result<(), Box<dyn std::error::Error>> {
428        let events = vec![
429            workflow_started(1)?,
430            Event::WorkflowFailed {
431                envelope: envelope(2),
432                error: workflow_error("transient"),
433            },
434            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
435        ];
436
437        assert_eq!(status_from_events(&events), WorkflowStatus::Running);
438        Ok(())
439    }
440
441    #[test]
442    fn reopen_then_new_terminal_projects_that_terminal() -> Result<(), Box<dyn std::error::Error>> {
443        let events = vec![
444            workflow_started(1)?,
445            Event::WorkflowFailed {
446                envelope: envelope(2),
447                error: workflow_error("transient"),
448            },
449            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
450            Event::WorkflowCompleted {
451                envelope: envelope(4),
452                result: payload("result")?,
453            },
454        ];
455
456        assert_eq!(status_from_events(&events), WorkflowStatus::Completed);
457        Ok(())
458    }
459
460    #[test]
461    fn current_lease_terminal_is_none_for_running_and_reopened()
462    -> Result<(), Box<dyn std::error::Error>> {
463        let running = vec![workflow_started(1)?];
464        assert!(current_lease_terminal(&running).is_none());
465
466        let failed = vec![
467            workflow_started(1)?,
468            Event::WorkflowFailed {
469                envelope: envelope(2),
470                error: workflow_error("boom"),
471            },
472        ];
473        assert!(matches!(
474            current_lease_terminal(&failed),
475            Some(Event::WorkflowFailed { .. })
476        ));
477
478        let mut reopened = failed.clone();
479        reopened.push(workflow_reopened(
480            3,
481            vec![ActivityId::from_sequence_position(2)],
482        ));
483        assert!(
484            current_lease_terminal(&reopened).is_none(),
485            "a reopened run has no current-lease terminal"
486        );
487        Ok(())
488    }
489
490    #[test]
491    fn current_lease_terminal_returns_terminal_after_reopen_and_retermination()
492    -> Result<(), Box<dyn std::error::Error>> {
493        let events = vec![
494            workflow_started(1)?,
495            Event::WorkflowFailed {
496                envelope: envelope(2),
497                error: workflow_error("boom"),
498            },
499            workflow_reopened(3, vec![ActivityId::from_sequence_position(2)]),
500            Event::WorkflowCompleted {
501                envelope: envelope(4),
502                result: payload("result")?,
503            },
504        ];
505
506        assert!(matches!(
507            current_lease_terminal(&events),
508            Some(Event::WorkflowCompleted { .. })
509        ));
510        Ok(())
511    }
512
513    #[test]
514    fn run_segment_scopes_to_the_named_run() -> Result<(), Box<dyn std::error::Error>> {
515        let first_run = RunId::new(uuid::Uuid::from_u128(10));
516        let second_run = RunId::new(uuid::Uuid::from_u128(20));
517        let events = vec![
518            Event::WorkflowStarted {
519                envelope: envelope(1),
520                workflow_type: String::from("checkout"),
521                input: payload("input")?,
522                run_id: first_run.clone(),
523                parent_run_id: None,
524                package_version: crate::PackageVersion::new("a".repeat(64)),
525            },
526            Event::WorkflowContinuedAsNew {
527                envelope: envelope(2),
528                input: payload("again")?,
529                workflow_type: None,
530                parent_run_id: first_run.clone(),
531            },
532            Event::WorkflowStarted {
533                envelope: envelope(3),
534                workflow_type: String::from("checkout"),
535                input: payload("input")?,
536                run_id: second_run.clone(),
537                parent_run_id: Some(first_run.clone()),
538                package_version: crate::PackageVersion::new("a".repeat(64)),
539            },
540            Event::WorkflowCompleted {
541                envelope: envelope(4),
542                result: payload("result")?,
543            },
544        ];
545
546        let first = run_segment(&events, &first_run);
547        assert_eq!(first.len(), 2, "first run spans its start through its CAN");
548        assert!(matches!(
549            current_lease_terminal(first),
550            Some(Event::WorkflowContinuedAsNew { .. })
551        ));
552
553        let second = run_segment(&events, &second_run);
554        assert_eq!(
555            second.len(),
556            2,
557            "second run spans its start through completion"
558        );
559        assert!(matches!(
560            current_lease_terminal(second),
561            Some(Event::WorkflowCompleted { .. })
562        ));
563
564        assert!(
565            run_segment(&events, &RunId::new(uuid::Uuid::from_u128(99))).is_empty(),
566            "absent run yields an empty segment"
567        );
568        Ok(())
569    }
570}