Skip to main content

malkuth/
worker.rs

1//! Supervised child-process workers (tokio).
2//!
3//! Each [`WorkerSpec`] is one independently-killable child process holding one
4//! resource. [`Supervisor`] spawns them and restarts per their
5//! [`RestartPolicy`] (`permanent` / `transient` / `temporary`), with a
6//! sliding-window rate limit to prevent crash storms. Workers are supervised
7//! concurrently via a `FuturesUnordered`.
8
9use std::{
10    path::PathBuf,
11    process::Stdio,
12    sync::Arc,
13    time::{Duration, Instant},
14};
15use tokio::process::{Child, Command};
16
17use futures_util::stream::{FuturesUnordered, StreamExt};
18use tracing::{info, warn};
19
20use crate::{DrainController, RestartPolicy, WorkerInfo, WorkerStatus};
21
22/// Default sliding window for restart rate-limiting.
23pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
24/// Default max restarts within the window before entering cooldown.
25pub const DEFAULT_MAX_RESTARTS: u32 = 5;
26/// Default cooldown after the rate limit trips.
27pub const DEFAULT_COOLDOWN: Duration = Duration::from_secs(30);
28
29/// Specification of one supervised worker.
30#[derive(Clone)]
31pub struct WorkerSpec {
32    pub id: String,
33    pub kind: String,
34    pub program: String,
35    pub args: Vec<String>,
36    pub env: Vec<(String, String)>,
37    pub restart_policy: RestartPolicy,
38    /// Optional working directory for the child process.
39    pub working_dir: Option<PathBuf>,
40    /// Per-worker drain signal — set externally to trigger a graceful restart
41    /// of this specific worker without affecting the rest of the pool.
42    #[allow(clippy::type_complexity)]
43    pub drain_signal: Option<Arc<tokio::sync::Notify>>,
44}
45
46impl WorkerSpec {
47    #[must_use]
48    pub fn new(id: impl Into<String>, kind: impl Into<String>, program: impl Into<String>) -> Self {
49        Self {
50            id: id.into(),
51            kind: kind.into(),
52            program: program.into(),
53            args: Vec::new(),
54            env: Vec::new(),
55            restart_policy: RestartPolicy::Permanent,
56            working_dir: None,
57            drain_signal: None,
58        }
59    }
60    #[must_use]
61    pub fn args<I, S>(mut self, args: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        self.args = args.into_iter().map(Into::into).collect();
67        self
68    }
69    #[must_use]
70    pub fn env<K, V>(mut self, key: K, value: V) -> Self
71    where
72        K: Into<String>,
73        V: Into<String>,
74    {
75        self.env.push((key.into(), value.into()));
76        self
77    }
78    #[must_use]
79    pub fn policy(mut self, policy: RestartPolicy) -> Self {
80        self.restart_policy = policy;
81        self
82    }
83    #[must_use]
84    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
85        self.working_dir = Some(dir.into());
86        self
87    }
88}
89
90/// Owns a pool of workers and supervises them.
91pub struct Supervisor {
92    specs: Vec<WorkerSpec>,
93    max_restarts: u32,
94    window: Duration,
95    cooldown: Duration,
96}
97
98impl Supervisor {
99    #[must_use]
100    pub fn new(specs: Vec<WorkerSpec>) -> Self {
101        Self {
102            specs,
103            max_restarts: DEFAULT_MAX_RESTARTS,
104            window: DEFAULT_WINDOW,
105            cooldown: DEFAULT_COOLDOWN,
106        }
107    }
108    #[must_use]
109    pub fn rate_limit(mut self, max_restarts: u32, window: Duration) -> Self {
110        self.max_restarts = max_restarts;
111        self.window = window;
112        self
113    }
114    #[must_use]
115    pub fn cooldown(mut self, cooldown: Duration) -> Self {
116        self.cooldown = cooldown;
117        self
118    }
119
120    /// Register a drain signal for a worker and return a handle to trigger it.
121    ///
122    /// Call `notify_one()` on the returned `Arc<Notify>` to gracefully restart
123    /// that specific worker. Must be called before `run()`.
124    pub fn register_drain(&mut self, worker_id: &str) -> Option<Arc<tokio::sync::Notify>> {
125        let signal = Arc::new(tokio::sync::Notify::new());
126        for spec in &mut self.specs {
127            if spec.id == worker_id {
128                spec.drain_signal = Some(signal.clone());
129                return Some(signal);
130            }
131        }
132        None
133    }
134
135    /// Run the supervision loop until `drain` begins, then return final snapshots.
136    pub async fn run(self, drain: DrainController) -> Vec<WorkerInfo> {
137        let mut handles: FuturesUnordered<
138            std::pin::Pin<Box<dyn std::future::Future<Output = WorkerInfo> + Send>>,
139        > = FuturesUnordered::new();
140        for spec in self.specs {
141            let drain = drain.clone();
142            let (max_restarts, window, cooldown) = (self.max_restarts, self.window, self.cooldown);
143            handles.push(Box::pin(async move {
144                supervise_one(spec, max_restarts, window, cooldown, drain).await
145            })
146                as std::pin::Pin<
147                    Box<dyn std::future::Future<Output = WorkerInfo> + Send>,
148                >);
149        }
150        let mut results = Vec::new();
151        while let Some(info) = handles.next().await {
152            results.push(info);
153        }
154        results
155    }
156}
157
158async fn supervise_one(
159    spec: WorkerSpec,
160    max_restarts: u32,
161    window: Duration,
162    cooldown: Duration,
163    drain: DrainController,
164) -> WorkerInfo {
165    let mut restart_count: u32 = 0;
166    let mut restart_times: Vec<Instant> = Vec::new();
167    let mut last_error: Option<String> = None;
168
169    loop {
170        if drain.is_draining() {
171            break;
172        }
173        let mut child = match spawn(&spec) {
174            Ok(c) => c,
175            Err(e) => {
176                last_error = Some(format!("spawn failed: {e}"));
177                warn!(worker = %spec.id, error = %e, "failed to spawn worker");
178                if !should_restart(spec.restart_policy, false) {
179                    break;
180                }
181                if rate_limited(&mut restart_times, max_restarts, window, cooldown, &drain).await {
182                    break;
183                }
184                restart_count += 1;
185                continue;
186            }
187        };
188
189        // Race child exit against global drain AND per-worker drain signal.
190        tokio::select! {
191            status = child.wait() => {
192                match status {
193                    Ok(s) => {
194                        let clean = s.success();
195                        info!(worker = %spec.id, clean, "worker exited");
196                        if !should_restart(spec.restart_policy, clean) { break; }
197                    }
198                    Err(e) => {
199                        last_error = Some(format!("wait failed: {e}"));
200                        warn!(worker = %spec.id, error = %e, "failed to await worker");
201                    }
202                }
203                restart_count += 1;
204                if rate_limited(&mut restart_times, max_restarts, window, cooldown, &drain).await {
205                    break;
206                }
207            }
208            _ = drain.wait_for_drain() => {
209                let _ = child.kill().await;
210                break;
211            }
212            _ = async {
213                if let Some(ref signal) = spec.drain_signal {
214                    signal.notified().await;
215                } else {
216                    std::future::pending::<()>().await;
217                }
218            } => {
219                info!(worker = %spec.id, "per-worker drain signal received");
220                let _ = child.kill().await;
221                break;
222            }
223        }
224    }
225
226    WorkerInfo {
227        id: spec.id.clone(),
228        kind: spec.kind.clone(),
229        status: WorkerStatus::Stopped,
230        restart_policy: spec.restart_policy,
231        restart_count,
232        last_error,
233    }
234}
235
236fn spawn(spec: &WorkerSpec) -> std::io::Result<Child> {
237    let mut cmd = Command::new(&spec.program);
238    cmd.args(&spec.args);
239    for (k, v) in &spec.env {
240        cmd.env(k, v);
241    }
242    if let Some(ref dir) = spec.working_dir {
243        cmd.current_dir(dir);
244    }
245    cmd.stdin(Stdio::null());
246    cmd.stdout(Stdio::inherit());
247    cmd.stderr(Stdio::inherit());
248    cmd.kill_on_drop(true);
249    cmd.spawn()
250}
251
252fn should_restart(policy: RestartPolicy, clean_exit: bool) -> bool {
253    match policy {
254        RestartPolicy::Permanent => true,
255        RestartPolicy::Transient => !clean_exit,
256        RestartPolicy::Temporary => false,
257    }
258}
259
260/// Returns true if the rate limit tripped (after sleeping the cooldown).
261async fn rate_limited(
262    restart_times: &mut Vec<Instant>,
263    max_restarts: u32,
264    window: Duration,
265    cooldown: Duration,
266    drain: &DrainController,
267) -> bool {
268    let now = Instant::now();
269    restart_times.retain(|t| now.duration_since(*t) < window);
270    restart_times.push(now);
271    if restart_times.len() as u32 > max_restarts {
272        warn!(
273            restarts = restart_times.len(),
274            "restart rate limit tripped, entering cooldown"
275        );
276        tokio::select! {
277            _ = tokio::time::sleep(cooldown) => {}
278            _ = drain.wait_for_drain() => {}
279        }
280        restart_times.clear();
281        true
282    } else {
283        false
284    }
285}