Skip to main content

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}