running_process/broker/server/
spawn_coordinator.rs1use std::collections::HashMap;
9use std::fs::File;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use super::backend_registry::BackendKey;
15
16pub const DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW: u32 = 3;
18
19pub const DEFAULT_SPAWN_BUDGET_WINDOW: Duration = Duration::from_secs(30);
21
22pub fn acquire_spawn_lock(path: impl AsRef<Path>) -> Result<SpawnLockGuard, SpawnLockError> {
35 acquire_spawn_lock_with_hook(path.as_ref(), |_, _| {})
36}
37
38fn acquire_spawn_lock_with_hook<F>(
39 path: &Path,
40 mut before_lock: F,
41) -> Result<SpawnLockGuard, SpawnLockError>
42where
43 F: FnMut(&Path, &File),
44{
45 let path_buf = path.to_path_buf();
46 let file =
47 crate::platform::fs::open_lock_file(path).map_err(|source| SpawnLockError::Open {
48 path: path_buf.clone(),
49 source,
50 })?;
51
52 before_lock(path, &file);
53
54 crate::platform::fs::try_lock_exclusive(&file).map_err(|source| {
55 if crate::platform::fs::is_lock_conflict(&source) {
56 SpawnLockError::AlreadyLocked {
57 path: path_buf.clone(),
58 }
59 } else {
60 SpawnLockError::Lock {
61 path: path_buf.clone(),
62 source,
63 }
64 }
65 })?;
66
67 let opened_identity = crate::platform::fs::file_identity(&file)
68 .map_err(|source| lock_identity_error(&path_buf, &file, source))?;
69 let current_identity = match crate::platform::fs::path_identity(path) {
70 Ok(identity) => identity,
71 Err(source) if source.kind() == io::ErrorKind::NotFound => {
72 let _ = crate::platform::fs::unlock(&file);
73 return Err(SpawnLockError::DeletedOrRecreated {
74 path: path_buf,
75 opened_identity,
76 current_identity: None,
77 });
78 }
79 Err(source) => return Err(lock_identity_error(&path_buf, &file, source)),
80 };
81
82 if opened_identity != current_identity {
83 let _ = crate::platform::fs::unlock(&file);
84 return Err(SpawnLockError::DeletedOrRecreated {
85 path: path_buf,
86 opened_identity,
87 current_identity,
88 });
89 }
90
91 Ok(SpawnLockGuard {
92 file,
93 path: path_buf,
94 identity: opened_identity,
95 })
96}
97
98fn lock_identity_error(path: &Path, file: &File, source: io::Error) -> SpawnLockError {
99 let _ = crate::platform::fs::unlock(file);
100 SpawnLockError::Identity {
101 path: path.to_path_buf(),
102 source,
103 }
104}
105
106#[must_use = "dropping the guard releases the backend spawn lock immediately"]
108#[derive(Debug)]
109pub struct SpawnLockGuard {
110 file: File,
111 path: PathBuf,
112 identity: Option<SpawnLockFileIdentity>,
113}
114
115impl SpawnLockGuard {
116 pub fn path(&self) -> &Path {
118 &self.path
119 }
120
121 pub fn file_identity(&self) -> Option<SpawnLockFileIdentity> {
123 self.identity
124 }
125}
126
127impl Drop for SpawnLockGuard {
128 fn drop(&mut self) {
129 let _ = crate::platform::fs::unlock(&self.file);
130 }
131}
132
133pub use crate::platform::fs::FileIdentity as SpawnLockFileIdentity;
139
140#[derive(Debug, thiserror::Error)]
142pub enum SpawnLockError {
143 #[error("failed to open backend spawn lock file {path}: {source}")]
145 Open {
146 path: PathBuf,
148 #[source]
150 source: io::Error,
151 },
152 #[error("backend spawn lock file {path} is already locked")]
154 AlreadyLocked {
155 path: PathBuf,
157 },
158 #[error("failed to lock backend spawn lock file {path}: {source}")]
160 Lock {
161 path: PathBuf,
163 #[source]
165 source: io::Error,
166 },
167 #[error("backend spawn lock file {path} was deleted or recreated during acquisition")]
169 DeletedOrRecreated {
170 path: PathBuf,
172 opened_identity: Option<SpawnLockFileIdentity>,
174 current_identity: Option<SpawnLockFileIdentity>,
176 },
177 #[error("failed to verify backend spawn lock file identity for {path}: {source}")]
179 Identity {
180 path: PathBuf,
182 #[source]
184 source: io::Error,
185 },
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub struct SpawnBudgetConfig {
191 pub max_attempts: u32,
193 pub window: Duration,
195}
196
197impl SpawnBudgetConfig {
198 pub fn new(max_attempts: u32, window: Duration) -> Self {
200 Self {
201 max_attempts: max_attempts.max(1),
202 window: if window.is_zero() {
203 Duration::from_millis(1)
204 } else {
205 window
206 },
207 }
208 }
209}
210
211impl Default for SpawnBudgetConfig {
212 fn default() -> Self {
213 Self {
214 max_attempts: DEFAULT_SPAWN_ATTEMPTS_PER_WINDOW,
215 window: DEFAULT_SPAWN_BUDGET_WINDOW,
216 }
217 }
218}
219
220#[derive(Debug)]
222pub struct SpawnCoordinator {
223 config: SpawnBudgetConfig,
224 states: HashMap<BackendKey, SpawnBudgetState>,
225}
226
227impl SpawnCoordinator {
228 pub fn new() -> Self {
230 Self::with_config(SpawnBudgetConfig::default())
231 }
232
233 pub fn with_config(config: SpawnBudgetConfig) -> Self {
235 Self {
236 config,
237 states: HashMap::new(),
238 }
239 }
240
241 pub fn try_begin(
247 &mut self,
248 key: BackendKey,
249 now: Instant,
250 ) -> Result<SpawnPermit, SpawnBeginError> {
251 let state = self
252 .states
253 .entry(key.clone())
254 .or_insert_with(|| SpawnBudgetState::new(now));
255 state.refresh(now, self.config.window);
256
257 if state.in_flight {
258 return Err(SpawnBeginError::AlreadyInProgress);
259 }
260
261 if state.attempts_used >= self.config.max_attempts {
262 let is_storm_trip = !state.storm_signaled;
263 state.storm_signaled = true;
264 return Err(SpawnBeginError::BudgetExhausted {
265 retry_after: retry_after(state.window_started_at, now, self.config.window),
266 remaining: 0,
267 is_storm_trip,
268 });
269 }
270
271 state.attempts_used += 1;
272 state.in_flight = true;
273 Ok(SpawnPermit {
274 key,
275 attempt_number: state.attempts_used,
276 remaining_after_begin: self.config.max_attempts - state.attempts_used,
277 })
278 }
279
280 pub fn finish(&mut self, key: &BackendKey, outcome: SpawnOutcome, now: Instant) {
282 let Some(state) = self.states.get_mut(key) else {
283 return;
284 };
285 state.refresh(now, self.config.window);
286 state.in_flight = false;
287 if outcome == SpawnOutcome::Success {
288 state.window_started_at = now;
289 state.attempts_used = 0;
290 state.storm_signaled = false;
291 }
292 }
293
294 pub fn snapshot(&mut self, key: BackendKey, now: Instant) -> SpawnBudgetSnapshot {
296 let state = self
297 .states
298 .entry(key.clone())
299 .or_insert_with(|| SpawnBudgetState::new(now));
300 state.refresh(now, self.config.window);
301 snapshot_for(key, state, self.config, now)
302 }
303}
304
305impl Default for SpawnCoordinator {
306 fn default() -> Self {
307 Self::new()
308 }
309}
310
311#[derive(Clone, Debug, PartialEq, Eq)]
313pub struct SpawnPermit {
314 pub key: BackendKey,
316 pub attempt_number: u32,
318 pub remaining_after_begin: u32,
320}
321
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub enum SpawnOutcome {
325 Success,
327 Failed,
329}
330
331#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
333pub enum SpawnBeginError {
334 #[error("backend spawn already in progress")]
336 AlreadyInProgress,
337 #[error("backend spawn budget exhausted; retry after {retry_after:?}")]
339 BudgetExhausted {
340 retry_after: Duration,
342 remaining: u32,
344 is_storm_trip: bool,
351 },
352}
353
354#[derive(Clone, Debug, PartialEq, Eq)]
356pub struct SpawnBudgetSnapshot {
357 pub key: BackendKey,
359 pub attempts_used: u32,
361 pub remaining: u32,
363 pub in_flight: bool,
365 pub retry_after: Option<Duration>,
367}
368
369#[derive(Clone, Debug)]
370struct SpawnBudgetState {
371 window_started_at: Instant,
372 attempts_used: u32,
373 in_flight: bool,
374 storm_signaled: bool,
382}
383
384impl SpawnBudgetState {
385 fn new(now: Instant) -> Self {
386 Self {
387 window_started_at: now,
388 attempts_used: 0,
389 in_flight: false,
390 storm_signaled: false,
391 }
392 }
393
394 fn refresh(&mut self, now: Instant, window: Duration) {
395 if elapsed_since(self.window_started_at, now) >= window {
396 self.window_started_at = now;
397 self.attempts_used = 0;
398 self.in_flight = false;
399 self.storm_signaled = false;
400 }
401 }
402}
403
404fn snapshot_for(
405 key: BackendKey,
406 state: &SpawnBudgetState,
407 config: SpawnBudgetConfig,
408 now: Instant,
409) -> SpawnBudgetSnapshot {
410 let remaining = config.max_attempts.saturating_sub(state.attempts_used);
411 SpawnBudgetSnapshot {
412 key,
413 attempts_used: state.attempts_used,
414 remaining,
415 in_flight: state.in_flight,
416 retry_after: (remaining == 0)
417 .then(|| retry_after(state.window_started_at, now, config.window)),
418 }
419}
420
421fn retry_after(window_started_at: Instant, now: Instant, window: Duration) -> Duration {
422 window.saturating_sub(elapsed_since(window_started_at, now))
423}
424
425fn elapsed_since(started_at: Instant, now: Instant) -> Duration {
426 now.checked_duration_since(started_at)
427 .unwrap_or(Duration::ZERO)
428}
429
430#[cfg(test)]
431mod tests {
432 use std::fs;
433
434 use super::*;
435
436 #[test]
437 #[cfg(any(unix, windows))]
438 fn acquire_spawn_lock_detects_lock_file_replacement_between_open_and_lock() {
439 let tmp = tempfile::tempdir().unwrap();
440 let lock_path = tmp.path().join("backend.spawn.lock");
441 let replaced_path = tmp.path().join("backend.spawn.lock.replaced");
442
443 let err = acquire_spawn_lock_with_hook(&lock_path, |path, _file| {
444 fs::rename(path, &replaced_path).unwrap();
445 fs::write(path, b"replacement lock file").unwrap();
446 })
447 .unwrap_err();
448
449 let SpawnLockError::DeletedOrRecreated {
450 path,
451 opened_identity: Some(opened_identity),
452 current_identity: Some(current_identity),
453 } = err
454 else {
455 panic!("expected deleted/recreated error, got {err:?}");
456 };
457
458 assert_eq!(path, lock_path);
459 assert_ne!(opened_identity, current_identity);
460
461 let _guard = acquire_spawn_lock(&lock_path).unwrap();
462 }
463}