Skip to main content

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}