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    /// Obtain the limiter at `path`, initializing a full bucket if the
120    /// path does not yet exist and attaching to it if it does.
121    /// Attaching leaves the live token count in place; a region built
122    /// with a different capacity or refill rate is a `LayoutMismatch`.
123    /// `refill_rate_per_sec` controls the steady-state rate; both
124    /// fields must be > 0. [`reset`](Self::reset) refills a live
125    /// bucket in place.
126    pub fn create(
127        path: impl AsRef<Path>, capacity: u32, refill_rate_per_sec: u32,
128    ) -> Result<Self, RateLimiterError> {
129        if capacity == 0 || refill_rate_per_sec == 0 {
130            return Err(RateLimiterError::InvalidConfig);
131        }
132        let (file, mmap) = crate::mmf_attach::create_or_attach(
133            path.as_ref(),
134            size_of::<RateLimiterHeader>(),
135            |ptr| unsafe { Self::init_region(ptr, capacity, refill_rate_per_sec) },
136            |ptr| unsafe { (*(ptr as *const RateLimiterHeader)).magic == RATE_LIMITER_MAGIC },
137        )?;
138        Self::from_region(file, mmap, capacity, refill_rate_per_sec)
139    }
140
141    /// Lay out a full bucket: config fields and the packed state first,
142    /// magic last, because attachers spin on it.
143    ///
144    /// # Safety
145    /// `ptr` addresses at least `size_of::<RateLimiterHeader>()`
146    /// writable zeroed bytes.
147    unsafe fn init_region(ptr: *mut u8, capacity: u32, refill_rate_per_sec: u32) {
148        let hdr = ptr as *mut RateLimiterHeader;
149        unsafe {
150            (*hdr).capacity = capacity;
151            (*hdr).refill_rate_per_sec = refill_rate_per_sec;
152            std::ptr::write(
153                &raw mut (*hdr).state,
154                AtomicU64::new(pack_state(capacity, now_us_low())),
155            );
156            std::ptr::write_volatile(&raw mut (*hdr).magic, RATE_LIMITER_MAGIC);
157        }
158    }
159
160    /// Wrap an initialized region, refusing one built with a different
161    /// capacity or refill rate.
162    fn from_region(
163        file: File,
164        mmap: MmapMut,
165        capacity: u32,
166        refill_rate_per_sec: u32,
167    ) -> Result<Self, RateLimiterError> {
168        let hdr = unsafe { &*(mmap.as_ptr() as *const RateLimiterHeader) };
169        if hdr.magic != RATE_LIMITER_MAGIC
170            || hdr.capacity != capacity
171            || hdr.refill_rate_per_sec != refill_rate_per_sec
172        {
173            return Err(RateLimiterError::LayoutMismatch);
174        }
175        Ok(Self {
176            _file: file, mmap,
177            header_sidecar: subetha_core::HandshakeHeader::new(),
178            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
179        })
180    }
181
182    pub fn open(
183        path: impl AsRef<Path>, capacity: u32, refill_rate_per_sec: u32,
184    ) -> Result<Self, RateLimiterError> {
185        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
186        let total = size_of::<RateLimiterHeader>();
187        if file.metadata()?.len() < total as u64 {
188            return Err(RateLimiterError::LayoutMismatch);
189        }
190        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
191        Self::from_region(file, mmap, capacity, refill_rate_per_sec)
192    }
193
194    fn header(&self) -> &RateLimiterHeader {
195        unsafe { &*(self.mmap.as_ptr() as *const RateLimiterHeader) }
196    }
197
198    #[inline]
199    pub fn capacity(&self) -> u32 { self.header().capacity }
200    #[inline]
201    pub fn refill_rate_per_sec(&self) -> u32 { self.header().refill_rate_per_sec }
202
203    /// Compute refilled tokens given an elapsed-microsecond delta
204    /// (handles 32-bit wraparound via wrapping_sub).
205    #[inline]
206    fn refill_amount(&self, prev_refill_us: u32, now_us: u32) -> u32 {
207        // wrapping_sub handles the 71-minute wrap correctly.
208        let elapsed = now_us.wrapping_sub(prev_refill_us) as u64;
209        let rate = self.refill_rate_per_sec() as u64;
210        let refilled = (elapsed * rate) / 1_000_000;
211        // Clamp to u32 - extremely long gaps overflow.
212        refilled.min(u32::MAX as u64) as u32
213    }
214
215    /// Read current available tokens (does NOT mutate state).
216    /// Returns the count after accounting for refill since the
217    /// last update.
218    pub fn available(&self) -> u32 {
219        let state = self.header().state.load(Ordering::Acquire);
220        let (tokens, refill_us) = unpack_state(state);
221        let now = now_us_low();
222        let refilled = self.refill_amount(refill_us, now);
223        let cap = self.capacity();
224        let v = (tokens.saturating_add(refilled)).min(cap);
225        self.ring_sidecar
226            .push_op(crate::sidecar_ops::rate_limiter::OP_AVAILABLE, 0);
227        v
228    }
229
230    /// Non-blocking acquire. Atomically refills and deducts `n`
231    /// tokens. Returns `Err(InsufficientTokens)` immediately if
232    /// fewer than `n` tokens are available after refill.
233    pub fn try_acquire(&self, n: u32) -> Result<(), RateLimiterError> {
234        loop {
235            let state = self.header().state.load(Ordering::Acquire);
236            let (tokens, refill_us) = unpack_state(state);
237
238            // Fast path: the bucket already holds enough tokens. Refill
239            // only ever ADDS, so `tokens >= n` guarantees the post-refill
240            // count would also satisfy `n` - we can deduct without reading
241            // the clock. `refill_us` is kept unchanged, deferring the
242            // refill accounting: the next time the bucket runs short, the
243            // clock read credits the entire elapsed interval (capped at
244            // capacity), so no tokens are lost and the long-run rate is
245            // preserved. This makes under-limit traffic - the common case -
246            // free of the `clock_gettime` the slow path pays.
247            if tokens >= n {
248                let new_state = pack_state(tokens - n, refill_us);
249                if self.header().state.compare_exchange(
250                    state, new_state, Ordering::AcqRel, Ordering::Acquire,
251                ).is_ok() {
252                    self.ring_sidecar
253                        .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 0);
254                    return Ok(());
255                }
256                continue; // CAS lost; reload and retry.
257            }
258
259            // Slow path: short on tokens - read the clock and refill.
260            let now = now_us_low();
261            let refilled = self.refill_amount(refill_us, now);
262            let cap = self.capacity();
263            let after_refill = (tokens.saturating_add(refilled)).min(cap);
264            if after_refill < n {
265                self.ring_sidecar
266                    .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 1); // insufficient tokens
267                return Err(RateLimiterError::InsufficientTokens {
268                    available: after_refill, requested: n,
269                });
270            }
271            let new_tokens = after_refill - n;
272            let new_state = pack_state(new_tokens, now);
273            if self.header().state.compare_exchange(
274                state, new_state, Ordering::AcqRel, Ordering::Acquire,
275            ).is_ok() {
276                self.ring_sidecar
277                    .push_op(crate::sidecar_ops::rate_limiter::OP_TRY_ACQUIRE, 0);
278                return Ok(());
279            }
280            // CAS lost; retry.
281        }
282    }
283
284    /// Blocking acquire with deadline. Spins with backoff until
285    /// enough tokens are available OR the deadline passes.
286    pub fn acquire_or_wait(
287        &self, n: u32, timeout: Duration,
288    ) -> Result<(), RateLimiterError> {
289        if n > self.capacity() {
290            return Err(RateLimiterError::InsufficientTokens {
291                available: self.capacity(), requested: n,
292            });
293        }
294        let deadline = Instant::now() + timeout;
295        let mut spins = 0u32;
296        loop {
297            match self.try_acquire(n) {
298                Ok(()) => return Ok(()),
299                Err(RateLimiterError::InsufficientTokens { .. }) => {}
300                Err(e) => return Err(e),
301            }
302            if Instant::now() >= deadline {
303                return Err(RateLimiterError::Timeout);
304            }
305            spins += 1;
306            if spins < 32 {
307                std::hint::spin_loop();
308            } else if spins < 256 {
309                std::thread::yield_now();
310            } else {
311                // Compute how long until we expect enough tokens.
312                let need = n.saturating_sub(self.available());
313                if need == 0 { continue; }
314                let micros_needed = (need as u64 * 1_000_000) / self.refill_rate_per_sec() as u64;
315                let sleep_us = micros_needed.min(10_000); // cap at 10ms
316                std::thread::sleep(Duration::from_micros(sleep_us));
317            }
318        }
319    }
320
321    /// Reset tokens to full capacity. Useful for tests / admin
322    /// recovery. Not concurrency-coordinated; expect transient
323    /// races with concurrent acquires.
324    pub fn reset(&self) {
325        self.header().state.store(
326            pack_state(self.capacity(), now_us_low()),
327            Ordering::Release,
328        );
329    }
330
331    pub fn flush(&self) -> Result<(), RateLimiterError> {
332        self.mmap.flush()?;
333        Ok(())
334    }
335
336    pub fn flush_async(&self) -> Result<(), RateLimiterError> {
337        self.mmap.flush_async()?;
338        Ok(())
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::sync::Arc;
346    use std::thread;
347
348    fn tmp(name: &str) -> std::path::PathBuf {
349        let mut p = std::env::temp_dir();
350        let pid = std::process::id();
351        p.push(format!("subetha-ratelim-{name}-{pid}.bin"));
352        p
353    }
354
355    #[test]
356    fn create_starts_with_full_bucket() {
357        let p = tmp("init");
358        let r = SharedRateLimiter::create(&p, 100, 10).unwrap();
359        assert_eq!(r.capacity(), 100);
360        assert_eq!(r.refill_rate_per_sec(), 10);
361        assert_eq!(r.available(), 100);
362        std::fs::remove_file(&p).ok();
363    }
364
365    /// A second create attaches with the live token count in place;
366    /// the in-place reset is what refills.
367    #[test]
368    fn second_create_attaches_and_keeps_the_bucket() {
369        let p = tmp("attach");
370        std::fs::remove_file(&p).ok();
371        let r = SharedRateLimiter::create(&p, 100, 1).unwrap();
372        r.try_acquire(40).unwrap();
373
374        let r2 = SharedRateLimiter::create(&p, 100, 1).unwrap();
375        assert_eq!(r2.available(), 60, "attach refilled a live bucket");
376        assert!(matches!(
377            SharedRateLimiter::create(&p, 50, 1),
378            Err(RateLimiterError::LayoutMismatch),
379        ));
380
381        r2.reset();
382        assert_eq!(r.available(), 100, "reset did not refill for every handle");
383        drop(r);
384        drop(r2);
385        std::fs::remove_file(&p).ok();
386    }
387
388    #[test]
389    fn invalid_config_rejected() {
390        let p = tmp("invalid");
391        assert_eq!(
392            SharedRateLimiter::create(&p, 0, 10).err(),
393            Some(RateLimiterError::InvalidConfig)
394        );
395        assert_eq!(
396            SharedRateLimiter::create(&p, 10, 0).err(),
397            Some(RateLimiterError::InvalidConfig)
398        );
399        std::fs::remove_file(&p).ok();
400    }
401
402    #[test]
403    fn try_acquire_deducts_tokens() {
404        let p = tmp("deduct");
405        let r = SharedRateLimiter::create(&p, 100, 1).unwrap();  // slow refill
406        r.try_acquire(30).unwrap();
407        let avail = r.available();
408        // available may include a few refilled tokens (microsecond
409        // elapsed at rate=1/s gives < 1 token). Should be near 70.
410        assert!((70..=71).contains(&avail), "after 30-token acquire from cap 100, available={avail} should be ~70");
411        std::fs::remove_file(&p).ok();
412    }
413
414    #[test]
415    fn empty_bucket_rejects() {
416        let p = tmp("empty");
417        let r = SharedRateLimiter::create(&p, 5, 1).unwrap();  // slow refill
418        r.try_acquire(5).unwrap();
419        // Immediately try to acquire more (no time has passed).
420        match r.try_acquire(1) {
421            Err(RateLimiterError::InsufficientTokens { available, requested }) => {
422                assert!(available < 1);
423                assert_eq!(requested, 1);
424            }
425            other => panic!("expected InsufficientTokens, got {other:?}"),
426        }
427        std::fs::remove_file(&p).ok();
428    }
429
430    #[test]
431    fn refill_scales_with_elapsed_time() {
432        let p = tmp("refill");
433        // 1000 tokens/sec means 1 token per millisecond.
434        let r = SharedRateLimiter::create(&p, 100, 1000).unwrap();
435        // Drain.
436        r.try_acquire(100).unwrap();
437        assert!(r.available() < 5, "after full drain, available should be ~0");
438        // Wait 30ms; expect ~30 tokens to have refilled.
439        thread::sleep(Duration::from_millis(30));
440        let after = r.available();
441        assert!((25..=40).contains(&after),
442            "after 30ms at 1000/s, available={after} should be ~30");
443        std::fs::remove_file(&p).ok();
444    }
445
446    #[test]
447    fn refill_clamped_to_capacity() {
448        let p = tmp("clamp");
449        let r = SharedRateLimiter::create(&p, 50, 10_000).unwrap();
450        // Drain.
451        r.try_acquire(50).unwrap();
452        // Wait long enough that uncapped refill exceeds capacity.
453        thread::sleep(Duration::from_millis(100));  // refills 1000 uncapped
454        assert_eq!(r.available(), 50, "available should clamp to capacity");
455        std::fs::remove_file(&p).ok();
456    }
457
458    #[test]
459    fn acquire_or_wait_blocks_then_succeeds() {
460        let p = tmp("wait");
461        // 100 tokens/sec = 1 every 10ms.
462        let r = SharedRateLimiter::create(&p, 1, 100).unwrap();
463        r.try_acquire(1).unwrap();
464        let start = Instant::now();
465        // Need 1 more token; should wait ~10ms.
466        r.acquire_or_wait(1, Duration::from_millis(500)).unwrap();
467        let elapsed = start.elapsed();
468        assert!(elapsed >= Duration::from_millis(5),
469            "should have waited some time, got {elapsed:?}");
470        assert!(elapsed < Duration::from_millis(100),
471            "should have completed quickly, got {elapsed:?}");
472        std::fs::remove_file(&p).ok();
473    }
474
475    #[test]
476    fn acquire_or_wait_returns_timeout() {
477        let p = tmp("timeout");
478        let r = SharedRateLimiter::create(&p, 1, 1).unwrap();  // 1 per second
479        r.try_acquire(1).unwrap();
480        let start = Instant::now();
481        let result = r.acquire_or_wait(1, Duration::from_millis(50));
482        let elapsed = start.elapsed();
483        assert!(matches!(result, Err(RateLimiterError::Timeout)));
484        assert!(elapsed >= Duration::from_millis(40),
485            "should have waited ~50ms, got {elapsed:?}");
486        std::fs::remove_file(&p).ok();
487    }
488
489    #[test]
490    fn acquire_or_wait_oversized_request_fails_fast() {
491        let p = tmp("oversize");
492        let r = SharedRateLimiter::create(&p, 10, 100).unwrap();
493        // Requesting more than capacity can never be satisfied.
494        let result = r.acquire_or_wait(100, Duration::from_secs(10));
495        assert!(matches!(result, Err(RateLimiterError::InsufficientTokens { .. })));
496        std::fs::remove_file(&p).ok();
497    }
498
499    #[test]
500    fn concurrent_acquirers_sum_to_at_most_capacity_no_refill() {
501        let p = tmp("concurrent");
502        // Slow refill so the test window sees ~no refilled tokens.
503        let r = Arc::new(SharedRateLimiter::create(&p, 100, 1).unwrap());
504        let n_threads = 8;
505        let per_thread = 50;
506        let mut handles = vec![];
507        for _ in 0..n_threads {
508            let r = r.clone();
509            handles.push(thread::spawn(move || {
510                let mut acquired = 0u32;
511                for _ in 0..per_thread {
512                    if r.try_acquire(1).is_ok() { acquired += 1; }
513                }
514                acquired
515            }));
516        }
517        let total: u32 = handles.into_iter()
518            .map(|h| h.join().unwrap()).sum();
519        // Total acquired across all threads must be <= capacity +
520        // very small refill (rate=1/sec, test window << 1 sec).
521        assert!(total <= 101, "total acquired {total} should not exceed capacity {} + tiny refill", 100);
522        // We should have acquired exactly the capacity (or very close).
523        assert!(total >= 95, "total acquired {total} should be near capacity 100");
524        std::fs::remove_file(&p).ok();
525    }
526
527    #[test]
528    fn cross_handle_state_shared() {
529        let p = tmp("cross-handle");
530        let writer = SharedRateLimiter::create(&p, 100, 10).unwrap();
531        let reader = SharedRateLimiter::open(&p, 100, 10).unwrap();
532        writer.try_acquire(40).unwrap();
533        let avail = reader.available();
534        assert!((59..=60).contains(&avail));
535        std::fs::remove_file(&p).ok();
536    }
537
538    #[test]
539    fn config_mismatch_at_open_rejected() {
540        let p = tmp("mismatch");
541        let _w = SharedRateLimiter::create(&p, 100, 10).unwrap();
542        assert!(matches!(
543            SharedRateLimiter::open(&p, 50, 10),
544            Err(RateLimiterError::LayoutMismatch)
545        ));
546        assert!(matches!(
547            SharedRateLimiter::open(&p, 100, 20),
548            Err(RateLimiterError::LayoutMismatch)
549        ));
550        std::fs::remove_file(&p).ok();
551    }
552
553    #[test]
554    fn reset_refills_to_capacity() {
555        let p = tmp("reset");
556        let r = SharedRateLimiter::create(&p, 50, 1).unwrap();
557        r.try_acquire(50).unwrap();
558        assert!(r.available() < 2);
559        r.reset();
560        assert_eq!(r.available(), 50);
561        std::fs::remove_file(&p).ok();
562    }
563
564    #[test]
565    fn disk_persistence_survives_reopen() {
566        let p = tmp("disk");
567        {
568            let r = SharedRateLimiter::create(&p, 50, 1).unwrap();
569            r.try_acquire(20).unwrap();
570            r.flush().unwrap();
571        }
572        let r2 = SharedRateLimiter::open(&p, 50, 1).unwrap();
573        let avail = r2.available();
574        // Should be ~30 plus any time elapsed at rate 1/s (likely 0-1).
575        assert!((30..=31).contains(&avail),
576            "after reopen available={avail} should be ~30");
577        std::fs::remove_file(&p).ok();
578    }
579
580    #[test]
581    fn acquire_zero_is_noop() {
582        let p = tmp("zero");
583        let r = SharedRateLimiter::create(&p, 100, 1).unwrap();
584        let before = r.available();
585        r.try_acquire(0).unwrap();
586        let after = r.available();
587        // available may have incremented by 0-1 due to elapsed time,
588        // but shouldn't have decreased.
589        assert!(after >= before);
590        std::fs::remove_file(&p).ok();
591    }
592}