Skip to main content

actionqueue_executor_local/
children.rs

1//! Dispatch-time snapshot of child task states.
2//!
3//! [`ChildrenSnapshot`] is an immutable view of child task states captured by
4//! the dispatch loop immediately before spawning a handler. Coordinator-pattern
5//! handlers read this snapshot to determine which children are complete and what
6//! steps to submit next.
7//!
8//! The snapshot is fully owned (no shared state, no locks), giving handlers a
9//! consistent point-in-time view without any contention with the dispatch loop.
10
11use actionqueue_core::ids::{RunId, TaskId};
12use actionqueue_core::run::state::RunState;
13
14/// Immutable snapshot of a single child task's run states.
15#[derive(Debug, Clone)]
16pub struct ChildState {
17    task_id: TaskId,
18    run_states: Vec<(RunId, RunState)>,
19    all_terminal: bool,
20}
21
22impl ChildState {
23    /// Creates a new child state entry.
24    ///
25    /// A child with an empty `run_states` vec (runs not yet derived) will have
26    /// `all_terminal == false`. This is intentional: the snapshot reflects the
27    /// true state — a child whose runs have not yet been derived is not
28    /// complete. Callers checking [`ChildrenSnapshot::all_children_terminal`]
29    /// will correctly treat such children as incomplete.
30    pub fn new(task_id: TaskId, run_states: Vec<(RunId, RunState)>) -> Self {
31        let all_terminal =
32            !run_states.is_empty() && run_states.iter().all(|(_, s)| s.is_terminal());
33        Self { task_id, run_states, all_terminal }
34    }
35
36    /// Returns the child task's identifier.
37    pub fn task_id(&self) -> TaskId {
38        self.task_id
39    }
40
41    /// Returns all run instances for this child task, with their current states.
42    pub fn run_states(&self) -> &[(RunId, RunState)] {
43        &self.run_states
44    }
45
46    /// Returns whether ALL runs for this child are in terminal states.
47    pub fn all_terminal(&self) -> bool {
48        self.all_terminal
49    }
50
51    /// Returns `true` if this child has at least one run instance.
52    pub fn has_runs(&self) -> bool {
53        !self.run_states.is_empty()
54    }
55}
56
57/// Immutable point-in-time snapshot of all child tasks' states.
58///
59/// Provided to Coordinator-pattern handlers so they can check progress
60/// and decide what to do next without accessing shared mutable state.
61#[derive(Debug, Clone, Default)]
62pub struct ChildrenSnapshot {
63    children: Vec<ChildState>,
64}
65
66impl ChildrenSnapshot {
67    /// Creates a snapshot from a list of child states.
68    pub fn new(children: Vec<ChildState>) -> Self {
69        Self { children }
70    }
71
72    /// Returns all child states in this snapshot.
73    pub fn children(&self) -> &[ChildState] {
74        &self.children
75    }
76
77    /// Returns `true` if there are no children (empty snapshot).
78    pub fn is_empty(&self) -> bool {
79        self.children.is_empty()
80    }
81
82    /// Returns the number of children in this snapshot.
83    pub fn len(&self) -> usize {
84        self.children.len()
85    }
86
87    /// Returns `true` if all children have all runs in terminal states.
88    pub fn all_children_terminal(&self) -> bool {
89        !self.children.is_empty() && self.children.iter().all(|c| c.all_terminal())
90    }
91
92    /// Returns the child state for the given task, if present.
93    pub fn get(&self, task_id: TaskId) -> Option<&ChildState> {
94        self.children.iter().find(|c| c.task_id() == task_id)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn tid() -> TaskId {
103        TaskId::new()
104    }
105
106    fn rid() -> RunId {
107        RunId::new()
108    }
109
110    #[test]
111    fn empty_snapshot() {
112        let snap = ChildrenSnapshot::default();
113        assert!(snap.is_empty());
114        assert!(!snap.all_children_terminal());
115        assert!(snap.children().is_empty());
116    }
117
118    #[test]
119    fn single_child_all_terminal() {
120        let snap =
121            ChildrenSnapshot::new(vec![ChildState::new(tid(), vec![(rid(), RunState::Completed)])]);
122        assert!(!snap.is_empty());
123        assert!(snap.all_children_terminal());
124    }
125
126    #[test]
127    fn single_child_not_terminal() {
128        let snap =
129            ChildrenSnapshot::new(vec![ChildState::new(tid(), vec![(rid(), RunState::Running)])]);
130        assert!(!snap.all_children_terminal());
131    }
132
133    #[test]
134    fn multiple_children_mixed() {
135        let snap = ChildrenSnapshot::new(vec![
136            ChildState::new(tid(), vec![(rid(), RunState::Completed)]),
137            ChildState::new(tid(), vec![(rid(), RunState::Running)]),
138        ]);
139        assert!(!snap.all_children_terminal());
140    }
141
142    #[test]
143    fn all_children_terminal_mixed_terminal_states() {
144        let snap = ChildrenSnapshot::new(vec![
145            ChildState::new(tid(), vec![(rid(), RunState::Completed)]),
146            ChildState::new(tid(), vec![(rid(), RunState::Failed)]),
147        ]);
148        assert!(snap.all_children_terminal());
149    }
150
151    #[test]
152    fn get_found_and_not_found() {
153        let known = tid();
154        let snap =
155            ChildrenSnapshot::new(vec![ChildState::new(known, vec![(rid(), RunState::Completed)])]);
156        assert!(snap.get(known).is_some());
157        assert!(snap.get(tid()).is_none());
158    }
159
160    #[test]
161    fn child_with_no_runs_not_terminal() {
162        let snap = ChildrenSnapshot::new(vec![ChildState::new(tid(), vec![])]);
163        assert!(!snap.all_children_terminal());
164        assert!(!snap.children()[0].all_terminal());
165    }
166
167    #[test]
168    fn child_with_no_runs_reports_has_runs_false() {
169        let child = ChildState::new(tid(), vec![]);
170        assert!(!child.has_runs());
171
172        let child_with_run = ChildState::new(tid(), vec![(rid(), RunState::Completed)]);
173        assert!(child_with_run.has_runs());
174    }
175}