polars_io/cloud/token_bucket.rs
1// TokenBucket: refill-on-read token bucket with just-in-time (JIT) pricing.
2//
3// Design notes:
4// - NO timer anywhere: pacing precision comes from arithmetic.
5// - Fast path: one load + one CAS.
6// - The failure path is READ-ONLY: fractional accrual is preserved implicitly
7// because `last_us` is untouched. Zero contention when starved.
8// - JIT pricing: the refill uses the rate AT READ TIME. A rate change applies
9// to the entire un-settled elapsed interval at the new rate — one interval
10// of mispricing per change, bounded by burst_cap, control-loop tolerance.
11// - Burst cap B = min(rate * BURST_WINDOW, B_ABS).
12
13use std::sync::atomic::AtomicU64;
14use std::sync::atomic::Ordering::Relaxed;
15use std::time::Instant;
16
17use crate::cloud::http_rate_limit::RateCell;
18
19/// Sizing window for accumulating burst tokens, proportional to the rate.
20const BURST_WINDOW_SECS: f64 = 10.0 / 1000.0;
21/// Absolute burst ceiling. The proportional term (rate * BURST_WINDOW)
22/// governs everywhere plausible; this only backstops implausible rates
23/// (config error, runaway probe). At a 10ms window it binds above 51,200/s.
24/// Lower bound to respect: the wake tick can grant at most `cap` per tick,
25/// so cap must exceed rate * WAKE_TICK (50 at 50k/s and 1ms tick) or the
26/// slow path silently throttles below the learned rate.
27const BURST_ABS_CAP: f64 = 512.0;
28/// Q16.16 fixed point.
29const FP_ONE: u64 = 1 << 16;
30
31// State packing (one AtomicU64 => refill+take is a single CAS):
32// high 32 bits: tokens, Q16.16 fixed point (max 65535 tokens — plenty; cap
33// is <= B_ABS anyway)
34// low 32 bits: last_refill timestamp, microseconds since epoch, WRAPPING.
35// Wrap analysis: u32 µs wraps every ~71.6 min. elapsed uses wrapping_sub,
36// so an idle gap that aliases (real elapsed ≡ small value mod 2^32 µs)
37// under-refills once — conservative direction, self-healing on the next
38// touch, and only reachable after 71+ minutes of NO traffic on the bucket.
39// Any alias >= burst/rate (~10ms) still fully fills the cap. Accepted.
40#[derive(Debug)]
41pub struct TokenBucket {
42 epoch: Instant,
43 // Packed {tokens_q16: u32, last_us: u32}. See state packing analysis.
44 state: AtomicU64,
45 // Actual rate in requests/s ('rps'), represented as f64 bits.
46 // Shared cell: Written exclusively by the AIMD learner via set_rate().
47 // Read by the concurrency controller via RateSignal and optionally persisted
48 // by the InitPolicy.
49 rate_bits: RateCell,
50}
51
52#[inline]
53fn pack(tokens_q16: u32, last_us: u32) -> u64 {
54 ((tokens_q16 as u64) << 32) | last_us as u64
55}
56#[inline]
57fn unpack(v: u64) -> (u32, u32) {
58 ((v >> 32) as u32, v as u32)
59}
60
61pub enum TryAcquireError {
62 NoTokens,
63}
64
65impl TokenBucket {
66 pub fn new(rate_cell: RateCell) -> Self {
67 Self {
68 epoch: Instant::now(),
69 // Start with 1 token, not a full burst: a fresh bucket must not
70 // grant an instant burst-cohort before any pacing has occurred.
71 state: AtomicU64::new(pack(FP_ONE as u32, 0)),
72 rate_bits: rate_cell,
73 }
74 }
75
76 #[inline]
77 fn now_us(&self) -> u32 {
78 // Wrapping by construction (as u32 truncates).
79 self.epoch.elapsed().as_micros() as u32
80 }
81
82 #[inline]
83 fn burst_cap_q16(rate: f64) -> f64 {
84 let burst = (rate * BURST_WINDOW_SECS).clamp(1.0, BURST_ABS_CAP);
85 burst * (FP_ONE as f64)
86 }
87
88 pub fn rate(&self) -> f64 {
89 f64::from_bits(self.rate_bits.load(Relaxed))
90 }
91
92 /// Fast path. Ok(()) = token taken, proceed immediately. Failure is read-only.
93 pub fn try_acquire(&self) -> Result<(), TryAcquireError> {
94 let rate = self.rate();
95 let burst_cap_q16 = TokenBucket::burst_cap_q16(rate);
96
97 loop {
98 let cur = self.state.load(Relaxed);
99 let (token_q16, last_us) = unpack(cur);
100 let now = self.now_us();
101 let elapsed_us = now.wrapping_sub(last_us) as f64;
102
103 let refill_q16 = rate * elapsed_us * (FP_ONE as f64) / 1e6;
104 let filled = ((token_q16 as u64) as f64 + refill_q16).min(burst_cap_q16) as u64;
105
106 if filled >= FP_ONE {
107 let after = (filled - FP_ONE) as u32;
108 if self
109 .state
110 .compare_exchange_weak(cur, pack(after, now), Relaxed, Relaxed)
111 .is_ok()
112 {
113 return Ok(());
114 }
115 // Lost the race: someone else took/refilled. Retry with fresh state.
116 continue;
117 }
118
119 return Err(TryAcquireError::NoTokens);
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::cloud::http_rate_limit::HTTP_RATE_LIMIT_WAKE_TICK;
128
129 /// Highest rate the design targets. The abs cap must let a single wake tick
130 /// grant a full tick's worth of accrual at this rate, or the parked path
131 /// silently throttles below the learned rate.
132 const MAX_DESIGN_RATE_RPS: f64 = 50_000.0;
133
134 /// The wake tick can grant at most `burst_cap` tokens per tick (it drains
135 /// what the bucket holds). If the cap is below one tick's accrual at the
136 /// design rate, the parked path silently throttles below the learned rate
137 /// — throughput loss with no error, no log, no signal.
138 #[test]
139 fn burst_cap_clears_wake_tick_floor() {
140 let per_tick_accrual = MAX_DESIGN_RATE_RPS * HTTP_RATE_LIMIT_WAKE_TICK.as_secs_f64();
141 assert!(
142 BURST_ABS_CAP >= per_tick_accrual,
143 "BURST_ABS_CAP ({BURST_ABS_CAP}) < rate * WAKE_TICK ({per_tick_accrual}) \
144 at {MAX_DESIGN_RATE_RPS} rps: the wake tick cannot drain a tick's \
145 accrual, throttling the slow path below the learned rate"
146 );
147 }
148}