Skip to main content

studio_worker/
lifecycle.rs

1//! Per-model lifecycle state machine (see `docs/runtime/model-lifecycle.md`).
2//!
3//! Pure: it decides what the model host must do next, and the host
4//! reports back when that work finishes.  Admission and exclusive groups
5//! are the host's concern; this only guards one model's transitions.
6
7/// Where one catalogue model is.  Only the worker changes it.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ModelState {
10    Unloaded,
11    Loading,
12    Loaded,
13    Unloading,
14    Failed { reason: String },
15}
16
17impl ModelState {
18    /// The name used on the wire and in logs.
19    pub fn name(&self) -> &'static str {
20        match self {
21            Self::Unloaded => "unloaded",
22            Self::Loading => "loading",
23            Self::Loaded => "loaded",
24            Self::Unloading => "unloading",
25            Self::Failed { .. } => "failed",
26        }
27    }
28
29    /// Whether requests may be served on the model's lane.
30    pub fn serves(&self) -> bool {
31        matches!(self, Self::Loaded)
32    }
33}
34
35/// Work the host must start after a transition.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Command {
38    None,
39    BeginLoad,
40    BeginUnload,
41}
42
43/// A finish event arrived in a state that never started that work.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45#[error("{event} is unexpected while {state}")]
46pub struct UnexpectedEvent {
47    pub event: &'static str,
48    pub state: &'static str,
49}
50
51/// One model's lifecycle.
52#[derive(Debug, Clone)]
53pub struct Lifecycle {
54    state: ModelState,
55    /// An unload asked for while loading; runs once the load succeeds.
56    unload_pending: bool,
57}
58
59impl Default for Lifecycle {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl Lifecycle {
66    pub fn new() -> Self {
67        Self {
68            state: ModelState::Unloaded,
69            unload_pending: false,
70        }
71    }
72
73    pub fn state(&self) -> &ModelState {
74        &self.state
75    }
76
77    pub fn request_load(&mut self) -> Command {
78        match self.state {
79            ModelState::Unloaded | ModelState::Failed { .. } => {
80                self.state = ModelState::Loading;
81                Command::BeginLoad
82            }
83            ModelState::Loading => {
84                self.unload_pending = false;
85                Command::None
86            }
87            ModelState::Loaded | ModelState::Unloading => Command::None,
88        }
89    }
90
91    pub fn request_unload(&mut self) -> Command {
92        match self.state {
93            ModelState::Loaded => {
94                self.state = ModelState::Unloading;
95                Command::BeginUnload
96            }
97            ModelState::Loading => {
98                self.unload_pending = true;
99                Command::None
100            }
101            ModelState::Failed { .. } => {
102                self.state = ModelState::Unloaded;
103                Command::None
104            }
105            ModelState::Unloaded | ModelState::Unloading => Command::None,
106        }
107    }
108
109    pub fn load_finished(
110        &mut self,
111        outcome: Result<(), String>,
112    ) -> Result<Command, UnexpectedEvent> {
113        if self.state != ModelState::Loading {
114            return Err(self.unexpected("load_finished"));
115        }
116        let unload_pending = std::mem::take(&mut self.unload_pending);
117        match outcome {
118            Ok(()) if unload_pending => {
119                self.state = ModelState::Unloading;
120                Ok(Command::BeginUnload)
121            }
122            Ok(()) => {
123                self.state = ModelState::Loaded;
124                Ok(Command::None)
125            }
126            Err(reason) => {
127                self.state = ModelState::Failed { reason };
128                Ok(Command::None)
129            }
130        }
131    }
132
133    pub fn unload_finished(
134        &mut self,
135        outcome: Result<(), String>,
136    ) -> Result<Command, UnexpectedEvent> {
137        if self.state != ModelState::Unloading {
138            return Err(self.unexpected("unload_finished"));
139        }
140        self.state = match outcome {
141            Ok(()) => ModelState::Unloaded,
142            Err(reason) => ModelState::Failed { reason },
143        };
144        Ok(Command::None)
145    }
146
147    fn unexpected(&self, event: &'static str) -> UnexpectedEvent {
148        UnexpectedEvent {
149            event,
150            state: self.state.name(),
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn loaded() -> Lifecycle {
160        let mut l = Lifecycle::new();
161        assert_eq!(l.request_load(), Command::BeginLoad);
162        assert_eq!(l.load_finished(Ok(())), Ok(Command::None));
163        l
164    }
165
166    #[test]
167    fn starts_unloaded() {
168        assert_eq!(Lifecycle::new().state(), &ModelState::Unloaded);
169    }
170
171    #[test]
172    fn load_moves_unloaded_to_loading_then_loaded() {
173        let mut l = Lifecycle::new();
174        assert_eq!(l.request_load(), Command::BeginLoad);
175        assert_eq!(l.state(), &ModelState::Loading);
176        assert_eq!(l.load_finished(Ok(())), Ok(Command::None));
177        assert_eq!(l.state(), &ModelState::Loaded);
178    }
179
180    #[test]
181    fn load_failure_moves_to_failed_with_the_reason() {
182        let mut l = Lifecycle::new();
183        l.request_load();
184        assert_eq!(l.load_finished(Err("cuda oom".into())), Ok(Command::None));
185        assert_eq!(
186            l.state(),
187            &ModelState::Failed {
188                reason: "cuda oom".into()
189            }
190        );
191    }
192
193    #[test]
194    fn load_is_a_noop_while_loading_or_loaded() {
195        let mut l = Lifecycle::new();
196        l.request_load();
197        assert_eq!(l.request_load(), Command::None);
198        assert_eq!(l.state(), &ModelState::Loading);
199        let mut l = loaded();
200        assert_eq!(l.request_load(), Command::None);
201        assert_eq!(l.state(), &ModelState::Loaded);
202    }
203
204    #[test]
205    fn load_retries_from_failed() {
206        let mut l = Lifecycle::new();
207        l.request_load();
208        l.load_finished(Err("x".into())).unwrap();
209        assert_eq!(l.request_load(), Command::BeginLoad);
210        assert_eq!(l.state(), &ModelState::Loading);
211    }
212
213    #[test]
214    fn unload_moves_loaded_to_unloading_then_unloaded() {
215        let mut l = loaded();
216        assert_eq!(l.request_unload(), Command::BeginUnload);
217        assert_eq!(l.state(), &ModelState::Unloading);
218        assert_eq!(l.unload_finished(Ok(())), Ok(Command::None));
219        assert_eq!(l.state(), &ModelState::Unloaded);
220    }
221
222    #[test]
223    fn unload_failure_moves_to_failed() {
224        let mut l = loaded();
225        l.request_unload();
226        l.unload_finished(Err("stuck".into())).unwrap();
227        assert_eq!(
228            l.state(),
229            &ModelState::Failed {
230                reason: "stuck".into()
231            }
232        );
233    }
234
235    #[test]
236    fn unload_is_a_noop_while_unloaded_or_unloading() {
237        let mut l = Lifecycle::new();
238        assert_eq!(l.request_unload(), Command::None);
239        assert_eq!(l.state(), &ModelState::Unloaded);
240        let mut l = loaded();
241        l.request_unload();
242        assert_eq!(l.request_unload(), Command::None);
243        assert_eq!(l.state(), &ModelState::Unloading);
244    }
245
246    #[test]
247    fn unload_clears_a_failed_model() {
248        let mut l = Lifecycle::new();
249        l.request_load();
250        l.load_finished(Err("x".into())).unwrap();
251        assert_eq!(l.request_unload(), Command::None);
252        assert_eq!(l.state(), &ModelState::Unloaded);
253    }
254
255    #[test]
256    fn unload_while_loading_runs_after_the_load_succeeds() {
257        let mut l = Lifecycle::new();
258        l.request_load();
259        assert_eq!(l.request_unload(), Command::None);
260        assert_eq!(l.state(), &ModelState::Loading);
261        assert_eq!(l.load_finished(Ok(())), Ok(Command::BeginUnload));
262        assert_eq!(l.state(), &ModelState::Unloading);
263    }
264
265    #[test]
266    fn unload_while_loading_is_dropped_when_the_load_fails() {
267        let mut l = Lifecycle::new();
268        l.request_load();
269        l.request_unload();
270        assert_eq!(l.load_finished(Err("x".into())), Ok(Command::None));
271        assert!(matches!(l.state(), ModelState::Failed { .. }));
272    }
273
274    #[test]
275    fn load_after_a_pending_unload_cancels_it() {
276        let mut l = Lifecycle::new();
277        l.request_load();
278        l.request_unload();
279        assert_eq!(l.request_load(), Command::None);
280        assert_eq!(l.load_finished(Ok(())), Ok(Command::None));
281        assert_eq!(l.state(), &ModelState::Loaded);
282    }
283
284    #[test]
285    fn a_finish_event_in_the_wrong_state_is_refused() {
286        let mut l = Lifecycle::new();
287        assert_eq!(
288            l.load_finished(Ok(())),
289            Err(UnexpectedEvent {
290                event: "load_finished",
291                state: "unloaded"
292            })
293        );
294        assert_eq!(
295            l.unload_finished(Ok(())),
296            Err(UnexpectedEvent {
297                event: "unload_finished",
298                state: "unloaded"
299            })
300        );
301        assert_eq!(l.state(), &ModelState::Unloaded);
302    }
303
304    #[test]
305    fn state_names_are_the_wire_names() {
306        assert_eq!(ModelState::Unloaded.name(), "unloaded");
307        assert_eq!(ModelState::Loading.name(), "loading");
308        assert_eq!(ModelState::Loaded.name(), "loaded");
309        assert_eq!(ModelState::Unloading.name(), "unloading");
310        assert_eq!(ModelState::Failed { reason: "r".into() }.name(), "failed");
311    }
312
313    #[test]
314    fn only_loaded_serves() {
315        assert!(ModelState::Loaded.serves());
316        for s in [
317            ModelState::Unloaded,
318            ModelState::Loading,
319            ModelState::Unloading,
320            ModelState::Failed { reason: "r".into() },
321        ] {
322            assert!(!s.serves(), "{} must not serve", s.name());
323        }
324    }
325
326    #[test]
327    fn unexpected_event_displays_event_and_state() {
328        let e = UnexpectedEvent {
329            event: "load_finished",
330            state: "loaded",
331        };
332        assert_eq!(e.to_string(), "load_finished is unexpected while loaded");
333    }
334}