1use 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
22pub const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
24pub const DEFAULT_MAX_RESTARTS: u32 = 5;
26pub const DEFAULT_COOLDOWN: Duration = Duration::from_secs(30);
28
29#[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 pub working_dir: Option<PathBuf>,
40 #[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
90pub 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 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 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 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
260async 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}