rate_net/eviction.rs
1//! How the per-key store bounds its memory.
2
3use core::time::Duration;
4
5/// The default key-capacity cap, applied unless overridden.
6///
7/// Roughly a million keys. Generous for legitimate traffic, but a hard ceiling
8/// so a flood of unique keys can never grow the store without limit. Pick a
9/// value that matches your real key cardinality with [`Eviction::capacity`].
10pub const DEFAULT_MAX_KEYS: usize = 1 << 20;
11
12/// How the limiter bounds the memory its per-key state can occupy.
13///
14/// A rate limiter that tracks state per key is a denial-of-service vector
15/// against itself if that state can grow without limit: a flood of unique keys
16/// would exhaust memory. `Eviction` is the defense. Two independent bounds
17/// compose:
18///
19/// - **Capacity** — a hard ceiling on the number of live keys. When inserting a
20/// new key would exceed it, the least-recently-seen key is evicted. This is
21/// what caps a unique-key flood.
22/// - **Idle TTL** — keys not seen for longer than the TTL become evictable, so
23/// long-idle state is reclaimed rather than held forever.
24///
25/// Eviction is **lazy and incremental**: it happens while inserting a new key,
26/// touches only the one shard being written, and never sweeps the whole store or
27/// blocks the steady-state check path. The capacity is enforced approximately
28/// per shard, so the live-key count stays within a small factor of the cap.
29///
30/// The [`Default`] is safe: a [`DEFAULT_MAX_KEYS`] capacity cap and no TTL —
31/// memory is bounded out of the box. Opt into unbounded growth explicitly with
32/// [`unbounded`](Self::unbounded), understanding the risk.
33///
34/// # Examples
35///
36/// ```
37/// use rate_net::Eviction;
38/// use std::time::Duration;
39///
40/// // Cap at 100k keys and reclaim anything idle for five minutes.
41/// let policy = Eviction::capacity(100_000).with_idle(Duration::from_secs(300));
42/// assert_eq!(policy.max_keys(), Some(100_000));
43/// assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(300)));
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Eviction {
47 max_keys: Option<usize>,
48 idle_ttl: Option<Duration>,
49}
50
51impl Eviction {
52 /// A hard cap of `max_keys` live keys, with no idle expiry.
53 ///
54 /// A `max_keys` of `0` is treated as `1` (the store always holds at least
55 /// one key per shard).
56 ///
57 /// # Examples
58 ///
59 /// ```
60 /// use rate_net::Eviction;
61 ///
62 /// let policy = Eviction::capacity(50_000);
63 /// assert_eq!(policy.max_keys(), Some(50_000));
64 /// assert_eq!(policy.idle_ttl(), None);
65 /// ```
66 #[must_use]
67 pub const fn capacity(max_keys: usize) -> Self {
68 Self {
69 max_keys: Some(max_keys),
70 idle_ttl: None,
71 }
72 }
73
74 /// Reclaim keys idle for longer than `ttl`, keeping the default capacity cap.
75 ///
76 /// Idle expiry alone does not bound a unique-key flood — flooded keys are not
77 /// idle — so this keeps the [`DEFAULT_MAX_KEYS`] cap as the flood defense and
78 /// layers the TTL on top. Use [`capacity`](Self::capacity) plus
79 /// [`with_idle`](Self::with_idle) to choose both bounds explicitly.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use rate_net::{Eviction, DEFAULT_MAX_KEYS};
85 /// use std::time::Duration;
86 ///
87 /// let policy = Eviction::idle(Duration::from_secs(300));
88 /// assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(300)));
89 /// assert_eq!(policy.max_keys(), Some(DEFAULT_MAX_KEYS));
90 /// ```
91 #[must_use]
92 pub const fn idle(ttl: Duration) -> Self {
93 Self {
94 max_keys: Some(DEFAULT_MAX_KEYS),
95 idle_ttl: Some(ttl),
96 }
97 }
98
99 /// Both bounds at once: a `max_keys` cap and an idle `ttl`.
100 ///
101 /// # Examples
102 ///
103 /// ```
104 /// use rate_net::Eviction;
105 /// use std::time::Duration;
106 ///
107 /// let policy = Eviction::new(10_000, Duration::from_secs(60));
108 /// assert_eq!(policy.max_keys(), Some(10_000));
109 /// assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(60)));
110 /// ```
111 #[must_use]
112 pub const fn new(max_keys: usize, ttl: Duration) -> Self {
113 Self {
114 max_keys: Some(max_keys),
115 idle_ttl: Some(ttl),
116 }
117 }
118
119 /// No bounds at all — the store grows without limit.
120 ///
121 /// Only safe when the key space is intrinsically bounded (a fixed set of
122 /// tenants, say). Against untrusted keys this is a self-inflicted
123 /// denial-of-service: prefer [`capacity`](Self::capacity).
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use rate_net::Eviction;
129 ///
130 /// let policy = Eviction::unbounded();
131 /// assert_eq!(policy.max_keys(), None);
132 /// assert_eq!(policy.idle_ttl(), None);
133 /// ```
134 #[must_use]
135 pub const fn unbounded() -> Self {
136 Self {
137 max_keys: None,
138 idle_ttl: None,
139 }
140 }
141
142 /// Returns a copy with the capacity cap set to `max_keys`.
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use rate_net::Eviction;
148 /// use std::time::Duration;
149 ///
150 /// let policy = Eviction::idle(Duration::from_secs(30)).with_capacity(1_000);
151 /// assert_eq!(policy.max_keys(), Some(1_000));
152 /// ```
153 #[must_use]
154 pub const fn with_capacity(mut self, max_keys: usize) -> Self {
155 self.max_keys = Some(max_keys);
156 self
157 }
158
159 /// Returns a copy with idle expiry set to `ttl`.
160 ///
161 /// # Examples
162 ///
163 /// ```
164 /// use rate_net::Eviction;
165 /// use std::time::Duration;
166 ///
167 /// let policy = Eviction::capacity(1_000).with_idle(Duration::from_secs(30));
168 /// assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(30)));
169 /// ```
170 #[must_use]
171 pub const fn with_idle(mut self, ttl: Duration) -> Self {
172 self.idle_ttl = Some(ttl);
173 self
174 }
175
176 /// Returns a copy with the capacity cap removed (unbounded key count).
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use rate_net::Eviction;
182 ///
183 /// let policy = Eviction::default().without_capacity();
184 /// assert_eq!(policy.max_keys(), None);
185 /// ```
186 #[must_use]
187 pub const fn without_capacity(mut self) -> Self {
188 self.max_keys = None;
189 self
190 }
191
192 /// The configured capacity cap, if any.
193 #[must_use]
194 pub const fn max_keys(&self) -> Option<usize> {
195 self.max_keys
196 }
197
198 /// The configured idle TTL, if any.
199 #[must_use]
200 pub const fn idle_ttl(&self) -> Option<Duration> {
201 self.idle_ttl
202 }
203}
204
205impl Default for Eviction {
206 /// A [`DEFAULT_MAX_KEYS`] capacity cap and no idle TTL — bounded memory out
207 /// of the box.
208 fn default() -> Self {
209 Self::capacity(DEFAULT_MAX_KEYS)
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::{DEFAULT_MAX_KEYS, Eviction};
216 use core::time::Duration;
217
218 #[test]
219 fn test_default_is_bounded() {
220 let policy = Eviction::default();
221 assert_eq!(policy.max_keys(), Some(DEFAULT_MAX_KEYS));
222 assert_eq!(policy.idle_ttl(), None);
223 }
224
225 #[test]
226 fn test_idle_keeps_default_cap() {
227 let policy = Eviction::idle(Duration::from_secs(5));
228 assert_eq!(policy.max_keys(), Some(DEFAULT_MAX_KEYS));
229 assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(5)));
230 }
231
232 #[test]
233 fn test_unbounded_has_no_bounds() {
234 let policy = Eviction::unbounded();
235 assert_eq!(policy.max_keys(), None);
236 assert_eq!(policy.idle_ttl(), None);
237 }
238
239 #[test]
240 fn test_builders_compose() {
241 let policy = Eviction::capacity(10).with_idle(Duration::from_secs(1));
242 assert_eq!(policy.max_keys(), Some(10));
243 assert_eq!(policy.idle_ttl(), Some(Duration::from_secs(1)));
244
245 let dropped = policy.without_capacity();
246 assert_eq!(dropped.max_keys(), None);
247 assert_eq!(dropped.idle_ttl(), Some(Duration::from_secs(1)));
248 }
249}