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