Skip to main content

sysknife_daemon/
jobs.rs

1//! The job status transition table.
2//!
3//! [`allowed_transition`] is the single source of truth, called by both
4//! transaction stores before any status write.
5//!
6//! There used to be a `JobStateMachine` struct wrapping this table with
7//! `transition_to`/`cancel`/`is_terminal` helpers. Nothing in production ever
8//! constructed one — the stores call `allowed_transition` directly and hold
9//! status in the database, not in memory — so it was dead code whose ten tests
10//! read as if they covered the live transition logic. The table below is what
11//! actually runs, and the tests now exercise it directly.
12
13use sysknife_types::JobState;
14
15pub fn allowed_transition(current: &JobState, next: &JobState) -> bool {
16    matches!(
17        (current, next),
18        (JobState::Queued, JobState::Running)
19            | (JobState::Queued, JobState::Canceled)
20            | (JobState::Running, JobState::Succeeded)
21            | (JobState::Running, JobState::Failed)
22            | (JobState::Running, JobState::Canceled)
23            | (JobState::Running, JobState::RolledBack)
24            | (JobState::Running, JobState::NeedsReboot)
25    )
26}
27
28/// Is this a state no transition may leave?
29pub fn is_terminal(state: &JobState) -> bool {
30    matches!(
31        state,
32        JobState::Succeeded
33            | JobState::Failed
34            | JobState::Canceled
35            | JobState::RolledBack
36            | JobState::NeedsReboot
37    )
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    const ALL: &[JobState] = &[
45        JobState::Queued,
46        JobState::Running,
47        JobState::Succeeded,
48        JobState::Failed,
49        JobState::Canceled,
50        JobState::RolledBack,
51        JobState::NeedsReboot,
52    ];
53
54    /// The complete set of permitted edges. Anything absent must be refused.
55    const ALLOWED: &[(JobState, JobState)] = &[
56        (JobState::Queued, JobState::Running),
57        (JobState::Queued, JobState::Canceled),
58        (JobState::Running, JobState::Succeeded),
59        (JobState::Running, JobState::Failed),
60        (JobState::Running, JobState::Canceled),
61        (JobState::Running, JobState::RolledBack),
62        (JobState::Running, JobState::NeedsReboot),
63    ];
64
65    #[test]
66    fn every_permitted_edge_is_allowed() {
67        for (from, to) in ALLOWED {
68            assert!(
69                allowed_transition(from, to),
70                "{from:?} -> {to:?} must be permitted"
71            );
72        }
73    }
74
75    /// Exhaustive over the whole 7x7 space, so a new variant or a widened
76    /// match arm cannot quietly add an edge nobody intended.
77    #[test]
78    fn every_other_edge_is_refused() {
79        for from in ALL {
80            for to in ALL {
81                let permitted = ALLOWED.iter().any(|(f, t)| f == from && t == to);
82                assert_eq!(
83                    allowed_transition(from, to),
84                    permitted,
85                    "{from:?} -> {to:?}"
86                );
87            }
88        }
89    }
90
91    #[test]
92    fn terminal_states_cannot_be_left() {
93        for from in ALL.iter().filter(|s| is_terminal(s)) {
94            for to in ALL {
95                assert!(
96                    !allowed_transition(from, to),
97                    "terminal {from:?} must not transition to {to:?}"
98                );
99            }
100        }
101    }
102
103    #[test]
104    fn queued_and_running_are_not_terminal() {
105        assert!(!is_terminal(&JobState::Queued));
106        assert!(!is_terminal(&JobState::Running));
107    }
108
109    #[test]
110    fn a_job_cannot_restart_after_finishing() {
111        // The property the old struct tests were really about: once a job
112        // reaches NeedsReboot (or any terminal state) it cannot go Running
113        // again and be executed twice.
114        assert!(!allowed_transition(
115            &JobState::NeedsReboot,
116            &JobState::Running
117        ));
118        assert!(!allowed_transition(
119            &JobState::Succeeded,
120            &JobState::Running
121        ));
122        assert!(!allowed_transition(&JobState::Canceled, &JobState::Running));
123    }
124}