Skip to main content

k_cache/
ttl.rs

1use std::time::Duration;
2
3use crate::cache::{DefaultLifecycle, EntryStatus, Lifecycle};
4
5/// Implemented by cache values that know how long they have left to live.
6///
7/// [`TtlLifecycle`] uses this to decide whether an entry is still fresh during
8/// [`Lifecycle::evaluate`]. A value that returns [`Duration::ZERO`] is treated
9/// as expired and will be evicted the next time the sieve hand reaches it.
10///
11/// Note that you may be asked multiple times about the same key even if you say
12/// zero.
13pub trait Ttl {
14    /// How much time the value has left. `Duration::ZERO` means expired.
15    fn remaining(&self) -> Duration;
16}
17
18/// Lifecycle decorator that evicts entries by [`Ttl::remaining`].
19///
20/// `on_eviction` is forwarded to the inner Lifecycle.
21/// `evaluate` is forwarded only when the value is still fresh.
22///
23/// Wrap any other Lifecycle to combine TTL with custom eviction policies.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct TtlLifecycle<L = DefaultLifecycle> {
26    inner: L,
27}
28
29impl TtlLifecycle<DefaultLifecycle> {
30    pub fn new() -> Self {
31        Self {
32            inner: DefaultLifecycle,
33        }
34    }
35}
36
37impl<L> TtlLifecycle<L> {
38    pub fn with_inner(inner: L) -> Self {
39        Self { inner }
40    }
41}
42
43impl<K, V, L> Lifecycle<K, V> for TtlLifecycle<L>
44where
45    V: Ttl,
46    L: Lifecycle<K, V>,
47{
48    fn on_eviction(&mut self, key: K, value: V) {
49        self.inner.on_eviction(key, value);
50    }
51
52    fn evaluate(&self, key: &K, value: &V) -> EntryStatus {
53        if value.remaining() == Duration::ZERO {
54            EntryStatus::Evict
55        } else {
56            self.inner.evaluate(key, value)
57        }
58    }
59}
60
61#[cfg(test)]
62mod test {
63    use std::hash::RandomState;
64    use std::sync::{Arc, Mutex};
65    use std::time::{Duration, Instant};
66
67    use super::*;
68    use crate::Cache;
69
70    #[derive(Debug, Clone)]
71    struct Expiring<V> {
72        value: V,
73        expires_at: Instant,
74    }
75
76    impl<V> Ttl for Expiring<V> {
77        fn remaining(&self) -> Duration {
78            self.expires_at.saturating_duration_since(Instant::now())
79        }
80    }
81
82    #[test]
83    fn fresh_entries_are_returned() {
84        let mut cache: Cache<String, Expiring<String>, RandomState, crate::One, TtlLifecycle> =
85            Cache::new_with_lifecycle(RandomState::new(), 100, TtlLifecycle::new());
86        cache.put(
87            "k".to_string(),
88            Expiring {
89                value: "v".to_string(),
90                expires_at: Instant::now() + Duration::from_secs(60),
91            },
92        );
93        assert_eq!(cache.get("k").map(|e| e.value.as_str()), Some("v"));
94    }
95
96    #[test]
97    fn expired_entries_report_a_miss_on_get() {
98        let mut cache: Cache<String, Expiring<String>, RandomState, crate::One, TtlLifecycle> =
99            Cache::new_with_lifecycle(RandomState::new(), 100, TtlLifecycle::new());
100        cache.put(
101            "k".to_string(),
102            Expiring {
103                value: "v".to_string(),
104                expires_at: Instant::now(),
105            },
106        );
107        // already at/past expiry
108        assert!(cache.get("k").is_none());
109    }
110
111    #[test]
112    fn expired_entries_are_evicted_when_the_hand_reaches_them() {
113        let evicted: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
114
115        #[derive(Clone)]
116        struct Recorder(Arc<Mutex<Vec<String>>>);
117        impl Lifecycle<String, Expiring<String>> for Recorder {
118            fn on_eviction(&mut self, key: String, _value: Expiring<String>) {
119                self.0.lock().expect("lock poison").push(key);
120            }
121        }
122
123        let lifecycle = TtlLifecycle::with_inner(Recorder(evicted.clone()));
124        let mut cache: Cache<
125            String,
126            Expiring<String>,
127            RandomState,
128            crate::One,
129            TtlLifecycle<Recorder>,
130        > = Cache::new_with_lifecycle(RandomState::new(), 100, lifecycle);
131
132        cache.put(
133            "stale".to_string(),
134            Expiring {
135                value: "x".to_string(),
136                expires_at: Instant::now(),
137            },
138        );
139        // hand advances 2 states per put; one extra put is enough to land on
140        // the lone stale entry and evict it via advance_hand.
141        cache.put(
142            "fresh".to_string(),
143            Expiring {
144                value: "y".to_string(),
145                expires_at: Instant::now() + Duration::from_secs(60),
146            },
147        );
148
149        assert_eq!(
150            evicted.lock().expect("lock poison").as_slice(),
151            &["stale".to_string()]
152        );
153        assert!(cache.get("stale").is_none());
154        assert!(cache.get("fresh").is_some());
155    }
156}