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}