This repository has no description
11 kB
388 lines
1use std::collections::HashMap;
2use std::collections::hash_map::Entry as Slot;
3use std::hash::Hash;
4use std::sync::Mutex;
5
6use knot_runtime::UnixMicros;
7
8knot_types::scalar_newtype! {
9 pub struct GroupQuota(usize);
10 pub struct TotalQuota(usize);
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct Quotas {
15 pub per_group: GroupQuota,
16 pub total: TotalQuota,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Rejected {
21 Total,
22 Group,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Admitted<V> {
27 Inserted,
28 Occupied(V),
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum Occupancy {
33 Keep,
34 Extend,
35}
36
37struct Entry<G, V> {
38 group: G,
39 value: V,
40 expires_at: UnixMicros,
41}
42
43struct Inner<K, G, V> {
44 entries: HashMap<K, Entry<G, V>>,
45 group_counts: HashMap<G, usize>,
46}
47
48pub struct Expiring<K, G, V> {
49 inner: Mutex<Inner<K, G, V>>,
50 quotas: Quotas,
51}
52
53impl<K, G, V> Expiring<K, G, V>
54where
55 K: Eq + Hash + Clone,
56 G: Eq + Hash + Clone,
57{
58 pub fn new(quotas: Quotas) -> Self {
59 Self {
60 inner: Mutex::new(Inner {
61 entries: HashMap::new(),
62 group_counts: HashMap::new(),
63 }),
64 quotas,
65 }
66 }
67
68 fn lock(&self) -> std::sync::MutexGuard<'_, Inner<K, G, V>> {
69 self.inner
70 .lock()
71 .unwrap_or_else(|poisoned| poisoned.into_inner())
72 }
73
74 pub fn prune(&self, now: UnixMicros) -> Vec<K> {
75 prune_locked(&mut self.lock(), now)
76 }
77
78 pub fn admit(
79 &self,
80 key: K,
81 group: G,
82 value: V,
83 expires_at: UnixMicros,
84 now: UnixMicros,
85 ) -> Result<Admitted<V>, Rejected>
86 where
87 V: Clone + PartialEq,
88 {
89 self.enter(key, group, value, expires_at, now, Occupancy::Keep)
90 }
91
92 pub fn admit_or_renew(
93 &self,
94 key: K,
95 group: G,
96 value: V,
97 expires_at: UnixMicros,
98 now: UnixMicros,
99 ) -> Result<Admitted<V>, Rejected>
100 where
101 V: Clone + PartialEq,
102 {
103 self.enter(key, group, value, expires_at, now, Occupancy::Extend)
104 }
105
106 fn enter(
107 &self,
108 key: K,
109 group: G,
110 value: V,
111 expires_at: UnixMicros,
112 now: UnixMicros,
113 occupied: Occupancy,
114 ) -> Result<Admitted<V>, Rejected>
115 where
116 V: Clone + PartialEq,
117 {
118 let mut inner = self.lock();
119 let key = match inner.entries.entry(key) {
120 Slot::Occupied(mut held) if held.get().expires_at > now => {
121 let entry = held.get_mut();
122 if occupied == Occupancy::Extend && entry.value == value {
123 entry.expires_at = expires_at;
124 }
125 return Ok(Admitted::Occupied(entry.value.clone()));
126 }
127 Slot::Occupied(held) => {
128 let (key, entry) = held.remove_entry();
129 release_group(&mut inner.group_counts, &entry.group);
130 key
131 }
132 Slot::Vacant(free) => free.into_key(),
133 };
134 // This is lazy on purpose!
135 // Pruning traverses every entry so we shouldn't do it on every
136 // admission, better to do this O(1) quota check.
137 if self.rejection(&inner, &group).is_some() {
138 prune_locked(&mut inner, now);
139 }
140 match self.rejection(&inner, &group) {
141 Some(rejected) => Err(rejected),
142 None => {
143 *inner.group_counts.entry(group.clone()).or_insert(0) += 1;
144 inner.entries.insert(
145 key,
146 Entry {
147 group,
148 value,
149 expires_at,
150 },
151 );
152 Ok(Admitted::Inserted)
153 }
154 }
155 }
156
157 fn rejection(&self, inner: &Inner<K, G, V>, group: &G) -> Option<Rejected> {
158 let held = inner.group_counts.get(group).copied().unwrap_or(0);
159 match (
160 held >= self.quotas.per_group.get(),
161 inner.entries.len() >= self.quotas.total.get(),
162 ) {
163 (true, _) => Some(Rejected::Group),
164 (_, true) => Some(Rejected::Total),
165 (false, false) => None,
166 }
167 }
168
169 pub fn remove(&self, key: &K) -> Option<V> {
170 remove_locked(&mut self.lock(), key)
171 }
172
173 pub fn get(&self, key: &K, now: UnixMicros) -> Option<V>
174 where
175 V: Clone,
176 {
177 let inner = self.lock();
178 inner
179 .entries
180 .get(key)
181 .filter(|entry| entry.expires_at > now)
182 .map(|entry| entry.value.clone())
183 }
184
185 pub fn len(&self) -> usize {
186 self.lock().entries.len()
187 }
188
189 pub fn is_empty(&self) -> bool {
190 self.len() == 0
191 }
192
193 pub fn group_len(&self, group: &G) -> usize {
194 self.lock().group_counts.get(group).copied().unwrap_or(0)
195 }
196}
197
198fn prune_locked<K, G, V>(inner: &mut Inner<K, G, V>, now: UnixMicros) -> Vec<K>
199where
200 K: Eq + Hash + Clone,
201 G: Eq + Hash,
202{
203 let expired: Vec<K> = inner
204 .entries
205 .iter()
206 .filter(|(_, entry)| entry.expires_at <= now)
207 .map(|(key, _)| key.clone())
208 .collect();
209 expired.iter().for_each(|key| {
210 remove_locked(inner, key);
211 });
212 expired
213}
214
215fn remove_locked<K, G, V>(inner: &mut Inner<K, G, V>, key: &K) -> Option<V>
216where
217 K: Eq + Hash,
218 G: Eq + Hash,
219{
220 let entry = inner.entries.remove(key)?;
221 release_group(&mut inner.group_counts, &entry.group);
222 Some(entry.value)
223}
224
225fn release_group<G: Eq + Hash>(counts: &mut HashMap<G, usize>, group: &G) {
226 if let Some(count) = counts.get_mut(group) {
227 *count = count.saturating_sub(1);
228 if *count == 0 {
229 counts.remove(group);
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn at(micros: u64) -> UnixMicros {
239 UnixMicros::new(micros)
240 }
241
242 fn store(per_group: usize, total: usize) -> Expiring<&'static str, &'static str, u8> {
243 Expiring::new(Quotas {
244 per_group: GroupQuota::new(per_group),
245 total: TotalQuota::new(total),
246 })
247 }
248
249 #[test]
250 fn a_slot_stays_occupied_until_it_expires_and_is_then_free_to_take_over() {
251 let store = store(8, 8);
252 assert_eq!(
253 store.admit("uni", "nel", 1, at(100), at(0)),
254 Ok(Admitted::Inserted)
255 );
256 assert_eq!(
257 store.admit("uni", "olaren", 2, at(900), at(50)),
258 Ok(Admitted::Occupied(1)),
259 "the caller decides whether an occupied slot is a replay or a renewal"
260 );
261 assert_eq!(store.get(&"uni", at(50)), Some(1));
262 assert_eq!(
263 store.get(&"uni", at(100)),
264 None,
265 "a replay guard must keep the original expiry or a replayed token renews its own window"
266 );
267 assert_eq!(
268 store.admit("uni", "olaren", 2, at(300), at(100)),
269 Ok(Admitted::Inserted),
270 "expiry is inclusive so a slot expiring exactly now is available"
271 );
272 assert_eq!(
273 store.group_len(&"nel"),
274 0,
275 "the takeover releases the old group's count"
276 );
277 assert_eq!(store.group_len(&"olaren"), 1);
278 }
279
280 #[test]
281 fn admit_or_renew_extends_only_the_current_holder() {
282 let store = store(8, 8);
283 store
284 .admit_or_renew("uni", "nel", 1, at(100), at(0))
285 .unwrap();
286 assert_eq!(
287 store.admit_or_renew("uni", "olaren", 2, at(900), at(50)),
288 Ok(Admitted::Occupied(1))
289 );
290 assert_eq!(
291 store.get(&"uni", at(150)),
292 None,
293 "a caller that isn't the holder mustn't extend the lease"
294 );
295 store
296 .admit_or_renew("uni", "nel", 1, at(300), at(200))
297 .unwrap();
298 assert_eq!(
299 store.admit_or_renew("uni", "nel", 1, at(600), at(250)),
300 Ok(Admitted::Occupied(1))
301 );
302 assert_eq!(
303 store.get(&"uni", at(500)),
304 Some(1),
305 "renewing under the lock that read the entry leaves no window for a release \
306 to drop the slot between the read and the renewal"
307 );
308 }
309
310 #[test]
311 fn each_quota_bounds_its_own_scope_and_names_itself_when_it_refuses() {
312 let store = store(1, 1);
313 store.admit("uni", "nel", 1, at(100), at(0)).unwrap();
314 assert_eq!(
315 store.admit("kelp", "nel", 2, at(100), at(0)),
316 Err(Rejected::Group),
317 "a rejection names the group limit first because the caller can act on its own quota"
318 );
319 assert_eq!(
320 store.admit("kelp", "olaren", 2, at(100), at(0)),
321 Err(Rejected::Total),
322 "a distinct group has its own budget but still shares the total"
323 );
324 assert_eq!(
325 store.admit("kelp", "olaren", 2, at(400), at(200)),
326 Ok(Admitted::Inserted),
327 "admit prunes the expired entry and takes the slot it freed"
328 );
329 }
330
331 #[test]
332 fn releasing_a_slot_by_expiry_or_by_hand_frees_its_group_budget() {
333 let store = store(2, 8);
334 store.admit("uni", "nel", 7, at(100), at(0)).unwrap();
335 store.admit("kelp", "nel", 8, at(500), at(0)).unwrap();
336 assert_eq!(store.prune(at(200)), vec!["uni"]);
337 assert_eq!(store.len(), 1);
338 assert_eq!(
339 store.group_len(&"nel"),
340 1,
341 "pruning decrements the group count rather than rebuilding it"
342 );
343 assert_eq!(store.remove(&"kelp"), Some(8));
344 assert_eq!(store.remove(&"kelp"), None);
345 assert_eq!(
346 store.group_len(&"nel"),
347 0,
348 "removing the last entry of a group frees its budget"
349 );
350 assert_eq!(
351 store.admit("whelk", "nel", 9, at(600), at(200)),
352 Ok(Admitted::Inserted)
353 );
354 }
355
356 #[test]
357 fn concurrent_admissions_at_the_quota_keep_group_counts_equal_to_what_is_stored() {
358 let store = std::sync::Arc::new(Expiring::new(Quotas {
359 per_group: GroupQuota::new(2),
360 total: TotalQuota::new(3),
361 }));
362 let keys = ["uni", "kelp", "whelk", "clam", "conch", "limpet"];
363 let groups = ["nel", "olaren"];
364 let threads: Vec<_> = (0..8u64)
365 .map(|thread| {
366 let store = std::sync::Arc::clone(&store);
367 std::thread::spawn(move || {
368 (0..2_000u64).for_each(|round| {
369 let key = keys[(thread + round) as usize % keys.len()];
370 let group = groups[(thread + round) as usize % groups.len()];
371 let now = at(round * 10);
372 let _ = store.admit(key, group, 1u8, at(round * 10 + 40), now);
373 store.prune(now);
374 });
375 })
376 })
377 .collect();
378 threads
379 .into_iter()
380 .for_each(|thread| thread.join().unwrap());
381 let counted: usize = groups.iter().map(|group| store.group_len(group)).sum();
382 assert_eq!(
383 counted,
384 store.len(),
385 "a group count higher than what is stored locks its group out of every later admission"
386 );
387 }
388}