use crate::Store;
use std::time::Instant;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ExpireStats {
pub sampled: u32,
pub expired: u32,
pub rounds: u32,
}
const EXPIRE_RATE_CONTINUATION: u32 = 25;
pub(crate) fn sample_round(
store: &mut Store,
samples: usize,
now: Instant,
) -> (u32, u32) {
let cap = store.map.capacity();
if cap == 0 || store.map.is_empty() {
return (0, 0);
}
store.clock_counter = store.clock_counter.wrapping_add(1);
let start = (store
.clock_counter
.wrapping_mul(0x9E37_79B9_7F4A_7C15) as usize)
% cap;
let mut victims: Vec<Vec<u8>> = Vec::with_capacity(samples);
let mut sampled = 0u32;
let visit_cap = samples.saturating_mul(8);
let mut visited = 0usize;
{
for (k, e) in store.map.iter_from_bucket(start) {
visited += 1;
if sampled as usize >= samples || visited > visit_cap {
break;
}
let Some(deadline_ns) = e.expire_at_ns else {
continue;
};
sampled += 1;
if crate::unpack_deadline(deadline_ns) <= now {
victims.push(k.to_vec());
}
}
}
let expired = victims.len() as u32;
for k in &victims {
store.remove_entry(k);
}
if expired > 0 {
store.expired_keys_total = store
.expired_keys_total
.saturating_add(expired as u64);
}
let _ = sampled; (sampled, expired)
}
impl Store {
pub fn tick_expire(&mut self, samples_per_round: usize, max_rounds: u32) -> ExpireStats {
self.refresh_clock();
if samples_per_round == 0 || max_rounds == 0 || self.map.is_empty() {
return ExpireStats::default();
}
let now = Instant::now();
let mut total_sampled = 0u32;
let mut total_expired = 0u32;
let mut rounds = 0u32;
let mut consecutive_zero = 0u32;
for _ in 0..max_rounds {
let (sampled, expired) = sample_round(self, samples_per_round, now);
rounds += 1;
total_sampled = total_sampled.saturating_add(sampled);
total_expired = total_expired.saturating_add(expired);
if sampled == 0 {
consecutive_zero += 1;
if consecutive_zero >= 3 {
break;
}
continue;
}
consecutive_zero = 0;
if expired * 100 < sampled * EXPIRE_RATE_CONTINUATION {
break;
}
}
ExpireStats {
sampled: total_sampled,
expired: total_expired,
rounds,
}
}
#[inline]
pub fn expired_keys_total(&self) -> u64 {
self.expired_keys_total
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::SmallBytes;
use std::time::Duration;
#[test]
fn tick_expire_drops_past_deadline() {
let mut s = Store::new();
s.set(b"k1", b"v".to_vec(), Some(Duration::from_millis(1)), false, false);
s.set(b"k2", b"v".to_vec(), Some(Duration::from_millis(1)), false, false);
s.set(b"perm", b"v".to_vec(), None, false, false);
std::thread::sleep(Duration::from_millis(20));
for _ in 0..64 {
if s.dbsize() == 1 {
break;
}
s.tick_expire(20, 16);
}
assert_eq!(s.dbsize(), 1, "perm survives, both TTL'd keys reaped");
assert!(s.expired_keys_total() >= 2);
}
#[test]
fn tick_expire_no_op_on_fresh_ttls() {
let mut s = Store::new();
s.set(b"k1", b"v".to_vec(), Some(Duration::from_secs(3600)), false, false);
s.set(b"k2", b"v".to_vec(), Some(Duration::from_secs(3600)), false, false);
let stats = s.tick_expire(20, 16);
assert_eq!(stats.expired, 0, "no fresh TTL should expire");
assert_eq!(s.dbsize(), 2);
}
#[test]
fn tick_expire_no_op_on_ttl_free_keyspace() {
let mut s = Store::new();
for i in 0..50 {
s.set(format!("k{i}").as_bytes(), b"v".to_vec(), None, false, false);
}
let stats = s.tick_expire(20, 16);
assert_eq!(stats.expired, 0);
assert_eq!(stats.sampled, 0, "no TTL'd keys ⇒ nothing sampled");
assert!(stats.rounds <= 3, "got {}", stats.rounds);
}
#[test]
fn tick_expire_zero_args_short_circuit() {
let mut s = Store::new();
s.set(b"k", b"v".to_vec(), Some(Duration::from_millis(1)), false, false);
std::thread::sleep(Duration::from_millis(5));
assert_eq!(s.tick_expire(0, 16), ExpireStats::default());
assert_eq!(s.tick_expire(20, 0), ExpireStats::default());
assert_eq!(s.dbsize(), 1);
}
#[test]
fn tick_expire_loops_on_heavy_batch() {
let mut s = Store::new();
for i in 0..40 {
s.set(
format!("k{i}").as_bytes(),
b"v".to_vec(),
Some(Duration::from_millis(1)),
false,
false,
);
}
s.set(b"perm", b"v".to_vec(), None, false, false);
std::thread::sleep(Duration::from_millis(20));
let mut total_expired = 0u32;
let mut any_round_ge_2 = false;
for _ in 0..20 {
let stats = s.tick_expire(20, 16);
total_expired += stats.expired;
if stats.rounds >= 2 {
any_round_ge_2 = true;
}
if s.dbsize() == 1 {
break;
}
}
assert_eq!(total_expired, 40);
assert!(any_round_ge_2, "at least one heavy-batch tick should loop");
assert_eq!(s.dbsize(), 1);
let _ = SmallBytes::from_slice(b"k0"); }
}