Skip to main content

optative_process_pool/
supervisor.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3use std::sync::mpsc;
4
5use optative::reconcile::ReconcileErrors;
6use tempfile::NamedTempFile;
7
8use crate::process::{ProcessIdentity, ProcessSource, ProcessState, SpawnError};
9use crate::resource::Resource;
10use crate::{ProcessPool, StreamItem};
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct ProcessSpec {
14    pub identity: ProcessIdentity,
15    pub args: Vec<Resource>,
16    pub env: BTreeMap<String, Resource>,
17    pub current_dir: Option<PathBuf>,
18    pub props: Option<serde_json::Value>,
19}
20
21struct CachedResolution {
22    spec: ProcessSpec,
23    source: ProcessSource,
24    _handles: Vec<NamedTempFile>,
25}
26
27fn resolve_spec(spec: &ProcessSpec) -> Result<(ProcessSource, Vec<NamedTempFile>), std::io::Error> {
28    let mut args = Vec::new();
29    let mut handles = Vec::new();
30
31    for resource in &spec.args {
32        let resolved = resource.resolve()?;
33        args.push(resolved.value);
34        if let Some(h) = resolved.handle {
35            handles.push(h);
36        }
37    }
38
39    let mut env = BTreeMap::new();
40    for (key, resource) in &spec.env {
41        let resolved = resource.resolve()?;
42        env.insert(key.clone(), resolved.value);
43        if let Some(h) = resolved.handle {
44            handles.push(h);
45        }
46    }
47
48    let source = ProcessSource {
49        identity: spec.identity.clone(),
50        args,
51        env,
52        current_dir: spec.current_dir.clone(),
53        props: spec.props.clone(),
54    };
55
56    Ok((source, handles))
57}
58
59pub struct ProcessSupervisor {
60    pool: ProcessPool,
61    states: HashMap<ProcessIdentity, CachedResolution>,
62}
63
64impl ProcessSupervisor {
65    pub fn new(stream_tx: mpsc::Sender<StreamItem>) -> Self {
66        Self {
67            pool: ProcessPool::new(stream_tx),
68            states: HashMap::new(),
69        }
70    }
71
72    pub fn reconcile(
73        &mut self,
74        desired: Vec<ProcessSpec>,
75    ) -> ReconcileErrors<ProcessIdentity, SpawnError> {
76        let mut resolved = Vec::new();
77        let mut new_states: HashMap<ProcessIdentity, CachedResolution> = HashMap::new();
78        let mut errors = ReconcileErrors::new();
79        let mut needs_restart = Vec::new();
80
81        for spec in desired {
82            let identity = spec.identity.clone();
83
84            if let Some(cached) = self.states.remove(&identity) {
85                if cached.spec == spec {
86                    resolved.push(cached.source.clone());
87                    new_states.insert(identity, cached);
88                    continue;
89                }
90                needs_restart.push(identity.clone());
91            }
92
93            match resolve_spec(&spec) {
94                Ok((source, handles)) => {
95                    resolved.push(source.clone());
96                    new_states.insert(
97                        identity,
98                        CachedResolution {
99                            spec,
100                            source,
101                            _handles: handles,
102                        },
103                    );
104                }
105                Err(e) => {
106                    errors.push((identity, SpawnError::ResourceResolutionFailed { source: e }));
107                }
108            }
109        }
110
111        // ProcessSource::reconcile_self doesn't detect arg changes, so changed
112        // specs need an explicit exit-then-enter cycle. First pass excludes
113        // them (pool exits the old process), second pass includes them (pool
114        // enters the new one).
115        if !needs_restart.is_empty() {
116            let without: Vec<ProcessSource> = resolved
117                .iter()
118                .filter(|s| !needs_restart.contains(&s.identity))
119                .cloned()
120                .collect();
121            self.pool.reconcile(without);
122        }
123
124        let pool_errors = self.pool.reconcile(resolved);
125        errors.extend(pool_errors);
126
127        self.states = new_states;
128        errors
129    }
130
131    pub fn get(&self, identity: &ProcessIdentity) -> Option<&ProcessState> {
132        self.pool.get(identity)
133    }
134
135    pub fn iter(&self) -> impl Iterator<Item = (&ProcessIdentity, &ProcessState)> {
136        self.pool.iter()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::time::Duration;
144
145    fn wait_for_line(rx: &mpsc::Receiver<StreamItem>, timeout: Duration) -> Option<StreamItem> {
146        rx.recv_timeout(timeout).ok()
147    }
148
149    #[test]
150    fn string_args_work_identically_to_process_pool() {
151        let (tx, rx) = mpsc::channel();
152        let mut supervisor = ProcessSupervisor::new(tx);
153
154        let errors = supervisor.reconcile(vec![ProcessSpec {
155            identity: ProcessIdentity {
156                bin: "/bin/sh".into(),
157                key: "echo".into(),
158            },
159            args: vec!["-c".into(), "echo hello_from_supervisor".into()],
160            env: BTreeMap::new(),
161            current_dir: None,
162            props: None,
163        }]);
164        assert!(errors.is_empty());
165
166        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
167        assert_eq!(item.line, "hello_from_supervisor");
168    }
169
170    #[test]
171    fn file_resource_is_passed_as_executable_script() {
172        let (tx, rx) = mpsc::channel();
173        let mut supervisor = ProcessSupervisor::new(tx);
174
175        let errors = supervisor.reconcile(vec![ProcessSpec {
176            identity: ProcessIdentity {
177                bin: "/bin/sh".into(),
178                key: "file-test".into(),
179            },
180            args: vec![Resource::File {
181                content: "echo from_file_resource".into(),
182            }],
183            env: BTreeMap::new(),
184            current_dir: None,
185            props: None,
186        }]);
187        assert!(errors.is_empty());
188
189        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("should receive stdout");
190        assert_eq!(item.line, "from_file_resource");
191    }
192
193    #[test]
194    fn unchanged_spec_does_not_restart_process() {
195        let (tx, rx) = mpsc::channel();
196        let mut supervisor = ProcessSupervisor::new(tx);
197
198        let spec = ProcessSpec {
199            identity: ProcessIdentity {
200                bin: "/bin/sh".into(),
201                key: "stable".into(),
202            },
203            args: vec!["-c".into(), "echo started; sleep 60".into()],
204            env: BTreeMap::new(),
205            current_dir: None,
206            props: None,
207        };
208
209        supervisor.reconcile(vec![spec.clone()]);
210        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("first start");
211        assert_eq!(item.line, "started");
212
213        supervisor.reconcile(vec![spec]);
214        // If the process restarted, we'd see another "started" line.
215        let next = rx.recv_timeout(Duration::from_millis(300));
216        assert!(
217            next.is_err(),
218            "process should not have restarted for unchanged spec"
219        );
220    }
221
222    #[test]
223    fn changed_file_content_restarts_process() {
224        let (tx, rx) = mpsc::channel();
225        let mut supervisor = ProcessSupervisor::new(tx);
226
227        let mk = |content: &str| ProcessSpec {
228            identity: ProcessIdentity {
229                bin: "/bin/sh".into(),
230                key: "versioned".into(),
231            },
232            args: vec![Resource::File {
233                content: content.into(),
234            }],
235            env: BTreeMap::new(),
236            current_dir: None,
237            props: None,
238        };
239
240        supervisor.reconcile(vec![mk("echo v1")]);
241        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v1");
242        assert_eq!(item.line, "v1");
243
244        supervisor.reconcile(vec![mk("echo v2")]);
245        let item = wait_for_line(&rx, Duration::from_secs(2)).expect("v2");
246        assert_eq!(item.line, "v2");
247    }
248
249    #[test]
250    fn removing_process_cleans_up_file_resources() {
251        let (tx, _rx) = mpsc::channel();
252        let mut supervisor = ProcessSupervisor::new(tx);
253
254        let spec = ProcessSpec {
255            identity: ProcessIdentity {
256                bin: "/bin/sh".into(),
257                key: "cleanup".into(),
258            },
259            args: vec![Resource::File {
260                content: "sleep 60".into(),
261            }],
262            env: BTreeMap::new(),
263            current_dir: None,
264            props: None,
265        };
266
267        supervisor.reconcile(vec![spec]);
268
269        let file_paths: Vec<String> = supervisor
270            .states
271            .values()
272            .flat_map(|c| c._handles.iter())
273            .map(|h| h.path().to_string_lossy().into_owned())
274            .collect();
275        assert!(!file_paths.is_empty(), "should have at least one temp file");
276        for p in &file_paths {
277            assert!(
278                std::path::Path::new(p).exists(),
279                "file should exist while process is running"
280            );
281        }
282
283        supervisor.reconcile(vec![]);
284
285        for p in &file_paths {
286            assert!(
287                !std::path::Path::new(p).exists(),
288                "file should be cleaned up after process exits"
289            );
290        }
291    }
292}