rust_supervisor/runtime/concurrent_gate.rs
1//! Concurrent restart throttle gates for preventing restart storm.
2//!
3//! This module implements instance-global and group-level concurrent restart
4//! limits to prevent resource contention during mass failure scenarios.
5//!
6//! # Permit-based design
7//!
8//! [`GatePermit`] is an RAII guard returned by [`SupervisorInstanceGate::try_acquire`].
9//! The gate slot is released automatically when the [`GatePermit`] is dropped,
10//! eliminating the "mismatched acquire/release" bug class where a single
11//! acquire was followed by multiple releases (e.g. OneForAll restart scope).
12
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU32, Ordering};
15use std::sync::{Arc, Mutex};
16
17/// RAII guard that represents one acquired concurrent restart slot.
18///
19/// When dropped, the slot is automatically returned to the gate.
20/// This eliminates counting errors caused by mismatched acquire/release
21/// across different code paths (e.g. restart scope vs child exit).
22#[derive(Debug)]
23pub struct GatePermit {
24 /// Reference to the gate that issued this permit.
25 gate: Arc<AtomicU32>,
26}
27
28impl Drop for GatePermit {
29 /// Releases one slot back to the gate on drop.
30 fn drop(&mut self) {
31 let previous = self.gate.fetch_sub(1, Ordering::SeqCst);
32 debug_assert!(previous > 0, "Released more slots than acquired");
33 }
34}
35
36/// Instance-global concurrent restart gate counter.
37///
38/// Tracks the number of currently active restart attempts across all children
39/// supervised by this supervisor instance. When the limit is reached, new
40/// restart requests are denied.
41#[derive(Debug, Clone)]
42pub struct SupervisorInstanceGate {
43 /// Maximum concurrent restarts allowed at instance level.
44 max_concurrent: u32,
45 /// Current count of active restart attempts.
46 active_count: Arc<AtomicU32>,
47}
48
49impl SupervisorInstanceGate {
50 /// Creates a new instance-global concurrent restart gate.
51 ///
52 /// # Arguments
53 ///
54 /// - `max_concurrent`: Maximum number of concurrent restart attempts allowed.
55 ///
56 /// # Returns
57 ///
58 /// Returns a new [`SupervisorInstanceGate`] with zero active count.
59 ///
60 /// # Examples
61 ///
62 /// ```
63 /// use rust_supervisor::runtime::concurrent_gate::SupervisorInstanceGate;
64 ///
65 /// let gate = SupervisorInstanceGate::new(5);
66 /// assert_eq!(gate.get_active_count(), 0);
67 /// ```
68 pub fn new(max_concurrent: u32) -> Self {
69 Self {
70 max_concurrent,
71 active_count: Arc::new(AtomicU32::new(0)),
72 }
73 }
74
75 /// Attempts to acquire a restart slot from the instance gate.
76 ///
77 /// Returns a [`GatePermit`] when the gate has capacity, or `None` when
78 /// saturated. The permit is an RAII guard — the slot is released
79 /// automatically when the permit is dropped, eliminating counting errors.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use rust_supervisor::runtime::concurrent_gate::SupervisorInstanceGate;
85 ///
86 /// let gate = SupervisorInstanceGate::new(2);
87 /// let p1 = gate.try_acquire();
88 /// let p2 = gate.try_acquire();
89 /// let p3 = gate.try_acquire();
90 /// assert!(p1.is_some());
91 /// assert!(p2.is_some());
92 /// assert!(p3.is_none());
93 /// drop(p1);
94 /// assert_eq!(gate.get_active_count(), 1);
95 /// ```
96 pub fn try_acquire(&self) -> Option<GatePermit> {
97 loop {
98 let current = self.active_count.load(Ordering::SeqCst);
99 if current >= self.max_concurrent {
100 return None;
101 }
102 match self.active_count.compare_exchange_weak(
103 current,
104 current + 1,
105 Ordering::SeqCst,
106 Ordering::SeqCst,
107 ) {
108 Ok(_) => {
109 return Some(GatePermit {
110 gate: self.active_count.clone(),
111 });
112 }
113 Err(_) => continue,
114 }
115 }
116 }
117
118 /// Returns the current number of active restart attempts.
119 ///
120 /// # Returns
121 ///
122 /// Returns the current active count for monitoring and diagnostics.
123 pub fn get_active_count(&self) -> u32 {
124 self.active_count.load(Ordering::SeqCst)
125 }
126
127 /// Returns the configured maximum concurrent restart limit.
128 ///
129 /// # Returns
130 ///
131 /// Returns the maximum allowed concurrent restarts.
132 pub fn get_max_concurrent(&self) -> u32 {
133 self.max_concurrent
134 }
135
136 /// Checks if the gate is currently saturated.
137 ///
138 /// # Returns
139 ///
140 /// Returns `true` if active count has reached or exceeded the limit.
141 pub fn is_saturated(&self) -> bool {
142 self.get_active_count() >= self.max_concurrent
143 }
144}
145
146/// Group-level concurrent restart gate for optional per-group throttling.
147///
148/// When enabled, tracks concurrent restarts within a specific restart execution
149/// plan group. Falls back to instance-global gate when not configured.
150#[derive(Debug, Clone)]
151pub struct GroupLevelGate {
152 /// Map from group identifier to per-group gate state.
153 group_gates: Arc<Mutex<HashMap<String, Arc<AtomicU32>>>>,
154 /// Default maximum concurrent restarts per group.
155 max_per_group: u32,
156}
157
158impl GroupLevelGate {
159 /// Creates a new group-level concurrent restart gate manager.
160 ///
161 /// # Arguments
162 ///
163 /// - `max_per_group`: Maximum concurrent restarts allowed per group.
164 ///
165 /// # Returns
166 ///
167 /// Returns a new [`GroupLevelGate`] with empty group map.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// use rust_supervisor::runtime::concurrent_gate::GroupLevelGate;
173 ///
174 /// let gate = GroupLevelGate::new(3);
175 /// assert_eq!(gate.get_active_count_for_group("group-a"), 0);
176 /// ```
177 pub fn new(max_per_group: u32) -> Self {
178 Self {
179 group_gates: Arc::new(Mutex::new(HashMap::new())),
180 max_per_group,
181 }
182 }
183
184 /// Attempts to acquire a restart slot for a specific group.
185 ///
186 /// # Arguments
187 ///
188 /// - `group_id`: Identifier of the restart execution plan group.
189 ///
190 /// # Returns
191 ///
192 /// Returns `true` if a slot was acquired for the group, `false` if saturated.
193 ///
194 /// # Examples
195 ///
196 /// ```
197 /// use rust_supervisor::runtime::concurrent_gate::GroupLevelGate;
198 ///
199 /// let gate = GroupLevelGate::new(2);
200 /// assert!(gate.try_acquire_for_group("group-a"));
201 /// assert!(gate.try_acquire_for_group("group-a"));
202 /// assert!(!gate.try_acquire_for_group("group-a")); // Limit reached
203 /// ```
204 pub fn try_acquire_for_group(&self, group_id: &str) -> bool {
205 let mut gates = self.group_gates.lock().unwrap();
206 let gate = gates
207 .entry(group_id.to_string())
208 .or_insert_with(|| Arc::new(AtomicU32::new(0)));
209
210 loop {
211 let current = gate.load(Ordering::SeqCst);
212 if current >= self.max_per_group {
213 return false;
214 }
215 match gate.compare_exchange_weak(
216 current,
217 current + 1,
218 Ordering::SeqCst,
219 Ordering::SeqCst,
220 ) {
221 Ok(_) => return true,
222 Err(_) => continue,
223 }
224 }
225 }
226
227 /// Releases a restart slot for a specific group.
228 ///
229 /// # Arguments
230 ///
231 /// - `group_id`: Identifier of the restart execution plan group.
232 pub fn release_for_group(&self, group_id: &str) {
233 let gates = self.group_gates.lock().unwrap();
234 if let Some(gate) = gates.get(group_id) {
235 let previous = gate.fetch_sub(1, Ordering::SeqCst);
236 debug_assert!(previous > 0, "Released more group slots than acquired");
237 }
238 }
239
240 /// Returns the current active count for a specific group.
241 ///
242 /// # Arguments
243 ///
244 /// - `group_id`: Identifier of the restart execution plan group.
245 ///
246 /// # Returns
247 ///
248 /// Returns the active restart count for the specified group.
249 pub fn get_active_count_for_group(&self, group_id: &str) -> u32 {
250 let gates = self.group_gates.lock().unwrap();
251 gates
252 .get(group_id)
253 .map(|g| g.load(Ordering::SeqCst))
254 .unwrap_or(0)
255 }
256
257 /// Checks if a specific group's gate is saturated.
258 ///
259 /// # Arguments
260 ///
261 /// - `group_id`: Identifier of the restart execution plan group.
262 ///
263 /// # Returns
264 ///
265 /// Returns `true` if the group's active count has reached the limit.
266 pub fn is_group_saturated(&self, group_id: &str) -> bool {
267 self.get_active_count_for_group(group_id) >= self.max_per_group
268 }
269}
270
271/// Combined throttle gate that enforces both instance and group limits.
272///
273/// When both gates are active, takes the stricter verdict: if either gate
274/// is saturated, the restart request is throttled.
275#[derive(Debug, Clone)]
276pub struct CombinedThrottleGate {
277 /// Instance-global concurrent restart gate.
278 instance_gate: SupervisorInstanceGate,
279 /// Optional group-level concurrent restart gate.
280 group_gate: Option<GroupLevelGate>,
281}
282
283impl CombinedThrottleGate {
284 /// Creates a combined throttle gate with both instance and group limits.
285 ///
286 /// # Arguments
287 ///
288 /// - `instance_gate`: Instance-global concurrent restart gate.
289 /// - `group_gate`: Optional group-level gate for per-group throttling.
290 ///
291 /// # Returns
292 ///
293 /// Returns a new [`CombinedThrottleGate`].
294 ///
295 /// # Examples
296 ///
297 /// ```
298 /// use rust_supervisor::runtime::concurrent_gate::{
299 /// CombinedThrottleGate, SupervisorInstanceGate, GroupLevelGate,
300 /// };
301 ///
302 /// let instance = SupervisorInstanceGate::new(10);
303 /// let group = GroupLevelGate::new(5);
304 /// let combined = CombinedThrottleGate::new(instance, Some(group));
305 /// ```
306 pub fn new(instance_gate: SupervisorInstanceGate, group_gate: Option<GroupLevelGate>) -> Self {
307 Self {
308 instance_gate,
309 group_gate,
310 }
311 }
312
313 /// Attempts to acquire restart permission through both gates.
314 ///
315 /// Takes the stricter verdict: if either gate is saturated, returns `false`.
316 /// The acquired instance permit is held for the duration of the call and
317 /// released when the permit drops (RAII). Callers that need long-lived
318 /// permits should use [`SupervisorInstanceGate`] directly.
319 ///
320 /// # Arguments
321 ///
322 /// - `group_id`: Optional group identifier for group-level gate check.
323 ///
324 /// # Returns
325 ///
326 /// Returns `true` only if both instance and group gates allow the restart.
327 ///
328 /// # Examples
329 ///
330 /// ```
331 /// use rust_supervisor::runtime::concurrent_gate::{
332 /// CombinedThrottleGate, SupervisorInstanceGate, GroupLevelGate,
333 /// };
334 ///
335 /// let instance = SupervisorInstanceGate::new(2);
336 /// let group = GroupLevelGate::new(1);
337 /// let combined = CombinedThrottleGate::new(instance, Some(group));
338 ///
339 /// assert!(combined.try_acquire(Some("group-a")));
340 /// assert!(!combined.try_acquire(Some("group-a"))); // Group limit reached
341 /// ```
342 pub fn try_acquire(&self, group_id: Option<&str>) -> bool {
343 // Acquire an instance permit. It is dropped at the end of this
344 // method because CombinedThrottleGate does not support long-lived
345 // permits — callers should use SupervisorInstanceGate directly
346 // when RAII-style permits are needed.
347 let _permit = match self.instance_gate.try_acquire() {
348 Some(p) => p,
349 None => return false,
350 };
351
352 // If group gate exists and group_id provided, check group limit
353 if let (Some(group_gate), Some(gid)) = (&self.group_gate, group_id)
354 && !group_gate.try_acquire_for_group(gid)
355 {
356 // _permit is dropped here, releasing the instance slot.
357 return false;
358 }
359
360 true
361 // _permit dropped here — instance slot released.
362 // This matches the legacy behavior where CombinedThrottleGate
363 // did not hold permits across async boundaries.
364 }
365
366 /// Releases restart slots from both instance and group gates.
367 ///
368 /// # Arguments
369 ///
370 /// - `group_id`: Optional group identifier for group-level release.
371 ///
372 /// # Legacy note
373 ///
374 /// This method is a no-op for the instance gate because the permit
375 /// was already released at the end of [`try_acquire`](Self::try_acquire).
376 /// It is retained for API compatibility with callers that still call
377 /// `release` after a combined gate acquire.
378 pub fn release(&self, group_id: Option<&str>) {
379 if let (Some(group_gate), Some(gid)) = (&self.group_gate, group_id) {
380 group_gate.release_for_group(gid);
381 }
382 }
383
384 /// Returns the instance-global gate reference.
385 ///
386 /// # Returns
387 ///
388 /// Returns a reference to the instance gate for monitoring.
389 pub fn instance_gate(&self) -> &SupervisorInstanceGate {
390 &self.instance_gate
391 }
392
393 /// Returns the group-level gate reference if configured.
394 ///
395 /// # Returns
396 ///
397 /// Returns an optional reference to the group gate.
398 pub fn group_gate(&self) -> Option<&GroupLevelGate> {
399 self.group_gate.as_ref()
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use crate::runtime::concurrent_gate::{
406 CombinedThrottleGate, GroupLevelGate, SupervisorInstanceGate,
407 };
408
409 /// Tests basic acquire and release operations on supervisor instance gate.
410 #[test]
411 fn test_instance_gate_basic_acquire_release() {
412 let gate = SupervisorInstanceGate::new(3);
413 assert_eq!(gate.get_active_count(), 0);
414
415 let p1 = gate.try_acquire();
416 assert!(p1.is_some());
417 assert_eq!(gate.get_active_count(), 1);
418
419 let p2 = gate.try_acquire();
420 assert!(p2.is_some());
421 assert_eq!(gate.get_active_count(), 2);
422
423 drop(p1);
424 assert_eq!(gate.get_active_count(), 1);
425
426 drop(p2);
427 assert_eq!(gate.get_active_count(), 0);
428 }
429
430 /// Tests that instance gate correctly reports saturation when limit is reached.
431 #[test]
432 fn test_instance_gate_saturation() {
433 let gate = SupervisorInstanceGate::new(2);
434
435 let p1 = gate.try_acquire();
436 let p2 = gate.try_acquire();
437 let p3 = gate.try_acquire();
438 assert!(p1.is_some());
439 assert!(p2.is_some());
440 assert!(p3.is_none()); // Saturated
441
442 assert!(gate.is_saturated());
443 }
444
445 /// Tests that group-level gates isolate concurrency limits per group independently.
446 #[test]
447 fn test_group_gate_isolation() {
448 let gate = GroupLevelGate::new(2);
449
450 // Group A can acquire up to limit
451 assert!(gate.try_acquire_for_group("group-a"));
452 assert!(gate.try_acquire_for_group("group-a"));
453 assert!(!gate.try_acquire_for_group("group-a"));
454
455 // Group B is independent and unaffected
456 assert!(gate.try_acquire_for_group("group-b"));
457 assert_eq!(gate.get_active_count_for_group("group-b"), 1);
458 assert_eq!(gate.get_active_count_for_group("group-a"), 2);
459 }
460
461 /// Tests that combined gate takes the stricter verdict between instance and group gates.
462 #[test]
463 fn test_combined_gate_takes_stricter_verdict() {
464 let instance = SupervisorInstanceGate::new(5);
465 let group = GroupLevelGate::new(2);
466 let combined = CombinedThrottleGate::new(instance, Some(group));
467
468 // Group limit is stricter (2 vs 5)
469 assert!(combined.try_acquire(Some("test-group")));
470 assert!(combined.try_acquire(Some("test-group")));
471 assert!(!combined.try_acquire(Some("test-group"))); // Group saturated
472 }
473
474 /// Tests that combined gate works correctly without a group gate configured.
475 #[test]
476 fn test_combined_gate_without_group() {
477 let instance = SupervisorInstanceGate::new(2);
478 let combined = CombinedThrottleGate::new(instance, None);
479
480 // Without group gate, CombinedThrottleGate checks instance capacity
481 // ephemerally — the permit is released immediately after each call.
482 // So repeated calls always succeed as long as the gate is not
483 // saturated at the instant of the call.
484 assert!(combined.try_acquire(None));
485 assert!(combined.try_acquire(None));
486 assert!(combined.try_acquire(None));
487 // For long-lived permits (held across async boundaries), callers
488 // should use SupervisorInstanceGate directly.
489 }
490}