cratestack_axum/ratelimit/store.rs
1use std::sync::Arc;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use cratestack_core::{
7 BoundedOutcome, Charged, ConsumeRequest, CratestackError, bucket_ttl_secs, scope_ttl_secs,
8};
9
10use super::config::{RateLimitConfig, RateLimitDecision};
11
12mod buckets;
13mod capacity;
14mod scopes;
15
16// Re-export from cratestack-core for internal use
17pub use cratestack_core::RateLimitStore;
18
19use buckets::Buckets;
20use scopes::Scopes;
21
22/// Default ceiling on live buckets.
23///
24/// The in-memory store documents itself as single-replica/development
25/// scale, and 100k live token buckets is far past that — a deployment
26/// legitimately tracking that many distinct callers in one process wants
27/// the Redis store, whose keyspace is not this process's heap. The cap
28/// exists as a backstop *under* the cardinality budget, not instead of it:
29/// with the budget doing its job the map is O(peers × 128), and the cap is
30/// what keeps "peers" from being the unbounded term when a botnet supplies
31/// them.
32pub const DEFAULT_MAX_BUCKETS: usize = 100_000;
33
34#[derive(Debug, Default)]
35struct State {
36 buckets: Buckets,
37 scopes: Scopes,
38}
39
40/// In-memory `RateLimitStore`. Suitable for single-replica deployments and
41/// development; banks running multi-replica clusters need a Redis-backed
42/// implementation so the limit is enforced cluster-wide.
43///
44/// Bounded in two independent ways since cratestack#871: an amortised
45/// sweep drops buckets idle for a full [`cratestack_core::bucket_ttl_secs`]
46/// (the same horizon Redis's `EXPIRE` uses), and [`Self::with_max_buckets`]
47/// caps live buckets outright, failing closed when a sweep frees nothing.
48#[derive(Debug, Clone)]
49pub struct InMemoryRateLimitStore {
50 state: Arc<Mutex<State>>,
51 max_buckets: Option<usize>,
52}
53
54impl Default for InMemoryRateLimitStore {
55 fn default() -> Self {
56 Self {
57 state: Arc::new(Mutex::new(State::default())),
58 max_buckets: Some(DEFAULT_MAX_BUCKETS),
59 }
60 }
61}
62
63impl InMemoryRateLimitStore {
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 /// Hard ceiling on live buckets, defaulting to [`DEFAULT_MAX_BUCKETS`].
69 ///
70 /// At the ceiling, a request for a bucket that does not exist yet is
71 /// refused with `CratestackError::Internal` — a *logical* failure, so
72 /// it stays closed under every [`super::StoreErrorPolicy`]. Requests
73 /// for buckets that already exist keep being served normally.
74 pub fn with_max_buckets(mut self, max_buckets: usize) -> Self {
75 self.max_buckets = Some(max_buckets);
76 self
77 }
78
79 /// Remove the ceiling entirely, leaving only the TTL sweep. For
80 /// deployments that would rather risk the heap than refuse a caller.
81 pub fn without_max_buckets(mut self) -> Self {
82 self.max_buckets = None;
83 self
84 }
85
86 /// Test seam: consume against an injected clock.
87 ///
88 /// Eviction is a clock decision, and a test that sleeps through a real
89 /// 60s TTL is a test nobody runs. Precedent:
90 /// `cratestack_core::log_throttle::LogThrottle::check_at`.
91 #[doc(hidden)]
92 pub fn _consume_at(
93 &self,
94 request: ConsumeRequest<'_>,
95 now: Instant,
96 ) -> Result<BoundedOutcome, CratestackError> {
97 let ttl = Duration::from_secs(bucket_ttl_secs(request.config));
98 let mut state = self
99 .state
100 .lock()
101 .map_err(|_| CratestackError::Internal("rate limit store poisoned".to_owned()))?;
102
103 if state.buckets.maybe_sweep(now, ttl) {
104 // Each scope carries its own deadline, so the sweep needs no
105 // horizon argument — one fewer way to get the scope lifetime
106 // wrong (cratestack#871 review, blocker 2).
107 state.scopes.sweep(now);
108 }
109
110 let charged = match request.budget {
111 None => Charged::Requested,
112 Some(budget) => {
113 // Refuse BEFORE touching the scope index, not after
114 // (cratestack#871 round-2, item 2). Admitting first and
115 // letting `Buckets::consume` refuse afterwards left a
116 // scope entry — and an interned member key — behind for
117 // every refused request: measured `max_buckets=10` ->
118 // `buckets=10 scopes=5000`, each scope able to hold 128
119 // keys for up to a day. `max_buckets` bounded the bucket
120 // map and nothing else.
121 self.reserve_admission(&mut state, &request, budget, now, ttl)?;
122 // Never shorter than the bucket TTL, so a member's slot
123 // outlives the bucket it admitted.
124 let scope_ttl = Duration::from_secs(scope_ttl_secs(request.config, budget.window));
125 if state.scopes.admit(budget, request.key, now, scope_ttl) {
126 Charged::Requested
127 } else {
128 Charged::Fallback
129 }
130 }
131 };
132 let decision = state.buckets.consume(
133 request.charged_key(charged),
134 request.config,
135 now,
136 self.max_buckets,
137 ttl,
138 )?;
139 Ok(BoundedOutcome::new(decision, charged))
140 }
141
142 /// Test seam: how many buckets are live right now. The number the
143 /// cratestack#871 regression tests assert a bound on.
144 #[doc(hidden)]
145 pub fn _bucket_count(&self) -> usize {
146 self.state
147 .lock()
148 .map(|state| state.buckets.len())
149 .unwrap_or(0)
150 }
151
152 /// Test seam: how many scope records are live. Separate from
153 /// [`Self::_bucket_count`] because the round-2 review found the two
154 /// diverging by three orders of magnitude.
155 #[doc(hidden)]
156 pub fn _scope_count(&self) -> usize {
157 self.state
158 .lock()
159 .map(|state| state.scopes.len())
160 .unwrap_or(0)
161 }
162}
163
164#[async_trait]
165impl RateLimitStore for InMemoryRateLimitStore {
166 async fn consume(
167 &self,
168 key: &str,
169 config: RateLimitConfig,
170 ) -> Result<RateLimitDecision, CratestackError> {
171 self.consume_bounded(ConsumeRequest::new(key, config, None))
172 .await
173 .map(|outcome| outcome.decision)
174 }
175
176 async fn consume_bounded(
177 &self,
178 request: ConsumeRequest<'_>,
179 ) -> Result<BoundedOutcome, CratestackError> {
180 self._consume_at(request, Instant::now())
181 }
182}