Skip to main content

cache_mod/
ttl.rs

1//! Time-To-Live (TTL) cache — lazy-expiry reference implementation.
2
3use core::hash::Hash;
4use std::collections::HashMap;
5use std::num::NonZeroUsize;
6use std::sync::Mutex;
7use std::time::{Duration, Instant};
8
9use crate::cache::Cache;
10use crate::error::CacheError;
11use crate::util::MutexExt;
12
13/// Fallback deadline span (~100 years) used when `Instant + ttl` would
14/// overflow. No realistic cache lifetime approaches this.
15const FAR_FUTURE: Duration = Duration::from_secs(60 * 60 * 24 * 365 * 100);
16
17/// A bounded, thread-safe cache with per-entry time-to-live.
18///
19/// Each entry is stamped with a deadline at insert time. On every access
20/// ([`get`](Cache::get), [`contains_key`](Cache::contains_key),
21/// [`len`](Cache::len)), expired entries are removed lazily. On overflow,
22/// the entry with the **soonest expiration** is evicted — already-expired
23/// entries are naturally preferred over live ones.
24///
25/// Both [`insert`](Cache::insert) and [`insert_with_ttl`](TtlCache::insert_with_ttl)
26/// reset the deadline on the affected entry — writes always re-arm the timer.
27///
28/// This is the reference implementation: correct, `&self`-everywhere,
29/// `Mutex`-guarded. Eviction and lazy purge are O(n) scans. The lock-free,
30/// arena-backed replacement lands in a later minor without changing this
31/// public surface.
32///
33/// # Example
34///
35/// ```
36/// use std::time::Duration;
37/// use cache_mod::{Cache, TtlCache};
38///
39/// let cache: TtlCache<&'static str, u32> =
40///     TtlCache::new(4, Duration::from_secs(60)).expect("capacity > 0");
41///
42/// cache.insert("session", 42);
43/// assert_eq!(cache.get(&"session"), Some(42));
44/// ```
45pub struct TtlCache<K, V> {
46    capacity: NonZeroUsize,
47    default_ttl: Duration,
48    inner: Mutex<Inner<K, V>>,
49}
50
51struct Entry<V> {
52    value: V,
53    expires_at: Instant,
54}
55
56struct Inner<K, V> {
57    map: HashMap<K, Entry<V>>,
58}
59
60impl<K, V> TtlCache<K, V>
61where
62    K: Eq + Hash + Clone,
63    V: Clone,
64{
65    /// Creates a cache with the given capacity and default time-to-live.
66    ///
67    /// `ttl` is applied to every `insert` that does not specify its own.
68    /// Returns [`CacheError::InvalidCapacity`] if `capacity == 0`.
69    ///
70    /// # Example
71    ///
72    /// ```
73    /// use std::time::Duration;
74    /// use cache_mod::TtlCache;
75    ///
76    /// let cache: TtlCache<String, u32> =
77    ///     TtlCache::new(128, Duration::from_secs(300)).expect("capacity > 0");
78    /// ```
79    pub fn new(capacity: usize, ttl: Duration) -> Result<Self, CacheError> {
80        let cap = NonZeroUsize::new(capacity).ok_or(CacheError::InvalidCapacity)?;
81        Ok(Self::with_capacity(cap, ttl))
82    }
83
84    /// Creates a cache with the given non-zero capacity and default TTL.
85    /// Infallible.
86    ///
87    /// # Example
88    ///
89    /// ```
90    /// use std::num::NonZeroUsize;
91    /// use std::time::Duration;
92    /// use cache_mod::TtlCache;
93    ///
94    /// let cap = NonZeroUsize::new(64).expect("64 != 0");
95    /// let cache: TtlCache<String, u32> =
96    ///     TtlCache::with_capacity(cap, Duration::from_secs(60));
97    /// ```
98    pub fn with_capacity(capacity: NonZeroUsize, ttl: Duration) -> Self {
99        let cap = capacity.get();
100        Self {
101            capacity,
102            default_ttl: ttl,
103            inner: Mutex::new(Inner {
104                map: HashMap::with_capacity(cap),
105            }),
106        }
107    }
108
109    /// Inserts `value` under `key` with a per-call TTL that overrides the
110    /// cache default. The deadline is `now + ttl`.
111    ///
112    /// Returns the previously-stored **live** value if `key` was already
113    /// present and not yet expired. An expired-but-not-yet-cleaned entry
114    /// is treated as absent: the call returns `None` and replaces it.
115    ///
116    /// # Example
117    ///
118    /// ```
119    /// use std::time::Duration;
120    /// use cache_mod::{Cache, TtlCache};
121    ///
122    /// let cache: TtlCache<u32, u32> =
123    ///     TtlCache::new(4, Duration::from_secs(60)).expect("capacity > 0");
124    ///
125    /// cache.insert_with_ttl(1, 10, Duration::from_secs(5));
126    /// assert_eq!(cache.get(&1), Some(10));
127    /// ```
128    pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) -> Option<V> {
129        let deadline = compute_deadline(ttl);
130        let mut inner = self.inner.lock_recover();
131        self.insert_inner(&mut inner, key, value, deadline)
132    }
133
134    fn insert_inner(
135        &self,
136        inner: &mut Inner<K, V>,
137        key: K,
138        value: V,
139        deadline: Instant,
140    ) -> Option<V> {
141        let now = Instant::now();
142
143        // Live update: existing key + still in-window.
144        if let Some(existing) = inner.map.get_mut(&key) {
145            if existing.expires_at > now {
146                let old = core::mem::replace(&mut existing.value, value);
147                existing.expires_at = deadline;
148                return Some(old);
149            }
150            // Expired — fall through to fresh-insert path. Borrow drops here.
151        }
152
153        // Drop any stale entry under this key so a re-insert reads as fresh.
154        let _ = inner.map.remove(&key);
155
156        // Evict the soonest-expiring entry if at capacity. Already-expired
157        // entries naturally win this scan.
158        if inner.map.len() >= self.capacity.get() {
159            if let Some(victim) = find_victim(&inner.map) {
160                let _ = inner.map.remove(&victim);
161            }
162        }
163
164        let _ = inner.map.insert(
165            key,
166            Entry {
167                value,
168                expires_at: deadline,
169            },
170        );
171        None
172    }
173}
174
175impl<K, V> Cache<K, V> for TtlCache<K, V>
176where
177    K: Eq + Hash + Clone,
178    V: Clone,
179{
180    fn get(&self, key: &K) -> Option<V> {
181        let mut inner = self.inner.lock_recover();
182        let now = Instant::now();
183        // Read the deadline first (Copy) so the borrow drops before any mutation.
184        let expires_at = inner.map.get(key).map(|e| e.expires_at)?;
185        if expires_at <= now {
186            let _ = inner.map.remove(key);
187            return None;
188        }
189        inner.map.get(key).map(|e| e.value.clone())
190    }
191
192    fn insert(&self, key: K, value: V) -> Option<V> {
193        let deadline = compute_deadline(self.default_ttl);
194        let mut inner = self.inner.lock_recover();
195        self.insert_inner(&mut inner, key, value, deadline)
196    }
197
198    fn remove(&self, key: &K) -> Option<V> {
199        let mut inner = self.inner.lock_recover();
200        inner.map.remove(key).map(|e| e.value)
201    }
202
203    fn contains_key(&self, key: &K) -> bool {
204        let mut inner = self.inner.lock_recover();
205        let now = Instant::now();
206        let Some(expires_at) = inner.map.get(key).map(|e| e.expires_at) else {
207            return false;
208        };
209        if expires_at > now {
210            return true;
211        }
212        let _ = inner.map.remove(key);
213        false
214    }
215
216    fn len(&self) -> usize {
217        let mut inner = self.inner.lock_recover();
218        purge_expired(&mut inner.map);
219        inner.map.len()
220    }
221
222    fn clear(&self) {
223        let mut inner = self.inner.lock_recover();
224        inner.map.clear();
225    }
226
227    fn capacity(&self) -> usize {
228        self.capacity.get()
229    }
230}
231
232/// Compute the deadline for a TTL. Falls back to a far-future deadline if
233/// `Instant + ttl` would overflow.
234fn compute_deadline(ttl: Duration) -> Instant {
235    let now = Instant::now();
236    match now.checked_add(ttl) {
237        Some(t) => t,
238        None => now.checked_add(FAR_FUTURE).unwrap_or(now),
239    }
240}
241
242/// Eviction target: soonest expiration first. Already-expired entries win
243/// naturally because their `expires_at` is in the past.
244fn find_victim<K, V>(map: &HashMap<K, Entry<V>>) -> Option<K>
245where
246    K: Clone,
247{
248    map.iter()
249        .min_by_key(|(_, e)| e.expires_at)
250        .map(|(k, _)| k.clone())
251}
252
253/// Remove every entry whose deadline is at or before `Instant::now()`.
254fn purge_expired<K, V>(map: &mut HashMap<K, Entry<V>>) {
255    let now = Instant::now();
256    map.retain(|_, entry| entry.expires_at > now);
257}