Skip to main content

krabka_throttle/
runtime.rs

1//! The concurrent [`TokenBucket`] runtime around the pure
2//! [`plan_consume`](krabka_verified::throttle::plan_consume) arithmetic.
3//!
4//! This module holds the bucket's state, the lock that serializes every
5//! update of it, and the injected [`MonotonicClock`].
6//!
7//! # Concurrency design
8//!
9//! The four values a consume reads and writes, `{rate, burst, available,
10//! last_refill}`, live together in one [`BucketState`] behind one [`Mutex`].
11//! [`TokenBucket::try_consume`] and [`TokenBucket::set_token_rate_with_burst`]
12//! each hold that lock for their whole read-compute-write, so every consume and
13//! every reset is one indivisible step on the group:
14//!
15//! * A reset can never land between a consume's reads and its writes, so a
16//!   consume can never commit a balance computed under the old burst on top of
17//!   the new one.
18//! * A consume advances `last_refill` and adds the matching refill to
19//!   `available` in the same critical section, so time a consume claims is
20//!   always turned into tokens or capped away by the burst. It is never dropped.
21//!
22//! An earlier lock-free design sampled a seqlock generation, claimed the refill
23//! with a compare-and-exchange on `last_refill`, re-checked the generation,
24//! then committed `available` with a second compare-and-exchange. It had both
25//! failures above: a whole reset could run between the generation re-check and
26//! the commit and store a value equal to the one the commit expected, and a
27//! failed commit threw away the refill its earlier claim had taken. Two words
28//! cannot be committed by one `u64` compare-and-exchange, so the group takes a
29//! lock. Kafka makes the same choice: `Sensor.record` updates its quota stats
30//! inside `synchronized` blocks. The critical section is a clock read and a
31//! few integer operations.
32//!
33//! # Sub-token accounting
34//!
35//! The group counts micro-tokens, [`MICROS_PER_TOKEN`] to a token, so a rate
36//! that is not a whole number of tokens per second is enforced as configured.
37//! Kafka holds every client quota as a double (`ClientQuotaManager` compares
38//! the measured `Rate` with a `Quota` bound), so a `consumer_byte_rate` of
39//! `0.5` must admit half a byte per second, not one and not an unlimited
40//! stream. A whole-token caller is granted whole tokens and leaves any part
41//! token in the bucket; [`TokenBucket::record`] charges the whole request and
42//! keeps the part token in the debt it reports, for a caller that turns the
43//! debt into a throttle delay.
44//!
45//! # Debt
46//!
47//! [`TokenBucket::record`] is Kafka's `Sensor.record` on a quota `Rate`: the
48//! value is recorded whether or not the balance covers it, and what the balance
49//! could not cover is debt. The refill repays the debt before it adds to the
50//! balance, so the throttle a caller derives from it, `debt / rate`, carries
51//! everything the client has sent above its bound. A grant-limited consume
52//! that clamped at zero would credit the overage back through the refill of the
53//! mute, and a client would send about twice its quota. `try_consume` grants
54//! nothing while the bucket is in debt. A change of rate keeps the balance and
55//! the debt (Kafka's `updateQuotaMetricConfigs` swaps the bound and leaves the
56//! recorded samples); only a bucket that had no rate starts full.
57//!
58//! One value is read outside the lock. `micro_rate_per_sec` mirrors the
59//! locked rate, and it is written only while the lock is held, so the unthrottled
60//! fast path in `try_consume` and [`TokenBucket::token_rate`] skip the lock.
61//! A fast-path read that sees `0` takes effect at that read, which orders the
62//! consume before any reset that is still in flight. A non-zero read proves
63//! nothing: the consume takes the lock and re-reads the rate there.
64//!
65//! The stateright model in `tests/bucket_model.rs` checks this design, and
66//! shows that the lock-free design it replaces breaks both properties.
67//!
68//! The bucket's own operations live in submodules that each add an
69//! `impl TokenBucket` block: [`self::rate`] holds the rate and burst
70//! accessors together with the reset that publishes them, and
71//! [`self::consume`] holds `try_consume`. [`self::state`] holds the
72//! broker-wide [`ThrottleState`] bundle.
73
74use {
75    qubit_clock::{MonotonicClock, StdMonotonicClock},
76    std::sync::{
77        Arc, Mutex, MutexGuard, PoisonError,
78        atomic::{AtomicU64, Ordering::Relaxed},
79    },
80};
81
82mod consume;
83mod rate;
84mod state;
85
86pub use self::{consume::whole_token_request, state::ThrottleState};
87
88/// Micro-tokens to one token: the resolution the bucket stores its rate,
89/// burst and balance in.
90///
91/// A millionth of a token per second is the smallest positive rate the bucket
92/// holds. A micro-token count fits a `u64` up to about 1.8e13 tokens, which
93/// is a larger burst than any quota window of a 64-bit byte rate reaches in
94/// practice, and every larger value saturates.
95pub const MICROS_PER_TOKEN: u64 = 1_000_000;
96
97/// Reads the injected clock's nanoseconds elapsed since its origin as a `u64`.
98///
99/// The refill arithmetic uses **differences** of this value only, so the
100/// absolute anchor does not matter. The span starts at zero when the clock is
101/// created and needs about 584 years to overflow `u64`, so it is a strictly
102/// safer anchor than the wall-clock epoch it replaces, which already stood at
103/// about 1.75e18 ns.
104#[inline]
105fn clock_nanos(clock: &dyn MonotonicClock) -> u64 {
106    // Saturating is a formality: the span reaches `u64::MAX` after 584 years.
107    u64::try_from(clock.now().elapsed_since_origin().as_nanos()).unwrap_or(u64::MAX)
108}
109
110/// The group every consume and every reset reads and writes as one unit.
111///
112/// It lives behind [`TokenBucket::state`]. `micro_available <= micro_burst`
113/// holds whenever the lock is free, and `last_refill_nanos` never moves
114/// backwards. Every count is in micro-tokens; see [`MICROS_PER_TOKEN`].
115///
116/// The balance the bucket stands at is `micro_available - micro_debt`, and
117/// at most one of the two is non-zero. A [`TokenBucket::record`] that charges
118/// more than the bucket holds leaves the rest as debt, and the refill repays
119/// the debt before it adds to the balance.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121struct BucketState {
122    /// Micro-tokens per second. `0` means no limit.
123    micro_rate_per_sec: u64,
124    /// The most micro-tokens the bucket holds.
125    micro_burst: u64,
126    /// Micro-tokens the bucket can grant now.
127    micro_available: u64,
128    /// Micro-tokens a [`TokenBucket::record`] charged beyond what the bucket
129    /// held, and the refill has not repaid yet.
130    micro_debt: u64,
131    /// The clock reading up to which elapsed time has become tokens.
132    last_refill_nanos: u64,
133    /// Fractional micro-token numerator, always below one billion.
134    micro_refill_fraction: u64,
135}
136
137#[derive(derive_more::Debug)]
138pub struct TokenBucket {
139    /// The group, and the lock that makes each consume and each reset one
140    /// indivisible step on it. See the module documentation.
141    #[debug("{:?}", *self.lock_state())]
142    state: Mutex<BucketState>,
143    /// A copy of `state.micro_rate_per_sec` for the unthrottled fast path. It
144    /// is written only while `state` is locked, right after the locked copy.
145    #[debug(skip)]
146    micro_rate_per_sec: AtomicU64,
147    /// Monotonic time source. The caller injects it, so tests can drive
148    /// refills deterministically with a [`qubit_clock::ManualMonotonicClock`]
149    /// instead of sleeping.
150    #[debug(skip)]
151    clock: Arc<dyn MonotonicClock>,
152}
153
154impl TokenBucket {
155    #[must_use]
156    pub fn new() -> Self { Self::with_clock(Arc::new(StdMonotonicClock::new())) }
157
158    /// Constructs a bucket backed by a caller-supplied [`MonotonicClock`].
159    ///
160    /// Production code uses [`TokenBucket::new`], which supplies a
161    /// [`StdMonotonicClock`]. Tests pass a
162    /// [`qubit_clock::ManualMonotonicClock`], so refill windows advance by an
163    /// exact, controlled amount instead of by wall-clock sleeping.
164    #[must_use]
165    pub fn with_clock(clock: Arc<dyn MonotonicClock>) -> Self {
166        let state = BucketState {
167            micro_rate_per_sec: 0,
168            micro_burst: 0,
169            micro_available: 0,
170            micro_debt: 0,
171            last_refill_nanos: clock_nanos(&*clock),
172            micro_refill_fraction: 0,
173        };
174        Self {
175            state: Mutex::new(state),
176            micro_rate_per_sec: AtomicU64::new(0),
177            clock,
178        }
179    }
180
181    #[inline]
182    fn now_nanos(&self) -> u64 { clock_nanos(&*self.clock) }
183
184    /// Locks the group.
185    ///
186    /// A poisoned lock is still used. Every critical section computes its new
187    /// values before it stores any of them, and nothing between those stores
188    /// can panic, so a panicking holder leaves the group as it found it.
189    fn lock_state(&self) -> MutexGuard<'_, BucketState> {
190        self.state.lock().unwrap_or_else(PoisonError::into_inner)
191    }
192
193    /// The micro-token rate the unthrottled fast path sees, read without the
194    /// lock.
195    #[inline]
196    fn fast_path_rate(&self) -> u64 { self.micro_rate_per_sec.load(Relaxed) }
197}
198
199impl Default for TokenBucket {
200    fn default() -> Self { Self::new() }
201}
202
203#[cfg(test)]
204mod tests {
205    use {
206        super::*,
207        assert2::check,
208        krabka_units::prelude::{bytes, bytes_per_sec},
209    };
210
211    #[test]
212    fn debug_renders_bucket_fields() {
213        let b = TokenBucket::new();
214        b.set_byte_rate_with_burst(bytes_per_sec(100), bytes(200));
215        let s = format!("{b:?}");
216        check!(s.contains("TokenBucket"));
217        check!(s.contains("micro_rate_per_sec: 100000000"));
218        check!(s.contains("micro_burst: 200000000"));
219        check!(s.contains("micro_available: 200000000"));
220        check!(s.contains("last_refill_nanos"));
221    }
222}