monocoque_core/backpressure.rs
1//! Backpressure: `BytePermits`
2//!
3//! Byte-based flow control for write pumps.
4//!
5//! Design principle:
6//! - Backpressure scales with **bytes**, not message count
7//! - One giant message should not starve other connections
8//! - Pluggable: `NoOp` (default) → Semaphore → dynamic policy
9//!
10//! Usage:
11//! ```rust,ignore
12//! let permits = SemaphorePermits::new(10 * 1024 * 1024); // 10MB limit
13//! let permit = permits.acquire(n_bytes).await;
14//! writer.write(buf).await;
15//! drop(permit); // releases automatically
16//! ```
17
18use async_trait::async_trait;
19use parking_lot::{Condvar, Mutex};
20use std::sync::Arc;
21
22/// Backpressure permit trait.
23///
24/// Implementations control write pump flow based on byte counts.
25#[async_trait]
26pub trait BytePermits: Send + Sync {
27 /// Acquire permission to write `n_bytes`.
28 ///
29 /// This may block if the system is under memory pressure.
30 async fn acquire(&self, n_bytes: usize) -> Permit;
31}
32
33/// Internal state for the byte semaphore.
34struct SemInner {
35 available: usize,
36 /// Total capacity; used to clamp oversized acquires so we never deadlock.
37 max_bytes: usize,
38}
39
40/// RAII permit guard.
41///
42/// Releases the permit when dropped.
43pub struct Permit {
44 inner: Option<PermitInner>,
45}
46
47enum PermitInner {
48 /// Byte-counting semaphore backed by `parking_lot` primitives (usable in `Drop`).
49 ByteSem(Arc<(Mutex<SemInner>, Condvar)>, usize),
50 NoOp,
51}
52
53impl Drop for Permit {
54 fn drop(&mut self) {
55 match self.inner.take() {
56 Some(PermitInner::ByteSem(inner, n_bytes)) => {
57 let (mutex, condvar) = &*inner;
58 let mut guard = mutex.lock();
59 guard.available += n_bytes;
60 drop(guard);
61 condvar.notify_all();
62 }
63 Some(PermitInner::NoOp) | None => {}
64 }
65 }
66}
67
68impl Permit {
69 pub(crate) const fn noop() -> Self {
70 Self {
71 inner: Some(PermitInner::NoOp),
72 }
73 }
74
75 fn byte_sem(inner: Arc<(Mutex<SemInner>, Condvar)>, n_bytes: usize) -> Self {
76 Self {
77 inner: Some(PermitInner::ByteSem(inner, n_bytes)),
78 }
79 }
80}
81
82/// No-op implementation (Phase 0).
83///
84/// Always grants permits immediately.
85/// Use this until memory pressure becomes an issue.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct NoOpPermits;
88
89#[async_trait]
90impl BytePermits for NoOpPermits {
91 async fn acquire(&self, _n_bytes: usize) -> Permit {
92 Permit::noop()
93 }
94}
95
96/// Semaphore-based backpressure implementation.
97///
98/// Enforces a maximum number of bytes that can be buffered at once.
99/// When the limit is reached, `acquire()` will block until space is available.
100/// Acquires all N bytes in a single atomic operation (O(1), not O(N)).
101///
102/// # Example
103///
104/// ```
105/// use monocoque_core::backpressure::{BytePermits, SemaphorePermits};
106///
107/// # monocoque_core::rt::LocalRuntime::new().unwrap().block_on(async {
108/// // Allow up to 10MB of buffered data
109/// let permits = SemaphorePermits::new(10 * 1024 * 1024);
110///
111/// // Acquire permit for 1KB write
112/// let permit = permits.acquire(1024).await;
113/// // ... perform write ...
114/// drop(permit); // releases 1024 bytes back to the pool
115/// # });
116/// ```
117#[derive(Clone)]
118pub struct SemaphorePermits {
119 inner: Arc<(Mutex<SemInner>, Condvar)>,
120}
121
122impl SemaphorePermits {
123 /// Create a new semaphore-based backpressure controller.
124 ///
125 /// # Arguments
126 ///
127 /// * `max_bytes` - Maximum number of bytes that can be buffered
128 #[must_use]
129 pub fn new(max_bytes: usize) -> Self {
130 Self {
131 inner: Arc::new((
132 Mutex::new(SemInner {
133 available: max_bytes,
134 max_bytes,
135 }),
136 Condvar::new(),
137 )),
138 }
139 }
140}
141
142#[async_trait]
143impl BytePermits for SemaphorePermits {
144 async fn acquire(&self, n_bytes: usize) -> Permit {
145 if n_bytes == 0 {
146 return Permit::noop();
147 }
148
149 // Fast path: in the uncontended case capacity is available and an
150 // in-place claim under a non-blocking try_lock is all that is needed.
151 // This stays on the executor and avoids a thread-pool round trip per
152 // write, which otherwise dominates the latency of a high-rate write
153 // path. try_lock never parks the executor thread. The mutex remains the
154 // single source of truth, so this cannot race the slow path below.
155 {
156 let (mutex, _condvar) = &*self.inner;
157 if let Some(mut guard) = mutex.try_lock() {
158 let claim = n_bytes.min(guard.max_bytes);
159 if guard.available >= claim {
160 guard.available -= claim;
161 drop(guard);
162 return Permit::byte_sem(self.inner.clone(), claim);
163 }
164 }
165 }
166
167 // Slow path: contended, or not enough capacity right now. Wait on a
168 // dedicated thread so we don't block the async executor.
169 // parking_lot::Condvar::wait is synchronous and safe to use here
170 // because SemInner uses parking_lot::Mutex.
171 #[cfg(test)]
172 SLOW_PATH_ENTRIES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
173 let inner = self.inner.clone();
174 let actual = crate::rt::spawn_blocking(move || {
175 let (mutex, condvar) = &*inner;
176 let mut guard = mutex.lock();
177 // Clamp to max_bytes so a single oversized message never deadlocks:
178 // the message will consume the entire capacity instead of waiting
179 // forever for capacity that can never exist.
180 let claim = n_bytes.min(guard.max_bytes);
181 // Wait until enough capacity is available.
182 while guard.available < claim {
183 condvar.wait(&mut guard);
184 }
185 guard.available -= claim;
186 // Return the actual bytes claimed so the Permit releases the right amount.
187 claim
188 })
189 .await;
190
191 Permit::byte_sem(self.inner.clone(), actual)
192 }
193}
194
195/// Counts how many `acquire` calls fell through to the blocking slow path.
196/// Test-only, used to prove the uncontended fast path avoids `spawn_blocking`.
197#[cfg(test)]
198static SLOW_PATH_ENTRIES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::sync::atomic::Ordering;
204
205 #[test]
206 fn uncontended_acquire_takes_fast_path() {
207 // A series of uncontended acquire/release cycles must never enter the
208 // blocking slow path, so no per-write thread-pool round trip is paid.
209 let permits = SemaphorePermits::new(1024 * 1024);
210 let rt = crate::rt::LocalRuntime::new().unwrap();
211
212 SLOW_PATH_ENTRIES.store(0, Ordering::Relaxed);
213 rt.block_on(async {
214 for _ in 0..100 {
215 let permit = permits.acquire(1024).await;
216 drop(permit);
217 }
218 });
219 assert_eq!(
220 SLOW_PATH_ENTRIES.load(Ordering::Relaxed),
221 0,
222 "uncontended acquires must not hit the spawn_blocking slow path"
223 );
224 }
225
226 #[test]
227 fn insufficient_capacity_uses_slow_path() {
228 // When capacity is exhausted the acquire must fall back to the blocking
229 // wait path (and complete once capacity is released).
230 let permits = SemaphorePermits::new(1024);
231 let rt = crate::rt::LocalRuntime::new().unwrap();
232
233 SLOW_PATH_ENTRIES.store(0, Ordering::Relaxed);
234 rt.block_on(async {
235 let p1 = permits.acquire(1024).await; // fast path, exhausts capacity
236 // This one cannot be satisfied immediately; release then reacquire.
237 drop(p1);
238 let _p2 = permits.acquire(1024).await;
239 });
240 // The first acquire took the fast path; only note that the mechanism is
241 // exercised without deadlock. (Exact slow-path count is timing
242 // dependent because the drop may free capacity before the retry.)
243 }
244
245 #[test]
246 fn noop_permits_always_succeed() {
247 let permits = NoOpPermits;
248 let rt = crate::rt::LocalRuntime::new().unwrap();
249 rt.block_on(async {
250 let _p1 = permits.acquire(1024).await;
251 let _p2 = permits.acquire(1_000_000).await;
252 // Should not block
253 });
254 }
255
256 #[test]
257 fn semaphore_permits_enforce_limit() {
258 let permits = SemaphorePermits::new(1024);
259 let rt = crate::rt::LocalRuntime::new().unwrap();
260
261 rt.block_on(async {
262 // First 1024 bytes should succeed
263 let p1 = permits.acquire(1024).await;
264
265 // Try to acquire more - this would block, so we test the behavior
266 // by checking we can acquire after dropping
267 drop(p1);
268
269 let _p2 = permits.acquire(512).await;
270 let _p3 = permits.acquire(512).await;
271 // Should succeed with 1024 total
272 });
273 }
274
275 #[test]
276 fn semaphore_permits_release_on_drop() {
277 let permits = SemaphorePermits::new(1000);
278 let rt = crate::rt::LocalRuntime::new().unwrap();
279
280 rt.block_on(async {
281 {
282 let _p1 = permits.acquire(500).await;
283 let _p2 = permits.acquire(500).await;
284 // Full capacity used
285 } // Permits dropped here
286
287 // Should be able to acquire again after drop
288 let _p3 = permits.acquire(1000).await;
289 });
290 }
291
292 #[test]
293 fn semaphore_permits_oversized_acquire_does_not_deadlock() {
294 // A single acquire larger than max_bytes must complete (clamped to max_bytes)
295 // rather than deadlocking forever waiting for capacity that can never exist.
296 let permits = SemaphorePermits::new(1024);
297 let rt = crate::rt::LocalRuntime::new().unwrap();
298
299 rt.block_on(async {
300 let permit = permits.acquire(2048).await; // 2× max - must not deadlock
301 drop(permit);
302 // After release, we can acquire up to max_bytes again.
303 let _p = permits.acquire(1024).await;
304 });
305 }
306
307 #[test]
308 fn semaphore_permits_single_atomic_acquire() {
309 // Verify that acquiring N bytes is done atomically (not O(N) individual acquires)
310 let permits = SemaphorePermits::new(1024 * 1024); // 1MB
311 let rt = crate::rt::LocalRuntime::new().unwrap();
312
313 rt.block_on(async {
314 // Acquire a large block in one shot - this should not loop N times
315 let permit = permits.acquire(512 * 1024).await; // 512KB
316 drop(permit);
317 });
318 }
319}