Skip to main content

actionqueue_engine/index/
terminal.rs

1//! Terminal index - runs that have completed their lifecycle.
2//!
3//! The terminal index holds run instances in terminal states (Completed,
4//! Failed, Canceled). These runs cannot transition to any other state
5//! and represent the final outcome of task execution.
6
7use actionqueue_core::run::run_instance::RunInstance;
8use actionqueue_core::run::state::RunState;
9
10/// A view of all runs in terminal states.
11///
12/// This structure provides filtering and traversal over runs that have
13/// reached a terminal state. Terminal runs cannot transition to any other
14/// state and represent completed task execution outcomes.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct TerminalIndex {
17    /// The runs in terminal states
18    runs: Vec<RunInstance>,
19}
20
21impl TerminalIndex {
22    /// Creates a new empty terminal index.
23    pub fn new() -> Self {
24        Self { runs: Vec::new() }
25    }
26
27    /// Creates a terminal index from a vector of runs.
28    ///
29    /// All runs must be in a terminal state (`Completed`, `Failed`, or
30    /// `Canceled`).
31    pub fn from_runs(runs: Vec<RunInstance>) -> Self {
32        debug_assert!(
33            runs.iter().all(|r| r.state().is_terminal()),
34            "TerminalIndex::from_runs called with non-terminal run"
35        );
36        Self { runs }
37    }
38
39    /// Returns all runs in the terminal index.
40    pub fn runs(&self) -> &[RunInstance] {
41        &self.runs
42    }
43
44    /// Returns the number of runs in the terminal index.
45    pub fn len(&self) -> usize {
46        self.runs.len()
47    }
48
49    /// Returns true if the index contains no runs.
50    pub fn is_empty(&self) -> bool {
51        self.runs.is_empty()
52    }
53
54    /// Returns only completed runs.
55    pub fn completed(&self) -> Vec<&RunInstance> {
56        self.runs.iter().filter(|run| run.state() == RunState::Completed).collect()
57    }
58
59    /// Returns only failed runs.
60    pub fn failed(&self) -> Vec<&RunInstance> {
61        self.runs.iter().filter(|run| run.state() == RunState::Failed).collect()
62    }
63
64    /// Returns only canceled runs.
65    pub fn canceled(&self) -> Vec<&RunInstance> {
66        self.runs.iter().filter(|run| run.state() == RunState::Canceled).collect()
67    }
68}
69
70impl From<&[RunInstance]> for TerminalIndex {
71    fn from(runs: &[RunInstance]) -> Self {
72        let terminal_runs: Vec<RunInstance> =
73            runs.iter().filter(|run| run.state().is_terminal()).cloned().collect();
74        Self::from_runs(terminal_runs)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use actionqueue_core::ids::TaskId;
81    use actionqueue_core::run::state::RunState;
82
83    use super::*;
84    use crate::index::test_util::build_run;
85
86    #[test]
87    fn terminal_index_filters_correctly() {
88        let now = 1000;
89        let task_id = TaskId::new();
90
91        // Create runs in different states
92        let runs = vec![
93            build_run(task_id, RunState::Ready, 900, now, 0, None),
94            build_run(task_id, RunState::Completed, 900, now, 0, None),
95            build_run(task_id, RunState::Failed, 950, now, 0, None),
96            build_run(task_id, RunState::Canceled, 800, now, 0, None),
97        ];
98
99        let index = TerminalIndex::from(runs.as_slice());
100
101        assert_eq!(index.len(), 3); // Completed, Failed, Canceled
102        assert!(index.runs().iter().all(|run| run.state().is_terminal()));
103    }
104
105    #[test]
106    fn terminal_index_breakdown() {
107        let now = 1000;
108        let task_id = TaskId::new();
109
110        let runs = vec![
111            build_run(task_id, RunState::Completed, 900, now, 0, None),
112            build_run(task_id, RunState::Completed, 950, now, 0, None),
113            build_run(task_id, RunState::Failed, 800, now, 0, None),
114        ];
115
116        let index = TerminalIndex::from(runs.as_slice());
117
118        assert_eq!(index.completed().len(), 2);
119        assert_eq!(index.failed().len(), 1);
120        assert_eq!(index.canceled().len(), 0);
121    }
122
123    #[test]
124    fn terminal_index_is_empty() {
125        let index = TerminalIndex::new();
126        assert!(index.is_empty());
127        assert_eq!(index.len(), 0);
128
129        let now = 1000;
130        let task_id = TaskId::new();
131        let run = build_run(task_id, RunState::Completed, 900, now, 0, None);
132        let index = TerminalIndex::from(std::slice::from_ref(&run));
133
134        assert!(!index.is_empty());
135        assert_eq!(index.len(), 1);
136    }
137
138    #[test]
139    fn terminal_index_preserves_order_and_state_purity() {
140        let now = 1000;
141        let task_id = TaskId::new();
142
143        let scheduled = build_run(task_id, RunState::Scheduled, 700, now, 0, None);
144        let completed = build_run(task_id, RunState::Completed, 710, now, 0, None);
145        let running = build_run(
146            task_id,
147            RunState::Running,
148            720,
149            now,
150            0,
151            Some(actionqueue_core::ids::AttemptId::new()),
152        );
153        let failed = build_run(task_id, RunState::Failed, 730, now, 0, None);
154        let canceled = build_run(task_id, RunState::Canceled, 740, now, 0, None);
155
156        let expected_order =
157            vec![completed.id().to_string(), failed.id().to_string(), canceled.id().to_string()];
158        let runs = vec![scheduled, completed, running, failed, canceled];
159        let index = TerminalIndex::from(runs.as_slice());
160
161        let actual_order: Vec<String> =
162            index.runs().iter().map(|run| run.id().to_string()).collect();
163        assert_eq!(actual_order, expected_order);
164        assert!(index.runs().iter().all(|run| run.state().is_terminal()));
165    }
166}