Skip to main content

subetha_cxc/
shared_rate_limiter.rs

1//! `SharedRateLimiter` - cross-process token-bucket rate limiter.
2//!
3//! Tokens accumulate at a configured rate up to a configured
4//! capacity; `acquire(n)` atomically deducts n tokens or returns
5//! `Err(InsufficientTokens)`. Refill happens lazily on each
6//! acquire - no background thread needed.
7//!
8//! # Layout
9//!
10//! Single MMF file:
11//!
12//! ```text
13//! +---------------------------+
14//! | RateLimiterHeader (64B)   |
15//! |   magic, capacity         |
16//! |   refill_rate_per_sec     |
17//! |   state: AtomicU64        |  // packed (tokens, refill_us_low)
18//! +---------------------------+
19//! ```
20//!
21//! # Packed state
22//!
23//! The hot atomic packs `(tokens_remaining: u32, last_refill_us_low: u32)`
24//! into one u64. Updates are CAS-only so multiple processes
25//! concurrently acquiring don't race-update either field
26//! independently.
27//!
28//! - `tokens_remaining` (low 32 bits) supports capacities up to
29//!   ~4B tokens; well past any realistic rate-limit budget.
30//! - `last_refill_us_low` (high 32 bits) holds the low 32 bits of
31//!   the wall-clock-microsecond timestamp at the last refill. Low
32//!   32 bits give a 4295-second (~71 minute) window before
33//!   wrap-around, which is FAR longer than any acquire-to-acquire
34//!   gap in practice. Wrap-around handles correctly via wrapping
35//!   subtraction.
36//!
37//! # Refill on acquire
38//!
39//! Each `acquire(n)` first computes how many tokens should have
40//! been refilled since the last refill: `elapsed_us *
41//! refill_rate_per_sec / 1_000_000`. The new token count is
42//! `min(current + refilled, capacity)`. Then `n` is subtracted; if
43//! the result goes negative, the acquire fails without
44//! modifying state.
45
46use std::fs::{File, OpenOptions};
47use std::mem::size_of;
48use std::path::Path;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
51
52use memmap2::{MmapMut, MmapOptions};
53
54pub const RATE_LIMITER_MAGIC: u64 = 0x4150_5246_4C4D_5452;
55
56#[repr(C, align(64))]
57pub struct RateLimiterHeader {
58    pub magic: u64,
59    pub capacity: u32,
60    pub refill_rate_per_sec: u32,
61    pub state: AtomicU64,
62    _pad: [u8; 40],
63}
64
65const _: () = {
66    assert!(size_of::<RateLimiterHeader>() == 64);
67};
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum RateLimiterError {
71    InsufficientTokens { available: u32, requested: u32 },
72    Timeout,
73    InvalidConfig,
74    LayoutMismatch,
75    IoError(std::io::ErrorKind),
76}
77
78impl From<std::io::Error> for RateLimiterError {
79    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
80}
81
82#[inline]
83fn now_us_low() -> u32 {
84    let micros = SystemTime::now()
85        .duration_since(UNIX_EPOCH)
86        .map(|d| d.as_micros() as u64)
87        .unwrap_or(0);
88    micros as u32
89}
90
91#[inline]
92fn pack_state(tokens: u32, refill_us_low: u32) -> u64 {
93    ((refill_us_low as u64) << 32) | (tokens as u64)
94}
95#[inline]
96fn unpack_state(state: u64) -> (u32, u32) {
97    (state as u32, (state >> 32) as u32)
98}
99
100pub struct SharedRateLimiter {
101    _file: File,
102    mmap: MmapMut,
103    header_sidecar: subetha_core::HandshakeHeader,
104    ring_sidecar: Box<subetha_core::ObservationRing>,
105}
106
107unsafe impl Send for SharedRateLimiter {}
108unsafe impl Sync for SharedRateLimiter {}
109
110impl subetha_sidecar::AdaptiveInstance for SharedRateLimiter {
111    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
112    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
113    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
114        Box::new(subetha_sidecar::NoMigrationPolicy)
115    }
116}
117
118impl SharedRateLimiter {
119    /// Create a rate limiter. Starts with `capacity` tokens (full
120    /// bucket). `refill_rate_per_sec` controls the steady-state
121    /// rate; both fields must be > 0.
122    pub fn create(
123        path: impl AsRef<Path>, capacity: u32, refill_rate_per_sec: u32,
124    ) -> Result<Self, RateLimiterError> {
125        if capacity == 0 || refill_rate_per_sec == 0 {
126            return Err(RateLimiterError::InvalidConfig);
127        }
128        let total = size_of::<RateLimiterHeader>();
129        let file = OpenOptions::new()
130            .read(true).write(true).create(true).truncate(true)
131            .open(path.as_ref())?;
132        file.set_len(total as u64)?;
133        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
134        let hdr = mmap.as_mut_ptr() as *mut RateLimiterHeader;
135        unsafe {
136            std::ptr::write_bytes(hdr as *mut u8, 0, total);
137            (*hdr).magic = RATE_LIMITER_MAGIC;
138            (*hdr).capacity = capacity;
139            (*hdr).refill_rate_per_sec = refill_rate_per_sec;
140            (*hdr).state.store(
141                pack_state(capacity, now_us_low()),
142                Ordering::Release,
143            );
144        }
145        Ok(Self {
146            _file: file, mmap,
147            header_sidecar: subetha_core::HandshakeHeader::new(),
148            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
149        })
150    }
151
152    pub fn open(
153        path: impl AsRef<Path>, capacity: u32, refill_rate_per_sec: u32,
154    ) -> Result<Self, RateLimiterError> {
155        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
156        let total = size_of::<RateLimiterHeader>();
157        if file.metadata()?.len() < total as u64 {
158            return Err(RateLimiterError::LayoutMismatch);
159        }
160        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
161        let hdr = unsafe { &*(mmap.as_ptr() as *const RateLimiterHeader) };
162        if hdr.magic != RATE_LIMITER_MAGIC
163            || hdr.capacity != capacity
164            || hdr.refill_rate_per_sec != refill_rate_per_sec
165        {
166            return Err(RateLimiterError::LayoutMismatch);
167        }
168        Ok(Self {
169            _file: file, mmap,
170            header_sidecar: subetha_core::HandshakeHeader::new(),
171            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
172        })
173    }
174
175    fn header(&self) -> &RateLimiterHeader {
176        unsafe { &*(self.mmap.as_ptr() as *const RateLimiterHeader) }
177    }
178
179    #[inline]
180    pub fn capacity(&self) -> u32 { self.header().capacity }
181    #[inline]
182    pub fn refill_rate_per_sec(&self) -> u32 { self.header().refill_rate_per_sec }
183
184    /// Compute refilled tokens given an elapsed-microsecond delta
185    /// (handles 32-bit wraparound via wrapping_sub).
186    #[inline]
187    fn refill_amount(&self, prev_refill_us: u32, now_us: u32) -> u32 {
188        // wrapping_sub handles the 71-minute wrap correctly.
189        let elapsed = now_us.wrapping_sub(prev_refill_us) as u64;
190        let rate = self.refill_rate_per_sec() as u64;
191        let refilled = (elapsed * rate) / 1_000_000;
192        // Clamp to u32 - extremely long gaps overflow.
193        refilled.min(u32::MAX as u64) as u32
194    }
195
196    /// Read current available tokens (does NOT mutate state).
197    /// Returns the count after accounting for refill since the
198    /// last update.
199    pub fn available(&self) -> u32 {
200        let state = self.header().state.load(Ordering::Acquire);
201        let (tokens, refill_us) = unpack_state(state);
202        let now = now_us_low();
203        let refilled = self.refill_amount(refill_us, now);
204        let cap = self.capacity();
205        let v = (tokens.saturating_add(refilled)).min(cap);
206        self.ring_sidecar
207            .push_op(crate::sidecar_ops::rate_limiter::OP_AVAILABLE, 0);
208        v
209    }
210
211    /// Non-blocking acquire. Atomically refills and deducts `n`
212    /// tokens. Returns `Err(InsufficientTokens)` immediately if
213    /// fewer than `n` tokens are available after refill.
214    pub fn try_acquire(&self, n: u32) -> Result<(), RateLimiterError> {
215        loop {
216            let state = self.header().state.load(Ordering::Acquire);
217            let (tokens, refill_us) = unpack_state(state);
218
219            // Fast path: the bucket already holds enough tokens. Refill
220            // only ever ADDS, so `tokens >= n` guarantees the post-refill
221            // count would also satisfy `n` - we can deduct without reading
222            // the clock. `refill_us` is kept unchanged, deferring the
223            // refill accounting: the next time the bucket runs short, the
224            // clock read credits the entire elapsed interval (capped at
225            // capacity), so no tokens are lost and the long-run rate is
226            // preserved. This makes under-limit traffic - the common case -
227            // free of the `clock_gettime` the slow path pays.
228            if tokens >= n {
229                let new_state = pack_state(tokens - n, refill_us);
230                if self.header().state.compare_exchange(
231                    state, new_state, Ordering::AcqRel, Ordering::Acquire,
232                ).is_ok() {
233                    self.ring_sidecar
234                        .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 0);
235                    return Ok(());
236                }
237                continue; // CAS lost; reload and retry.
238            }
239
240            // Slow path: short on tokens - read the clock and refill.
241            let now = now_us_low();
242            let refilled = self.refill_amount(refill_us, now);
243            let cap = self.capacity();
244            let after_refill = (tokens.saturating_add(refilled)).min(cap);
245            if after_refill < n {
246                self.ring_sidecar
247                    .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 1); // insufficient tokens
248                return Err(RateLimiterError::InsufficientTokens {
249                    available: after_refill, requested: n,
250                });
251            }
252            let new_tokens = after_refill - n;
253            let new_state = pack_state(new_tokens, now);
254            if self.header().state.compare_exchange(
255                state, new_state, Ordering::AcqRel, Ordering::Acquire,
256            ).is_ok() {
257                self.ring_sidecar
258                    .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 0);
259                return Ok(());
260            }
261            // CAS lost; retry.
262        }
263    }
264
265    /// Blocking acquire with deadline. Spins with backoff until
266    /// enough tokens are available OR the deadline passes.
267    pub fn acquire_or_wait(
268        &self, n: u32, timeout: Duration,
269    ) -> Result<(), RateLimiterError> {
270        if n > self.capacity() {
271            return Err(RateLimiterError::InsufficientTokens {
272                available: self.capacity(), requested: n,
273            });
274        }
275        let deadline = Instant::now() + timeout;
276        let mut spins = 0u32;
277        loop {
278            match self.try_acquire(n) {
279                Ok(()) => return Ok(()),
280                Err(RateLimiterError::InsufficientTokens { .. }) => {}
281                Err(e) => return Err(e),
282            }
283            if Instant::now() >= deadline {
284                return Err(RateLimiterError::Timeout);
285            }
286            spins += 1;
287            if spins < 32 {
288                std::hint::spin_loop();
289            } else if spins < 256 {
290                std::thread::yield_now();
291            } else {
292                // Compute how long until we expect enough tokens.
293                let need = n.saturating_sub(self.available());
294                if need == 0 { continue; }
295                let micros_needed = (need as u64 * 1_000_000) / self.refill_rate_per_sec() as u64;
296                let sleep_us = micros_needed.min(10_000); // cap at 10ms
297                std::thread::sleep(Duration::from_micros(sleep_us));
298            }
299        }
300    }
301
302    /// Reset tokens to full capacity. Useful for tests / admin
303    /// recovery. Not concurrency-coordinated; expect transient
304    /// races with concurrent acquires.
305    pub fn reset(&self) {
306        self.header().state.store(
307            pack_state(self.capacity(), now_us_low()),
308            Ordering::Release,
309        );
310    }
311
312    pub fn flush(&self) -> Result<(), RateLimiterError> {
313        self.mmap.flush()?;
314        Ok(())
315    }
316
317    pub fn flush_async(&self) -> Result<(), RateLimiterError> {
318        self.mmap.flush_async()?;
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::sync::Arc;
327    use std::thread;
328
329    fn tmp(name: &str) -> std::path::PathBuf {
330        let mut p = std::env::temp_dir();
331        let pid = std::process::id();
332        p.push(format!("subetha-ratelim-{name}-{pid}.bin"));
333        p
334    }
335
336    #[test]
337    fn create_starts_with_full_bucket() {
338        let p = tmp("init");
339        let r = SharedRateLimiter::create(&p, 100, 10).unwrap();
340        assert_eq!(r.capacity(), 100);
341        assert_eq!(r.refill_rate_per_sec(), 10);
342        assert_eq!(r.available(), 100);
343        std::fs::remove_file(&p).ok();
344    }
345
346    #[test]
347    fn invalid_config_rejected() {
348        let p = tmp("invalid");
349        assert_eq!(
350            SharedRateLimiter::create(&p, 0, 10).err(),
351            Some(RateLimiterError::InvalidConfig)
352        );
353        assert_eq!(
354            SharedRateLimiter::create(&p, 10, 0).err(),
355            Some(RateLimiterError::InvalidConfig)
356        );
357        std::fs::remove_file(&p).ok();
358    }
359
360    #[test]
361    fn try_acquire_deducts_tokens() {
362        let p = tmp("deduct");
363        let r = SharedRateLimiter::create(&p, 100, 1).unwrap();  // slow refill
364        r.try_acquire(30).unwrap();
365        let avail = r.available();
366        // available may include a few refilled tokens (microsecond
367        // elapsed at rate=1/s gives < 1 token). Should be near 70.
368        assert!((70..=71).contains(&avail), "after 30-token acquire from cap 100, available={avail} should be ~70");
369        std::fs::remove_file(&p).ok();
370    }
371
372    #[test]
373    fn empty_bucket_rejects() {
374        let p = tmp("empty");
375        let r = SharedRateLimiter::create(&p, 5, 1).unwrap();  // slow refill
376        r.try_acquire(5).unwrap();
377        // Immediately try to acquire more (no time has passed).
378        match r.try_acquire(1) {
379            Err(RateLimiterError::InsufficientTokens { available, requested }) => {
380                assert!(available < 1);
381                assert_eq!(requested, 1);
382            }
383            other => panic!("expected InsufficientTokens, got {other:?}"),
384        }
385        std::fs::remove_file(&p).ok();
386    }
387
388    #[test]
389    fn refill_scales_with_elapsed_time() {
390        let p = tmp("refill");
391        // 1000 tokens/sec means 1 token per millisecond.
392        let r = SharedRateLimiter::create(&p, 100, 1000).unwrap();
393        // Drain.
394        r.try_acquire(100).unwrap();
395        assert!(r.available() < 5, "after full drain, available should be ~0");
396        // Wait 30ms; expect ~30 tokens to have refilled.
397        thread::sleep(Duration::from_millis(30));
398        let after = r.available();
399        assert!((25..=40).contains(&after),
400            "after 30ms at 1000/s, available={after} should be ~30");
401        std::fs::remove_file(&p).ok();
402    }
403
404    #[test]
405    fn refill_clamped_to_capacity() {
406        let p = tmp("clamp");
407        let r = SharedRateLimiter::create(&p, 50, 10_000).unwrap();
408        // Drain.
409        r.try_acquire(50).unwrap();
410        // Wait long enough that uncapped refill exceeds capacity.
411        thread::sleep(Duration::from_millis(100));  // refills 1000 uncapped
412        assert_eq!(r.available(), 50, "available should clamp to capacity");
413        std::fs::remove_file(&p).ok();
414    }
415
416    #[test]
417    fn acquire_or_wait_blocks_then_succeeds() {
418        let p = tmp("wait");
419        // 100 tokens/sec = 1 every 10ms.
420        let r = SharedRateLimiter::create(&p, 1, 100).unwrap();
421        r.try_acquire(1).unwrap();
422        let start = Instant::now();
423        // Need 1 more token; should wait ~10ms.
424        r.acquire_or_wait(1, Duration::from_millis(500)).unwrap();
425        let elapsed = start.elapsed();
426        assert!(elapsed >= Duration::from_millis(5),
427            "should have waited some time, got {elapsed:?}");
428        assert!(elapsed < Duration::from_millis(100),
429            "should have completed quickly, got {elapsed:?}");
430        std::fs::remove_file(&p).ok();
431    }
432
433    #[test]
434    fn acquire_or_wait_returns_timeout() {
435        let p = tmp("timeout");
436        let r = SharedRateLimiter::create(&p, 1, 1).unwrap();  // 1 per second
437        r.try_acquire(1).unwrap();
438        let start = Instant::now();
439        let result = r.acquire_or_wait(1, Duration::from_millis(50));
440        let elapsed = start.elapsed();
441        assert!(matches!(result, Err(RateLimiterError::Timeout)));
442        assert!(elapsed >= Duration::from_millis(40),
443            "should have waited ~50ms, got {elapsed:?}");
444        std::fs::remove_file(&p).ok();
445    }
446
447    #[test]
448    fn acquire_or_wait_oversized_request_fails_fast() {
449        let p = tmp("oversize");
450        let r = SharedRateLimiter::create(&p, 10, 100).unwrap();
451        // Requesting more than capacity can never be satisfied.
452        let result = r.acquire_or_wait(100, Duration::from_secs(10));
453        assert!(matches!(result, Err(RateLimiterError::InsufficientTokens { .. })));
454        std::fs::remove_file(&p).ok();
455    }
456
457    #[test]
458    fn concurrent_acquirers_sum_to_at_most_capacity_no_refill() {
459        let p = tmp("concurrent");
460        // Slow refill so the test window sees ~no refilled tokens.
461        let r = Arc::new(SharedRateLimiter::create(&p, 100, 1).unwrap());
462        let n_threads = 8;
463        let per_thread = 50;
464        let mut handles = vec![];
465        for _ in 0..n_threads {
466            let r = r.clone();
467            handles.push(thread::spawn(move || {
468                let mut acquired = 0u32;
469                for _ in 0..per_thread {
470                    if r.try_acquire(1).is_ok() { acquired += 1; }
471                }
472                acquired
473            }));
474        }
475        let total: u32 = handles.into_iter()
476            .map(|h| h.join().unwrap()).sum();
477        // Total acquired across all threads must be <= capacity +
478        // very small refill (rate=1/sec, test window << 1 sec).
479        assert!(total <= 101, "total acquired {total} should not exceed capacity {} + tiny refill", 100);
480        // We should have acquired exactly the capacity (or very close).
481        assert!(total >= 95, "total acquired {total} should be near capacity 100");
482        std::fs::remove_file(&p).ok();
483    }
484
485    #[test]
486    fn cross_handle_state_shared() {
487        let p = tmp("cross-handle");
488        let writer = SharedRateLimiter::create(&p, 100, 10).unwrap();
489        let reader = SharedRateLimiter::open(&p, 100, 10).unwrap();
490        writer.try_acquire(40).unwrap();
491        let avail = reader.available();
492        assert!((59..=60).contains(&avail));
493        std::fs::remove_file(&p).ok();
494    }
495
496    #[test]
497    fn config_mismatch_at_open_rejected() {
498        let p = tmp("mismatch");
499        let _w = SharedRateLimiter::create(&p, 100, 10).unwrap();
500        assert!(matches!(
501            SharedRateLimiter::open(&p, 50, 10),
502            Err(RateLimiterError::LayoutMismatch)
503        ));
504        assert!(matches!(
505            SharedRateLimiter::open(&p, 100, 20),
506            Err(RateLimiterError::LayoutMismatch)
507        ));
508        std::fs::remove_file(&p).ok();
509    }
510
511    #[test]
512    fn reset_refills_to_capacity() {
513        let p = tmp("reset");
514        let r = SharedRateLimiter::create(&p, 50, 1).unwrap();
515        r.try_acquire(50).unwrap();
516        assert!(r.available() < 2);
517        r.reset();
518        assert_eq!(r.available(), 50);
519        std::fs::remove_file(&p).ok();
520    }
521
522    #[test]
523    fn disk_persistence_survives_reopen() {
524        let p = tmp("disk");
525        {
526            let r = SharedRateLimiter::create(&p, 50, 1).unwrap();
527            r.try_acquire(20).unwrap();
528            r.flush().unwrap();
529        }
530        let r2 = SharedRateLimiter::open(&p, 50, 1).unwrap();
531        let avail = r2.available();
532        // Should be ~30 plus any time elapsed at rate 1/s (likely 0-1).
533        assert!((30..=31).contains(&avail),
534            "after reopen available={avail} should be ~30");
535        std::fs::remove_file(&p).ok();
536    }
537
538    #[test]
539    fn acquire_zero_is_noop() {
540        let p = tmp("zero");
541        let r = SharedRateLimiter::create(&p, 100, 1).unwrap();
542        let before = r.available();
543        r.try_acquire(0).unwrap();
544        let after = r.available();
545        // available may have incremented by 0-1 due to elapsed time,
546        // but shouldn't have decreased.
547        assert!(after >= before);
548        std::fs::remove_file(&p).ok();
549    }
550}