1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
//! TTL-based caching.
//!
//! This module provides a thread-safe, capacity-bounded cache with
//! time-to-live (TTL) expiration.
//!
//! # Clock
//!
//! The cache uses [`tokio::time::Instant`] as its monotonic clock. In normal
//! operation this is identical to [`std::time::Instant`]; when a test runs
//! inside a `#[tokio::test(start_paused = true)]` runtime, the clock becomes
//! virtual and can be advanced deterministically via `tokio::time::advance`,
//! which keeps TTL unit tests fast and non-flaky.
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::time::Duration;
use tokio::time::Instant;
use tracing::{debug, warn};
/// A cache entry with TTL tracking.
#[derive(Debug, Clone)]
struct CacheEntry<V> {
value: V,
inserted_at: Instant,
ttl: Duration,
}
impl<V> CacheEntry<V> {
/// Creates a new cache entry.
fn new(value: V, ttl: Duration) -> Self {
Self {
value,
inserted_at: Instant::now(),
ttl,
}
}
/// Returns true if the entry has expired.
fn is_expired(&self) -> bool {
self.inserted_at.elapsed() > self.ttl
}
/// Returns the age of the entry.
fn age(&self) -> Duration {
self.inserted_at.elapsed()
}
}
/// Thread-safe TTL cache.
///
/// This cache supports:
/// - Automatic expiration based on TTL
/// - A capacity bound (expired, then oldest, entries are evicted)
/// - Thread-safe access via RwLock
///
/// # Example
///
/// ```
/// use std::time::Duration;
/// use seer_core::cache::TtlCache;
///
/// let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
///
/// // Insert a value
/// cache.insert("key".to_string(), "value".to_string());
///
/// // Get the value (returns None if expired)
/// if let Some(value) = cache.get(&"key".to_string()) {
/// println!("Got: {}", value);
/// }
/// ```
pub struct TtlCache<K, V> {
entries: RwLock<HashMap<K, CacheEntry<V>>>,
default_ttl: Duration,
/// Maximum number of entries. When exceeded, expired entries are purged
/// and if still over capacity, the oldest entry is evicted.
max_capacity: usize,
}
/// Default maximum capacity for TtlCache instances.
const DEFAULT_MAX_CAPACITY: usize = 1024;
impl<K, V> TtlCache<K, V>
where
K: Eq + Hash + Clone + std::fmt::Debug,
V: Clone,
{
/// Creates a new cache with the specified default TTL and default max capacity (1024).
pub fn new(default_ttl: Duration) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
default_ttl,
max_capacity: DEFAULT_MAX_CAPACITY,
}
}
/// Creates a new cache with a specified TTL and max capacity.
pub fn with_max_capacity(default_ttl: Duration, max_capacity: usize) -> Self {
Self {
entries: RwLock::new(HashMap::new()),
default_ttl,
max_capacity,
}
}
/// Read access; a poisoned lock is recovered with a warning.
fn read(&self) -> RwLockReadGuard<'_, HashMap<K, CacheEntry<V>>> {
self.entries.read().unwrap_or_else(|poisoned| {
warn!("Cache read lock poisoned, recovering");
poisoned.into_inner()
})
}
/// Write access; a poisoned lock is recovered with a warning.
fn write(&self) -> RwLockWriteGuard<'_, HashMap<K, CacheEntry<V>>> {
self.entries.write().unwrap_or_else(|poisoned| {
warn!("Cache write lock poisoned, recovering");
poisoned.into_inner()
})
}
/// Gets a value from the cache if it exists and is not expired.
///
/// Returns `None` if the key doesn't exist or the entry has expired.
pub fn get(&self, key: &K) -> Option<V> {
let entries = self.read();
let entry = entries.get(key)?;
if entry.is_expired() {
debug!(
hit = false,
?key,
age_secs = entry.age().as_secs(),
"cache lookup (expired)"
);
None
} else {
debug!(hit = true, ?key, "cache lookup");
Some(entry.value.clone())
}
}
/// Inserts a value into the cache with the default TTL.
pub fn insert(&self, key: K, value: V) {
self.insert_with_ttl(key, value, self.default_ttl);
}
/// Inserts a value into the cache with a custom TTL.
///
/// If the cache exceeds max capacity, expired entries are purged first.
/// If still over capacity, the oldest entry is evicted.
pub fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
let mut entries = self.write();
// Evict if at capacity (before inserting)
if entries.len() >= self.max_capacity && !entries.contains_key(&key) {
// First, remove expired entries
let before = entries.len();
entries.retain(|_, entry| !entry.is_expired());
let removed = before - entries.len();
if removed > 0 {
debug!(removed, "Evicted expired entries to make room");
}
// If still at capacity, evict the oldest entries in a single batch
// down to a low-water mark (~90% of capacity). Doing this in one
// pass amortizes the O(n) selection across the next inserts instead
// of re-running it on every insert once full — which serialized all
// writers behind an O(n) scan and became a write-lock contention
// cliff at large capacities (issue #51). Exact-LRU is preserved:
// the genuinely oldest entries are the ones removed.
if entries.len() >= self.max_capacity {
let low_water = self.max_capacity.saturating_mul(9) / 10;
let to_remove = entries.len().saturating_sub(low_water).max(1);
let mut aged: Vec<(K, Duration)> =
entries.iter().map(|(k, e)| (k.clone(), e.age())).collect();
let take = to_remove.min(aged.len());
if take < aged.len() {
// Partition the `take` oldest (largest age) to the front; an
// O(n) average partial-select, no full sort.
aged.select_nth_unstable_by(take - 1, |a, b| b.1.cmp(&a.1));
}
for (k, _) in aged.into_iter().take(take) {
entries.remove(&k);
}
debug!(evicted = take, "Batch-evicted oldest entries to make room");
}
}
debug!(?key, ttl_secs = ttl.as_secs(), "Inserting cache entry");
entries.insert(key, CacheEntry::new(value, ttl));
}
/// Removes a value from the cache.
pub fn remove(&self, key: &K) -> Option<V> {
self.write().remove(key).map(|e| e.value)
}
/// Returns the number of entries in the cache (including expired ones).
pub fn len(&self) -> usize {
self.read().len()
}
/// Returns true if the cache is empty.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Clears all entries from the cache.
pub fn clear(&self) {
self.write().clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_insert_and_get() {
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
cache.insert("key".to_string(), "value".to_string());
assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
}
#[test]
fn test_cache_get_missing_key() {
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
assert_eq!(cache.get(&"missing".to_string()), None);
}
#[test]
fn test_cache_expiration() {
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_millis(10));
cache.insert("key".to_string(), "value".to_string());
assert_eq!(cache.get(&"key".to_string()), Some("value".to_string()));
// Wait for expiration
std::thread::sleep(Duration::from_millis(20));
assert_eq!(cache.get(&"key".to_string()), None);
}
#[test]
fn test_cache_remove() {
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
cache.insert("key".to_string(), "value".to_string());
assert!(cache.get(&"key".to_string()).is_some());
cache.remove(&"key".to_string());
assert!(cache.get(&"key".to_string()).is_none());
}
#[test]
fn capacity_eviction_batches_to_low_water_mark() {
// Eviction removes a BATCH of the oldest entries down to a low-water
// mark in a single pass, so the O(n) selection is amortized across the
// next inserts rather than re-run on every insert once full (issue #51).
// After crossing capacity the size drops below it, not hovering at it.
let cap = 100;
let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
for i in 0..=cap as u32 {
// 0..=100 => 101 distinct keys, crossing capacity once.
cache.insert(i, i);
}
assert!(
cache.len() <= (cap * 9 / 10) + 1,
"batch eviction should drop to ~90% low-water, got len {}",
cache.len()
);
assert!(
cache.len() < cap,
"must be below capacity after a batch evict"
);
// Exact-LRU preserved: newest survives, oldest evicted.
assert_eq!(cache.get(&(cap as u32)), Some(cap as u32));
assert_eq!(cache.get(&0), None);
}
#[test]
fn capacity_eviction_never_exceeds_capacity_under_churn() {
let cap = 50;
let cache: TtlCache<u32, u32> = TtlCache::with_max_capacity(Duration::from_secs(3600), cap);
for i in 0..1000u32 {
cache.insert(i, i);
assert!(cache.len() <= cap, "len {} exceeded cap {cap}", cache.len());
}
}
#[test]
fn test_cache_clear() {
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(3600));
cache.insert("key1".to_string(), "value1".to_string());
cache.insert("key2".to_string(), "value2".to_string());
assert_eq!(cache.len(), 2);
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[tokio::test(start_paused = true)]
async fn test_entry_expires_after_ttl() {
// Uses tokio's virtual clock (TtlCache uses tokio::time::Instant), so
// the clock is advanced deterministically instead of real sleeps.
let cache: TtlCache<String, String> = TtlCache::new(Duration::from_secs(1));
cache.insert("key".to_string(), "value".to_string());
tokio::time::advance(Duration::from_millis(800)).await;
assert!(
cache.get(&"key".to_string()).is_some(),
"entry must not be expired at t=800ms (< 1000ms TTL)"
);
tokio::time::advance(Duration::from_millis(300)).await;
assert!(
cache.get(&"key".to_string()).is_none(),
"entry must be expired at t=1100ms (> 1000ms TTL)"
);
}
}