Skip to main content

solti_model/domain/policy/
restart.rs

1//! # Restart policy.
2//!
3//! [`RestartPolicy`] controls when a task is restarted after completion or failure.
4
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8use crate::error::{ModelError, ModelResult};
9
10/// Determines whether a task should be automatically restarted after completion or failure.
11///
12/// | Variant     | Behaviour                                                    |
13/// |-------------|--------------------------------------------------------------|
14/// | `Never`     | Do not restart under any circumstances                       |
15/// | `OnFailure` | Restart only when the task ends with an error                |
16/// | `Always`    | Restart unconditionally (immediate or periodic via interval) |
17///
18/// `Always { interval_ms: None }` restarts immediately;
19/// `Always { interval_ms: Some(N) }` waits N ms between runs (periodic task).
20///
21/// Cancellation (via controller or shutdown) is **not** treated as failure and will not trigger a restart.
22///
23/// ## Also
24///
25/// - [`BackoffPolicy`](super::BackoffPolicy) delay between restart attempts.
26/// - [`TaskSpec`](crate::TaskSpec) carries `restart` as a field.
27#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "camelCase")]
29#[non_exhaustive]
30pub enum RestartPolicy {
31    /// Never restart the task.
32    #[default]
33    Never,
34    /// Restart the task only if it failed (non-zero exit, error, panic, etc.).
35    OnFailure,
36    /// Always restart after completion.
37    ///
38    /// - `interval_ms: None` - restart immediately after the previous run completes (tight loop tempered only by [`BackoffPolicy`](super::BackoffPolicy) on failure).
39    /// - `interval_ms: Some(n)` - wait `n` milliseconds between runs (periodic task).
40    /// - `Some(0)` is treated as immediate and is semantically identical to `None`; prefer `None` for clarity.
41    ///
42    /// **Caution:** `interval_ms: None` (or `Some(0)`) is appropriate for a task
43    /// that *blocks* (a long-running service that should respawn immediately if it
44    /// ever exits). For a task that returns *quickly* on success it produces a hot
45    /// restart loop with no delay floor — use `interval_ms: Some(n)` for anything
46    /// that completes fast and is meant to repeat.
47    #[serde(rename_all = "camelCase")]
48    Always {
49        #[serde(skip_serializing_if = "Option::is_none")]
50        interval_ms: Option<u64>,
51    },
52}
53
54impl RestartPolicy {
55    /// Create an Always policy without interval (immediate restart).
56    pub const fn always() -> Self {
57        RestartPolicy::Always { interval_ms: None }
58    }
59
60    /// Create an Always policy with periodic interval.
61    pub const fn periodic(interval_ms: u64) -> Self {
62        RestartPolicy::Always {
63            interval_ms: Some(interval_ms),
64        }
65    }
66}
67
68impl FromStr for RestartPolicy {
69    type Err = ModelError;
70
71    fn from_str(s: &str) -> ModelResult<Self> {
72        let original = s.trim();
73        if original.is_empty() {
74            return Ok(RestartPolicy::Never);
75        }
76
77        let (head, rest) = match original.find(':') {
78            Some(pos) => (&original[..pos], Some(original[pos + 1..].trim())),
79            None => (original, None),
80        };
81
82        if head.eq_ignore_ascii_case("never") {
83            Ok(RestartPolicy::Never)
84        } else if head.eq_ignore_ascii_case("on-failure") || head.eq_ignore_ascii_case("failure") {
85            Ok(RestartPolicy::OnFailure)
86        } else if head.eq_ignore_ascii_case("always") {
87            let interval_ms = match rest {
88                None | Some("") => None,
89                Some(v) => {
90                    let v = v.parse::<u64>().map_err(|_| {
91                        ModelError::UnknownRestart(format!(
92                            "invalid interval in '{}': must be u64",
93                            original
94                        ))
95                    })?;
96                    Some(v)
97                }
98            };
99            Ok(RestartPolicy::Always { interval_ms })
100        } else {
101            Err(ModelError::UnknownRestart(original.to_string()))
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::RestartPolicy;
109    use crate::error::ModelError;
110    use std::str::FromStr;
111
112    #[test]
113    fn parse_never_and_empty() {
114        assert_eq!(RestartPolicy::from_str("").unwrap(), RestartPolicy::Never);
115        assert_eq!(
116            RestartPolicy::from_str("never").unwrap(),
117            RestartPolicy::Never
118        );
119        assert_eq!(
120            RestartPolicy::from_str("  NeVeR  ").unwrap(),
121            RestartPolicy::Never
122        );
123    }
124
125    #[test]
126    fn parse_on_failure() {
127        assert_eq!(
128            RestartPolicy::from_str("on-failure").unwrap(),
129            RestartPolicy::OnFailure
130        );
131        assert_eq!(
132            RestartPolicy::from_str("failure").unwrap(),
133            RestartPolicy::OnFailure
134        );
135        assert_eq!(
136            RestartPolicy::from_str("  Failure ").unwrap(),
137            RestartPolicy::OnFailure
138        );
139    }
140
141    #[test]
142    fn parse_always_immediate() {
143        assert_eq!(
144            RestartPolicy::from_str("always").unwrap(),
145            RestartPolicy::Always { interval_ms: None }
146        );
147        assert_eq!(
148            RestartPolicy::from_str("  ALWAYS  ").unwrap(),
149            RestartPolicy::Always { interval_ms: None }
150        );
151        assert_eq!(
152            RestartPolicy::from_str("always:").unwrap(),
153            RestartPolicy::Always { interval_ms: None }
154        );
155        assert_eq!(
156            RestartPolicy::from_str("always:   ").unwrap(),
157            RestartPolicy::Always { interval_ms: None }
158        );
159    }
160
161    #[test]
162    fn parse_always_with_interval() {
163        assert_eq!(
164            RestartPolicy::from_str("always:1000").unwrap(),
165            RestartPolicy::Always {
166                interval_ms: Some(1000)
167            }
168        );
169        assert_eq!(
170            RestartPolicy::from_str(" Always:  60000 ").unwrap(),
171            RestartPolicy::Always {
172                interval_ms: Some(60000)
173            }
174        );
175    }
176
177    #[test]
178    fn parse_always_invalid_interval() {
179        let err = RestartPolicy::from_str("always:not-a-number").unwrap_err();
180        assert!(matches!(err, ModelError::UnknownRestart(_)));
181    }
182
183    #[test]
184    fn parse_unknown_head_fails() {
185        let err = RestartPolicy::from_str("random").unwrap_err();
186        assert!(matches!(err, ModelError::UnknownRestart(_)));
187    }
188}