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}