1use papaya::{Compute, HashMap, Operation};
23use std::borrow::Borrow;
24use std::hash::Hash;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::time::Instant;
27
28pub struct Entry<V> {
31 pub value: V,
33 pub inserted_at: Instant,
35 pub size_bytes: usize,
37}
38
39impl<V> Entry<V> {
40 pub fn new(value: V, size_bytes: usize) -> Self {
42 Self {
43 value,
44 inserted_at: Instant::now(),
45 size_bytes,
46 }
47 }
48
49 pub fn weigh(value_size: usize, key_len: usize) -> usize {
52 value_size + key_len + std::mem::size_of::<Self>()
53 }
54}
55
56pub enum Claim<R> {
58 Claimed,
60 Retained(R),
63}
64
65pub struct EvictionStats {
67 pub evicted: usize,
69 pub freed: usize,
71}
72
73pub struct SizeBoundedMap<K, V> {
80 map: HashMap<K, Entry<V>>,
82 current_size: AtomicUsize,
84 max_size: usize,
86}
87
88impl<K, V> SizeBoundedMap<K, V>
89where
90 K: Hash + Eq + Clone,
91{
92 pub fn new(max_size_bytes: usize) -> Self {
97 Self {
98 map: HashMap::new(),
99 current_size: AtomicUsize::new(0),
100 max_size: max_size_bytes,
101 }
102 }
103
104 pub fn is_disabled(&self) -> bool {
106 self.max_size == 0
107 }
108
109 pub fn max_size(&self) -> usize {
111 self.max_size
112 }
113
114 pub fn current_size(&self) -> usize {
116 self.current_size.load(Ordering::Relaxed)
117 }
118
119 #[allow(deprecated)]
130 fn sub_current_size(&self, bytes: usize) {
131 let _ = self
132 .current_size
133 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
134 Some(current.saturating_sub(bytes))
135 });
136 }
137
138 pub fn len(&self) -> usize {
140 self.map.pin().len()
141 }
142
143 pub fn is_empty(&self) -> bool {
145 self.map.pin().is_empty()
146 }
147
148 pub fn with_entry<Q, R>(&self, key: &Q, f: impl FnOnce(&Entry<V>) -> R) -> Option<R>
154 where
155 K: Borrow<Q>,
156 Q: Hash + Eq + ?Sized,
157 {
158 if self.is_disabled() {
159 return None;
160 }
161 self.map.pin().get(key).map(f)
162 }
163
164 pub fn insert_weighted(&self, key: K, value: V, size_bytes: usize) -> (Option<V>, usize)
168 where
169 V: Clone,
170 {
171 self.insert_weighted_at(key, value, size_bytes, Instant::now())
172 }
173
174 pub fn insert_weighted_at(
185 &self,
186 key: K,
187 value: V,
188 size_bytes: usize,
189 inserted_at: Instant,
190 ) -> (Option<V>, usize)
191 where
192 V: Clone,
193 {
194 if self.is_disabled() {
195 return (None, 0);
196 }
197 let entry = Entry {
198 value,
199 inserted_at,
200 size_bytes,
201 };
202 let guard = self.map.pin();
203 let (replaced_value, replaced_size) = guard
204 .insert(key, entry)
205 .map_or((None, 0), |old| (Some(old.value.clone()), old.size_bytes));
206 self.sub_current_size(replaced_size);
207 let new_total = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
208 (replaced_value, new_total)
209 }
210
211 pub fn claim_or<R>(
229 &self,
230 key: K,
231 value: V,
232 size_bytes: usize,
233 decide: impl Fn(&Entry<V>) -> Option<R>,
234 ) -> Claim<R>
235 where
236 V: Clone,
237 {
238 if self.is_disabled() {
239 return Claim::Claimed;
240 }
241
242 let guard = self.map.pin();
243 let outcome = guard.compute(key, |existing| {
244 let keep = existing.and_then(|(_, entry)| decide(entry));
245 match keep {
246 Some(reason) => Operation::Abort(reason),
247 None => Operation::Insert(Entry::new(value.clone(), size_bytes)),
248 }
249 });
250
251 match outcome {
252 Compute::Aborted(reason) => Claim::Retained(reason),
253 Compute::Inserted(_, _) => {
254 self.current_size.fetch_add(size_bytes, Ordering::Relaxed);
255 Claim::Claimed
256 }
257 Compute::Updated {
258 old: (_, replaced), ..
259 } => {
260 self.sub_current_size(replaced.size_bytes);
261 self.current_size.fetch_add(size_bytes, Ordering::Relaxed);
262 Claim::Claimed
263 }
264 Compute::Removed(_, removed) => {
269 self.sub_current_size(removed.size_bytes);
270 Claim::Claimed
271 }
272 }
273 }
274
275 pub fn remove_if(&self, key: K, predicate: impl Fn(&Entry<V>) -> bool) -> bool {
283 if self.is_disabled() {
284 return false;
285 }
286 let guard = self.map.pin();
287 let outcome = guard.compute(key, |existing| match existing {
288 Some((_, entry)) if predicate(entry) => Operation::Remove,
289 _ => Operation::Abort(()),
290 });
291
292 match outcome {
293 Compute::Removed(_, entry) => {
294 self.current_size
295 .fetch_sub(entry.size_bytes, Ordering::Relaxed);
296 true
297 }
298 _ => false,
299 }
300 }
301
302 pub fn evict_until_freed<P: Ord>(
308 &self,
309 target_bytes: usize,
310 priority: impl Fn(&K, &Entry<V>) -> Option<P>,
311 ) -> EvictionStats {
312 let guard = self.map.pin();
313 let mut candidates: Vec<(K, P)> = guard
314 .iter()
315 .filter_map(|(k, e)| priority(k, e).map(|p| (k.clone(), p)))
316 .collect();
317
318 candidates.sort_by(|(_, a), (_, b)| a.cmp(b));
320
321 let mut freed = 0usize;
322 let mut evicted = 0usize;
323 for (key, _) in candidates {
324 if freed >= target_bytes {
325 break;
326 }
327 if let Some(removed) = guard.remove(&key) {
333 freed += removed.size_bytes;
334 evicted += 1;
335 self.sub_current_size(removed.size_bytes);
336 }
337 }
338
339 EvictionStats { evicted, freed }
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_insert_and_with_entry_roundtrip() {
349 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
350
351 let (replaced, total) = map.insert_weighted("k".to_string(), "value".to_string(), 10);
352 assert!(replaced.is_none());
353 assert_eq!(total, 10);
354
355 let value = map.with_entry("k", |e| e.value.clone());
356 assert_eq!(value.as_deref(), Some("value"));
357 assert_eq!(map.current_size(), 10);
358 assert_eq!(map.len(), 1);
359 assert!(!map.is_empty());
360 }
361
362 #[test]
363 fn test_disabled_map_misses_and_ignores_inserts() {
364 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(0);
365 assert!(map.is_disabled());
366
367 let (replaced, total) = map.insert_weighted("k".to_string(), "v".to_string(), 10);
368 assert!(replaced.is_none());
369 assert_eq!(total, 0);
370 assert!(map.with_entry("k", |e| e.value.clone()).is_none());
371 assert!(map.is_empty());
372 assert_eq!(map.current_size(), 0);
373 }
374
375 #[test]
376 fn test_overwrite_subtracts_replaced_size() {
377 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
379
380 map.insert_weighted("k".to_string(), "small".to_string(), 100);
381 assert_eq!(map.current_size(), 100);
382
383 let (replaced, total) = map.insert_weighted("k".to_string(), "large".to_string(), 250);
384 assert_eq!(replaced.as_deref(), Some("small"));
385 assert_eq!(total, 250);
386 assert_eq!(map.current_size(), 250);
387 assert_eq!(map.len(), 1);
388 }
389
390 #[test]
391 fn test_remove_if_removes_only_matching_entries_and_adjusts_size() {
392 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
393 map.insert_weighted("keep".to_string(), "keep".to_string(), 100);
394 map.insert_weighted("drop".to_string(), "drop".to_string(), 250);
395 assert_eq!(map.current_size(), 350);
396
397 assert!(!map.remove_if("keep".to_string(), |entry| entry.value == "drop"));
399 assert_eq!(map.current_size(), 350);
400 assert_eq!(map.len(), 2);
401
402 assert!(map.remove_if("drop".to_string(), |entry| entry.value == "drop"));
404 assert_eq!(map.current_size(), 100);
405 assert_eq!(map.len(), 1);
406 assert!(map.with_entry("drop", |e| e.value.clone()).is_none());
407 }
408
409 #[test]
410 fn test_remove_if_absent_key_is_a_no_op() {
411 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
412 assert!(!map.remove_if("missing".to_string(), |_| true));
413 assert_eq!(map.current_size(), 0);
414 }
415
416 #[test]
417 fn test_remove_if_on_disabled_map_is_a_no_op() {
418 let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(0);
419 assert!(!map.remove_if("k".to_string(), |_| true));
420 }
421
422 #[test]
423 fn test_evict_until_freed_oldest_first() {
424 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
425
426 let base = Instant::now();
428 map.insert_weighted_at("old".to_string(), 1, 100, base);
429 map.insert_weighted_at(
430 "new".to_string(),
431 2,
432 100,
433 base + std::time::Duration::from_secs(1),
434 );
435
436 let stats = map.evict_until_freed(100, |_, e| Some(e.inserted_at));
437 assert_eq!(stats.evicted, 1);
438 assert_eq!(stats.freed, 100);
439 assert!(map.with_entry("old", |_| ()).is_none());
440 assert!(map.with_entry("new", |_| ()).is_some());
441 assert_eq!(map.current_size(), 100);
442 }
443
444 #[test]
445 fn test_evict_skips_entries_without_priority() {
446 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
447
448 map.insert_weighted("keep".to_string(), 0, 100);
449 map.insert_weighted("evictable".to_string(), 1, 100);
450
451 let stats = map.evict_until_freed(1000, |_, e| (e.value > 0).then_some(e.inserted_at));
454 assert_eq!(stats.evicted, 1);
455 assert_eq!(stats.freed, 100);
456 assert!(map.with_entry("keep", |_| ()).is_some());
457 assert!(map.with_entry("evictable", |_| ()).is_none());
458 }
459
460 #[test]
461 fn test_evict_accounts_the_entry_actually_removed() {
462 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
467 map.insert_weighted("k".to_string(), 1, 100);
468 assert_eq!(map.current_size(), 100);
469
470 let stats = map.evict_until_freed(50, |_, entry| {
473 map.insert_weighted("k".to_string(), 2, 900);
474 Some(entry.inserted_at)
475 });
476
477 assert_eq!(stats.evicted, 1);
478 assert_eq!(
479 stats.freed, 900,
480 "freed bytes must reflect the removed entry, not the stale snapshot"
481 );
482 assert!(map.is_empty());
483 assert_eq!(
484 map.current_size(),
485 0,
486 "an emptied map must account exactly zero bytes"
487 );
488 }
489
490 #[test]
491 fn test_current_size_subtraction_saturates_at_zero() {
492 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
496 map.insert_weighted("k".to_string(), 1, 10);
497
498 map.sub_current_size(usize::MAX);
499
500 assert_eq!(map.current_size(), 0);
501 }
502
503 #[test]
504 fn test_claim_or_claims_vacant_slot_and_accounts_size() {
505 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
506
507 let claim = map.claim_or("k".to_string(), 7, 40, |_| Some("occupied"));
508
509 assert!(matches!(claim, Claim::Claimed));
510 assert_eq!(map.with_entry("k", |e| e.value), Some(7));
511 assert_eq!(map.current_size(), 40);
512 }
513
514 #[test]
515 fn test_claim_or_retains_existing_entry() {
516 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
517 map.insert_weighted("k".to_string(), 7, 40);
518
519 let claim = map.claim_or("k".to_string(), 9, 40, |entry| Some(entry.value));
520
521 assert!(matches!(claim, Claim::Retained(7)));
522 assert_eq!(map.with_entry("k", |e| e.value), Some(7));
523 assert_eq!(
524 map.current_size(),
525 40,
526 "a retained slot must not be charged"
527 );
528 }
529
530 #[test]
531 fn test_claim_or_replaces_when_decision_declines_to_keep() {
532 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
533 map.insert_weighted("k".to_string(), 7, 40);
534
535 let claim = map.claim_or("k".to_string(), 9, 10, |_| None::<()>);
536
537 assert!(matches!(claim, Claim::Claimed));
538 assert_eq!(map.with_entry("k", |e| e.value), Some(9));
539 assert_eq!(
540 map.current_size(),
541 10,
542 "replacement must subtract the old size and add the new one"
543 );
544 }
545
546 #[test]
547 fn test_claim_or_on_disabled_map_stores_nothing() {
548 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(0);
549
550 let claim = map.claim_or("k".to_string(), 7, 40, |_| Some("occupied"));
551
552 assert!(matches!(claim, Claim::Claimed));
553 assert!(map.is_empty());
554 assert_eq!(map.current_size(), 0);
555 }
556
557 #[test]
558 fn test_claim_or_admits_exactly_one_of_many_concurrent_claimants() {
559 use std::sync::Barrier;
560
561 const THREADS: usize = 8;
562 const ROUNDS: usize = 64;
563
564 for round in 0..ROUNDS {
565 let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024 * 1024);
566 let key = format!("race-{round}");
567 let barrier = Barrier::new(THREADS);
568 let claims = AtomicUsize::new(0);
569
570 std::thread::scope(|scope| {
571 for _ in 0..THREADS {
572 scope.spawn(|| {
573 barrier.wait();
574 if let Claim::Claimed = map.claim_or(key.clone(), 1, 0, |_| Some(())) {
575 claims.fetch_add(1, Ordering::Relaxed);
576 }
577 });
578 }
579 });
580
581 assert_eq!(
582 claims.load(Ordering::Relaxed),
583 1,
584 "exactly one claimant may win a slot"
585 );
586 }
587 }
588
589 #[test]
590 fn test_entry_weigh_includes_key_and_overhead() {
591 let size = Entry::<Vec<u8>>::weigh(100, 7);
592 assert_eq!(size, 100 + 7 + std::mem::size_of::<Entry<Vec<u8>>>());
593 }
594}