use std::num::NonZeroUsize;
use lru::LruCache;
pub(crate) const CACHE_ENTRY_OVERHEAD_BYTES: usize = 512;
pub(crate) struct ByteBoundedLruCache<K: std::hash::Hash + Eq, V> {
inner: LruCache<K, V>,
total_bytes: usize,
byte_budget: usize,
weigh: fn(&V) -> usize,
}
impl<K: std::hash::Hash + Eq, V> ByteBoundedLruCache<K, V> {
pub(crate) fn new(count_cap: NonZeroUsize, byte_budget: usize, weigh: fn(&V) -> usize) -> Self {
Self {
inner: LruCache::new(count_cap),
total_bytes: 0,
byte_budget: byte_budget.max(1),
weigh,
}
}
fn entry_weight(&self, value: &V) -> usize {
(self.weigh)(value).saturating_add(CACHE_ENTRY_OVERHEAD_BYTES)
}
pub(crate) fn get(&mut self, key: &K) -> Option<&V> {
self.inner.get(key)
}
pub(crate) fn put(&mut self, key: K, value: V) {
let added = self.entry_weight(&value);
if added > self.byte_budget {
return;
}
if let Some((_, displaced)) = self.inner.push(key, value) {
self.total_bytes = self
.total_bytes
.saturating_sub(self.entry_weight(&displaced));
}
self.total_bytes = self.total_bytes.saturating_add(added);
while self.total_bytes > self.byte_budget && self.inner.len() > 1 {
match self.inner.pop_lru() {
Some((_, evicted)) => {
self.total_bytes = self.total_bytes.saturating_sub(self.entry_weight(&evicted));
}
None => break,
}
}
}
pub(crate) fn cap(&self) -> NonZeroUsize {
self.inner.cap()
}
pub(crate) fn grow(&mut self, cap: NonZeroUsize) {
debug_assert!(
cap >= self.inner.cap(),
"ByteBoundedLruCache::grow must not shrink (would leak byte accounting)"
);
if cap <= self.inner.cap() {
return;
}
self.inner.resize(cap);
}
#[cfg(test)]
pub(crate) fn total_bytes(&self) -> usize {
self.total_bytes
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.inner.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(
clippy::ptr_arg,
reason = "must match ByteBoundedLruCache<_, Vec<u8>>'s fn(&V) -> usize weigh signature exactly"
)]
fn vec_len(v: &Vec<u8>) -> usize {
v.len()
}
#[test]
fn byte_budget_bounds_ram_for_large_values() {
let byte_budget = 8 * 1024 * 1024; let count_cap = NonZeroUsize::new(65_536).unwrap(); let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(count_cap, byte_budget, vec_len);
for i in 0..200u64 {
cache.put(i, vec![0u8; 1024 * 1024]);
assert!(
cache.total_bytes() <= byte_budget,
"total_bytes {} exceeded byte_budget {} after insert {}",
cache.total_bytes(),
byte_budget,
i
);
}
assert!(
cache.len() <= 8,
"byte budget must hold far fewer than the count cap; held {}",
cache.len()
);
assert!(
cache.total_bytes() <= byte_budget,
"final total_bytes {} must be within byte_budget {}",
cache.total_bytes(),
byte_budget
);
}
#[test]
fn oversized_value_is_not_cached() {
let byte_budget = 8 * 1024 * 1024; let count_cap = NonZeroUsize::new(65_536).unwrap();
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(count_cap, byte_budget, vec_len);
cache.put(1, vec![0u8; 16 * 1024 * 1024]);
assert!(
cache.get(&1).is_none(),
"an over-budget value must not be cached"
);
assert_eq!(
cache.len(),
0,
"cache must stay empty after an over-budget put"
);
assert_eq!(
cache.total_bytes(),
0,
"total_bytes must stay 0 when nothing was cached"
);
cache.put(2, vec![0u8; 1024]);
assert!(cache.get(&2).is_some(), "a within-budget value must cache");
assert_eq!(cache.len(), 1);
assert!(
cache.total_bytes() <= byte_budget,
"total_bytes {} must stay within budget {}",
cache.total_bytes(),
byte_budget
);
}
#[test]
fn empty_values_stay_entry_bounded() {
let byte_budget = 32 * CACHE_ENTRY_OVERHEAD_BYTES;
let count_cap = NonZeroUsize::new(65_536).unwrap();
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(count_cap, byte_budget, vec_len);
for i in 0..2000u64 {
cache.put(i, Vec::new()); }
assert!(
cache.len() <= 32,
"empty values must still be evicted at the overhead floor; held {} (expected <= 32)",
cache.len()
);
}
#[test]
fn count_cap_binds_for_small_values() {
let byte_budget = 32 * 1024 * 1024; let count_cap = NonZeroUsize::new(4).unwrap();
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(count_cap, byte_budget, vec_len);
for i in 0..10u64 {
cache.put(i, vec![7u8; 8]);
}
assert_eq!(cache.len(), 4, "count cap must bind for small values");
assert_eq!(
cache.total_bytes(),
4 * (8 + CACHE_ENTRY_OVERHEAD_BYTES),
"byte accounting must stay exact across count-cap evictions"
);
for i in 0..6u64 {
assert!(cache.get(&i).is_none(), "key {i} should have been evicted");
}
for i in 6..10u64 {
assert!(cache.get(&i).is_some(), "key {i} should be resident");
}
}
#[test]
fn replacing_a_key_keeps_byte_total_exact() {
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(NonZeroUsize::new(16).unwrap(), 32 * 1024 * 1024, vec_len);
cache.put(1, vec![0u8; 100]);
cache.put(1, vec![0u8; 300]);
assert_eq!(cache.len(), 1);
assert_eq!(
cache.total_bytes(),
300 + CACHE_ENTRY_OVERHEAD_BYTES,
"replace must account only the new value, not old + new"
);
}
#[test]
fn grow_preserves_byte_total() {
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(NonZeroUsize::new(2).unwrap(), 32 * 1024 * 1024, vec_len);
cache.put(1, vec![0u8; 10]);
cache.put(2, vec![0u8; 20]);
let before = cache.total_bytes();
cache.grow(NonZeroUsize::new(1024).unwrap());
assert_eq!(cache.cap().get(), 1024);
assert_eq!(
cache.total_bytes(),
before,
"grow must not change byte total"
);
assert_eq!(cache.len(), 2, "grow must not evict");
}
#[test]
fn grow_with_equal_cap_is_noop() {
let count_cap = NonZeroUsize::new(4).unwrap();
let mut cache: ByteBoundedLruCache<u64, Vec<u8>> =
ByteBoundedLruCache::new(count_cap, 32 * 1024 * 1024, vec_len);
cache.put(1, vec![0u8; 10]);
cache.put(2, vec![0u8; 20]);
cache.put(3, vec![0u8; 30]);
let len_before = cache.len();
let bytes_before = cache.total_bytes();
let cap_before = cache.cap().get();
cache.grow(count_cap);
assert_eq!(cache.len(), len_before, "equal-cap grow must not evict");
assert_eq!(
cache.total_bytes(),
bytes_before,
"equal-cap grow must not change the byte total"
);
assert_eq!(
cache.cap().get(),
cap_before,
"equal-cap grow must not change the count cap"
);
}
}