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