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