Skip to main content

confium_coordinator/coordinator/
backpressure.rs

1//! Backpressure — limits concurrent active sessions.
2//!
3//! When the coordinator reaches `max_active_sessions`, new session
4//! creation requests are rejected with [`BackpressureError::AtCapacity`].
5//! This protects against memory exhaustion under load.
6//!
7//! Existing sessions continue to be processed; only new ones are
8//! rejected until capacity frees up.
9
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12/// Configuration for backpressure.
13#[derive(Debug, Clone)]
14pub struct BackpressureConfig {
15    /// Maximum simultaneous active sessions. 0 = unlimited.
16    pub max_active_sessions: usize,
17}
18
19impl Default for BackpressureConfig {
20    fn default() -> Self {
21        Self {
22            max_active_sessions: 100,
23        }
24    }
25}
26
27/// Errors from backpressure enforcement.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum BackpressureError {
30    /// Coordinator is at capacity.
31    #[error("at capacity: {active}/{max}")]
32    AtCapacity {
33        /// Currently active sessions.
34        active: usize,
35        /// Maximum allowed.
36        max: usize,
37    },
38}
39
40/// Backpressure gate — tracks active sessions and enforces the limit.
41pub struct BackpressureGate {
42    config: BackpressureConfig,
43    active: AtomicUsize,
44}
45
46impl BackpressureGate {
47    /// Create a new gate with the given configuration.
48    pub fn new(config: BackpressureConfig) -> Self {
49        Self {
50            config,
51            active: AtomicUsize::new(0),
52        }
53    }
54
55    /// Current active session count.
56    pub fn active_count(&self) -> usize {
57        self.active.load(Ordering::SeqCst)
58    }
59
60    /// Maximum allowed sessions.
61    pub fn max_sessions(&self) -> usize {
62        self.config.max_active_sessions
63    }
64
65    /// Try to acquire a slot. Returns `Ok(())` if a slot was acquired
66    /// (active count incremented), or `Err(AtCapacity)` if the
67    /// coordinator is full.
68    pub fn try_acquire(&self) -> Result<ActiveSlot<'_>, BackpressureError> {
69        let max = self.config.max_active_sessions;
70        if max == 0 {
71            return Ok(ActiveSlot {
72                gate: self,
73                unlimited: true,
74            });
75        }
76        let current = self.active.fetch_add(1, Ordering::SeqCst);
77        if current >= max {
78            self.active.fetch_sub(1, Ordering::SeqCst);
79            return Err(BackpressureError::AtCapacity {
80                active: current,
81                max,
82            });
83        }
84        Ok(ActiveSlot {
85            gate: self,
86            unlimited: false,
87        })
88    }
89
90    /// Release a slot (decrement active count).
91    fn release(&self) {
92        self.active.fetch_sub(1, Ordering::SeqCst);
93    }
94
95    /// Is the coordinator at capacity?
96    pub fn is_at_capacity(&self) -> bool {
97        let max = self.config.max_active_sessions;
98        max > 0 && self.active_count() >= max
99    }
100}
101
102/// RAII guard for an acquired backpressure slot. When dropped, the
103/// slot is automatically released.
104pub struct ActiveSlot<'a> {
105    gate: &'a BackpressureGate,
106    unlimited: bool,
107}
108
109impl<'a> std::fmt::Debug for ActiveSlot<'a> {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("ActiveSlot")
112            .field("unlimited", &self.unlimited)
113            .finish()
114    }
115}
116
117impl<'a> Drop for ActiveSlot<'a> {
118    fn drop(&mut self) {
119        if !self.unlimited {
120            self.gate.release();
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn default_max_is_100() {
131        let gate = BackpressureGate::new(BackpressureConfig::default());
132        assert_eq!(gate.max_sessions(), 100);
133    }
134
135    #[test]
136    fn acquire_increments_count() {
137        let gate = BackpressureGate::new(BackpressureConfig {
138            max_active_sessions: 5,
139        });
140        assert_eq!(gate.active_count(), 0);
141        let slot = gate.try_acquire().unwrap();
142        assert_eq!(gate.active_count(), 1);
143        drop(slot);
144        assert_eq!(gate.active_count(), 0);
145    }
146
147    #[test]
148    fn at_capacity_rejects() {
149        let gate = BackpressureGate::new(BackpressureConfig {
150            max_active_sessions: 2,
151        });
152        let _s1 = gate.try_acquire().unwrap();
153        let _s2 = gate.try_acquire().unwrap();
154        let result = gate.try_acquire();
155        assert_eq!(
156            result.unwrap_err(),
157            BackpressureError::AtCapacity { active: 2, max: 2 }
158        );
159    }
160
161    #[test]
162    fn release_allows_new_acquire() {
163        let gate = BackpressureGate::new(BackpressureConfig {
164            max_active_sessions: 1,
165        });
166        {
167            let _slot = gate.try_acquire().unwrap();
168            assert!(gate.try_acquire().is_err());
169        }
170        assert!(gate.try_acquire().is_ok());
171    }
172
173    #[test]
174    fn unlimited_mode_never_rejects() {
175        let gate = BackpressureGate::new(BackpressureConfig {
176            max_active_sessions: 0,
177        });
178        for _ in 0..1000 {
179            assert!(gate.try_acquire().is_ok());
180        }
181    }
182
183    #[test]
184    fn is_at_capacity_reflects_state() {
185        let gate = BackpressureGate::new(BackpressureConfig {
186            max_active_sessions: 2,
187        });
188        assert!(!gate.is_at_capacity());
189        let _s1 = gate.try_acquire().unwrap();
190        assert!(!gate.is_at_capacity());
191        let _s2 = gate.try_acquire().unwrap();
192        assert!(gate.is_at_capacity());
193    }
194
195    #[test]
196    fn unlimited_is_never_at_capacity() {
197        let gate = BackpressureGate::new(BackpressureConfig {
198            max_active_sessions: 0,
199        });
200        assert!(!gate.is_at_capacity());
201    }
202
203    #[test]
204    fn slot_release_on_drop() {
205        let gate = BackpressureGate::new(BackpressureConfig {
206            max_active_sessions: 1,
207        });
208        {
209            let _slot = gate.try_acquire().unwrap();
210            assert_eq!(gate.active_count(), 1);
211        }
212        assert_eq!(gate.active_count(), 0);
213    }
214
215    #[test]
216    fn concurrent_acquires_respect_limit() {
217        use std::sync::Arc;
218        use std::thread;
219
220        let gate = Arc::new(BackpressureGate::new(BackpressureConfig {
221            max_active_sessions: 3,
222        }));
223        let mut handles = Vec::new();
224        let success_count = Arc::new(AtomicUsize::new(0));
225
226        for _ in 0..10 {
227            let gate = Arc::clone(&gate);
228            let counter = Arc::clone(&success_count);
229            handles.push(thread::spawn(move || {
230                if let Ok(_slot) = gate.try_acquire() {
231                    counter.fetch_add(1, Ordering::SeqCst);
232                    thread::sleep(std::time::Duration::from_millis(10));
233                }
234            }));
235        }
236
237        for h in handles {
238            h.join().unwrap();
239        }
240
241        assert_eq!(success_count.load(Ordering::SeqCst), 3);
242    }
243}