Skip to main content

rustlavel_cache/
memory.rs

1//! The in-memory driver: the default, and the one tests use.
2//!
3//! Entries live in a fixed set of shards, each behind its own `RwLock`. One
4//! global lock would serialise every cache read in the process — which for a
5//! web server means serialising every request — while sharding means two keys
6//! that hash differently never wait for each other.
7//!
8//! Expired entries are removed on read (so a stale value is never returned even
9//! between sweeps) and by a periodic background sweep (so a key that is written
10//! once and never read again does not leak memory forever).
11
12use crate::store::{BoxFuture, Cache, counter_value, prefixed, record};
13use rustlavel_core::{Json, Result};
14use std::collections::HashMap;
15use std::collections::hash_map::Entry as MapEntry;
16use std::hash::{Hash, Hasher};
17use std::sync::{Arc, RwLock, Weak};
18use std::time::{Duration, Instant};
19
20/// How many shards the key space is split across. A power of two so the index
21/// is a mask; sixteen is comfortably more than the core count of a typical
22/// deployment without wasting memory on empty maps.
23const SHARDS: usize = 16;
24
25/// How often the background task removes entries nobody read.
26const DEFAULT_SWEEP: Duration = Duration::from_secs(60);
27
28#[derive(Clone, Debug)]
29struct Entry {
30    value: Json,
31    /// `None` means the entry was stored with `forever`.
32    expires_at: Option<Instant>,
33}
34
35impl Entry {
36    fn is_expired(&self, now: Instant) -> bool {
37        self.expires_at.is_some_and(|at| at <= now)
38    }
39}
40
41struct Inner {
42    shards: Vec<RwLock<HashMap<String, Entry>>>,
43    prefix: String,
44}
45
46impl Inner {
47    fn shard(&self, key: &str) -> &RwLock<HashMap<String, Entry>> {
48        let mut hasher = std::collections::hash_map::DefaultHasher::new();
49        key.hash(&mut hasher);
50        &self.shards[(hasher.finish() as usize) % SHARDS]
51    }
52
53    /// Drop every expired entry. Called by the background sweep.
54    fn sweep(&self) {
55        let now = Instant::now();
56        for shard in &self.shards {
57            let mut map = shard.write().expect("cache shard poisoned");
58            map.retain(|_, entry| !entry.is_expired(now));
59        }
60    }
61
62    fn live_count(&self) -> usize {
63        let now = Instant::now();
64        self.shards
65            .iter()
66            .map(|shard| {
67                shard
68                    .read()
69                    .expect("cache shard poisoned")
70                    .values()
71                    .filter(|entry| !entry.is_expired(now))
72                    .count()
73            })
74            .sum()
75    }
76}
77
78/// A process-local cache. Cloning shares one store, so it can be registered as
79/// application state and handed to every handler.
80#[derive(Clone)]
81pub struct MemoryStore {
82    inner: Arc<Inner>,
83}
84
85impl Default for MemoryStore {
86    fn default() -> Self {
87        MemoryStore::new()
88    }
89}
90
91impl MemoryStore {
92    pub fn new() -> Self {
93        MemoryStore::with_options("", DEFAULT_SWEEP)
94    }
95
96    pub fn with_prefix(prefix: impl Into<String>) -> Self {
97        MemoryStore::with_options(prefix, DEFAULT_SWEEP)
98    }
99
100    /// Build a store with an explicit sweep interval.
101    ///
102    /// The sweep is spawned only when a Tokio runtime is already running, so
103    /// constructing a cache during boot — before the runtime exists — works and
104    /// simply relies on lazy eviction until the process starts serving.
105    pub fn with_options(prefix: impl Into<String>, sweep_every: Duration) -> Self {
106        let inner = Arc::new(Inner {
107            shards: (0..SHARDS).map(|_| RwLock::new(HashMap::new())).collect(),
108            prefix: prefix.into(),
109        });
110
111        if let Ok(handle) = tokio::runtime::Handle::try_current() {
112            // Weak, so the sweep never keeps a dropped cache alive; the task
113            // ends by itself the first time it wakes up after the last clone
114            // goes away.
115            let weak: Weak<Inner> = Arc::downgrade(&inner);
116            handle.spawn(async move {
117                loop {
118                    tokio::time::sleep(sweep_every).await;
119                    match weak.upgrade() {
120                        Some(inner) => inner.sweep(),
121                        None => break,
122                    }
123                }
124            });
125        }
126
127        MemoryStore { inner }
128    }
129
130    /// Remove expired entries now. The background sweep calls this; tests call
131    /// it directly rather than waiting a minute.
132    pub fn sweep(&self) {
133        self.inner.sweep();
134    }
135
136    /// How many unexpired entries are held. For tests and diagnostics.
137    pub fn len(&self) -> usize {
138        self.inner.live_count()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.len() == 0
143    }
144
145    /// The one place a key is read, so lazy eviction cannot be forgotten.
146    fn read(&self, key: &str) -> Option<Json> {
147        let shard = self.inner.shard(key);
148        let now = Instant::now();
149
150        // Fast path under a read lock: the common case is a live entry.
151        {
152            let map = shard.read().expect("cache shard poisoned");
153            match map.get(key) {
154                None => return None,
155                Some(entry) if !entry.is_expired(now) => return Some(entry.value.clone()),
156                Some(_) => {}
157            }
158        }
159
160        // Expired: take the write lock to evict, re-checking in case another
161        // task replaced the entry with a fresh one in between.
162        let mut map = shard.write().expect("cache shard poisoned");
163        match map.get(key) {
164            Some(entry) if entry.is_expired(now) => {
165                map.remove(key);
166                None
167            }
168            Some(entry) => Some(entry.value.clone()),
169            None => None,
170        }
171    }
172
173    fn write(&self, key: String, value: Json, expires_at: Option<Instant>) {
174        self.inner
175            .shard(&key)
176            .write()
177            .expect("cache shard poisoned")
178            .insert(key, Entry { value, expires_at });
179    }
180
181    fn remove(&self, key: &str) -> bool {
182        let now = Instant::now();
183        self.inner
184            .shard(key)
185            .write()
186            .expect("cache shard poisoned")
187            .remove(key)
188            // Removing an entry that had already expired is not a removal: the
189            // caller should see the same `false` a later read would imply.
190            .is_some_and(|entry| !entry.is_expired(now))
191    }
192
193    /// Increment under one write lock, which is what makes this driver safe for
194    /// the rate limiter: no window exists in which two tasks read the same value.
195    fn bump(&self, key: String, by: i64, ttl: Option<Duration>) -> i64 {
196        let now = Instant::now();
197        let mut map = self.inner.shard(&key).write().expect("cache shard poisoned");
198
199        match map.entry(key) {
200            MapEntry::Occupied(mut slot) if !slot.get().is_expired(now) => {
201                let next = counter_value(Some(&slot.get().value)) + by;
202                slot.get_mut().value = Json::from(next);
203                next
204            }
205            MapEntry::Occupied(mut slot) => {
206                // Expired: this call is creating the entry, so it owns the TTL.
207                slot.insert(Entry { value: Json::from(by), expires_at: ttl.map(|d| now + d) });
208                by
209            }
210            MapEntry::Vacant(slot) => {
211                slot.insert(Entry { value: Json::from(by), expires_at: ttl.map(|d| now + d) });
212                by
213            }
214        }
215    }
216}
217
218impl Cache for MemoryStore {
219    fn driver(&self) -> &'static str {
220        "memory"
221    }
222
223    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Json>>> {
224        Box::pin(async move {
225            let full = prefixed(&self.inner.prefix, key);
226            let found = self.read(&full);
227            record(found.is_some(), "memory", key);
228            Ok(found)
229        })
230    }
231
232    fn put<'a>(&'a self, key: &'a str, value: Json, ttl: Duration) -> BoxFuture<'a, Result<()>> {
233        Box::pin(async move {
234            let full = prefixed(&self.inner.prefix, key);
235            if ttl.is_zero() {
236                self.remove(&full);
237                return Ok(());
238            }
239            self.write(full, value, Some(Instant::now() + ttl));
240            Ok(())
241        })
242    }
243
244    fn forever<'a>(&'a self, key: &'a str, value: Json) -> BoxFuture<'a, Result<()>> {
245        Box::pin(async move {
246            self.write(prefixed(&self.inner.prefix, key), value, None);
247            Ok(())
248        })
249    }
250
251    fn forget<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<bool>> {
252        Box::pin(async move { Ok(self.remove(&prefixed(&self.inner.prefix, key))) })
253    }
254
255    fn flush(&self) -> BoxFuture<'_, Result<()>> {
256        Box::pin(async move {
257            for shard in &self.inner.shards {
258                shard.write().expect("cache shard poisoned").clear();
259            }
260            Ok(())
261        })
262    }
263
264    fn increment<'a>(&'a self, key: &'a str, by: i64) -> BoxFuture<'a, Result<i64>> {
265        Box::pin(async move { Ok(self.bump(prefixed(&self.inner.prefix, key), by, None)) })
266    }
267
268    fn increment_within<'a>(
269        &'a self,
270        key: &'a str,
271        by: i64,
272        ttl: Duration,
273    ) -> BoxFuture<'a, Result<i64>> {
274        Box::pin(async move { Ok(self.bump(prefixed(&self.inner.prefix, key), by, Some(ttl))) })
275    }
276
277    fn ttl<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Duration>>> {
278        Box::pin(async move {
279            let full = prefixed(&self.inner.prefix, key);
280            let now = Instant::now();
281            let map = self.inner.shard(&full).read().expect("cache shard poisoned");
282            Ok(map
283                .get(&full)
284                .filter(|entry| !entry.is_expired(now))
285                .and_then(|entry| entry.expires_at)
286                .map(|at| at.saturating_duration_since(now)))
287        })
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::store::CacheExt;
295    use std::sync::atomic::{AtomicUsize, Ordering};
296
297    #[tokio::test]
298    async fn an_expired_entry_is_evicted_the_moment_it_is_read() {
299        let cache = MemoryStore::new();
300        cache.put("temporary", Json::from("here"), Duration::from_millis(30)).await.unwrap();
301        assert_eq!(cache.len(), 1);
302
303        tokio::time::sleep(Duration::from_millis(60)).await;
304
305        assert_eq!(cache.get("temporary").await.unwrap(), None);
306        assert_eq!(cache.len(), 0, "the read should have dropped the entry, not just hidden it");
307    }
308
309    #[tokio::test]
310    async fn a_sweep_removes_entries_nobody_ever_reads_again() {
311        let cache = MemoryStore::new();
312        for i in 0..50 {
313            cache
314                .put(&format!("key-{i}"), Json::from(i), Duration::from_millis(20))
315                .await
316                .unwrap();
317        }
318
319        tokio::time::sleep(Duration::from_millis(50)).await;
320        cache.sweep();
321
322        // Nothing read them, so only the sweep could have freed the memory.
323        let raw: usize = cache.inner.shards.iter().map(|s| s.read().unwrap().len()).sum();
324        assert_eq!(raw, 0);
325    }
326
327    #[tokio::test]
328    async fn the_background_sweep_stops_when_the_last_clone_is_dropped() {
329        let cache = MemoryStore::with_options("", Duration::from_millis(5));
330        let weak = Arc::downgrade(&cache.inner);
331        drop(cache);
332
333        tokio::time::sleep(Duration::from_millis(30)).await;
334        assert_eq!(weak.strong_count(), 0, "the sweep task must not keep the cache alive");
335    }
336
337    #[tokio::test]
338    async fn a_prefix_keeps_two_stores_from_colliding() {
339        let alpha = MemoryStore::with_prefix("alpha:");
340        let beta = MemoryStore::with_prefix("beta:");
341
342        alpha.forever("shared", Json::from(1)).await.unwrap();
343        beta.forever("shared", Json::from(2)).await.unwrap();
344
345        assert_eq!(alpha.get("shared").await.unwrap(), Some(Json::from(1)));
346        assert_eq!(beta.get("shared").await.unwrap(), Some(Json::from(2)));
347    }
348
349    #[tokio::test]
350    async fn concurrent_increments_never_lose_a_count() {
351        let cache = MemoryStore::new();
352        let cache = Arc::new(cache);
353
354        let mut tasks = Vec::new();
355        for _ in 0..32 {
356            let cache = Arc::clone(&cache);
357            tasks.push(tokio::spawn(async move {
358                for _ in 0..100 {
359                    cache.increment("hits", 1).await.unwrap();
360                }
361            }));
362        }
363        for task in tasks {
364            task.await.unwrap();
365        }
366
367        assert_eq!(cache.get("hits").await.unwrap(), Some(Json::from(3200)));
368    }
369
370    #[tokio::test]
371    async fn hammering_mixed_operations_from_many_tasks_stays_consistent() {
372        let cache = Arc::new(MemoryStore::new());
373        let computed = Arc::new(AtomicUsize::new(0));
374
375        let mut tasks = Vec::new();
376        for task_id in 0..24 {
377            let cache = Arc::clone(&cache);
378            let computed = Arc::clone(&computed);
379            tasks.push(tokio::spawn(async move {
380                for round in 0..80 {
381                    let key = format!("k-{}", (task_id * round) % 40);
382                    match round % 4 {
383                        0 => {
384                            cache.put(&key, Json::from(round), Duration::from_millis(5)).await.unwrap();
385                        }
386                        1 => {
387                            let _ = cache.get(&key).await.unwrap();
388                        }
389                        2 => {
390                            let _ = cache.forget(&key).await.unwrap();
391                        }
392                        _ => {
393                            let counter = Arc::clone(&computed);
394                            let value = cache
395                                .remember(&key, Duration::from_millis(5), move || async move {
396                                    counter.fetch_add(1, Ordering::SeqCst);
397                                    Ok(Json::from("computed"))
398                                })
399                                .await
400                                .unwrap();
401                            assert!(!value.is_null());
402                        }
403                    }
404                }
405            }));
406        }
407
408        for task in tasks {
409            task.await.unwrap();
410        }
411
412        // Everything expires within milliseconds, so after a sweep the store
413        // must come back to empty rather than growing without bound.
414        tokio::time::sleep(Duration::from_millis(30)).await;
415        cache.sweep();
416        assert_eq!(cache.len(), 0);
417    }
418
419    #[tokio::test]
420    async fn increment_within_starts_the_clock_only_on_the_first_call() {
421        let cache = MemoryStore::new();
422
423        assert_eq!(cache.increment_within("window", 1, Duration::from_millis(120)).await.unwrap(), 1);
424        tokio::time::sleep(Duration::from_millis(60)).await;
425        assert_eq!(cache.increment_within("window", 1, Duration::from_millis(120)).await.unwrap(), 2);
426
427        // If the second call had reset the TTL the counter would still be here.
428        tokio::time::sleep(Duration::from_millis(90)).await;
429        assert_eq!(cache.get("window").await.unwrap(), None);
430    }
431
432    #[tokio::test]
433    async fn ttl_reports_the_remaining_life_of_a_key() {
434        let cache = MemoryStore::new();
435        cache.forever("immortal", Json::from(1)).await.unwrap();
436        cache.put("mortal", Json::from(1), Duration::from_secs(30)).await.unwrap();
437
438        assert_eq!(cache.ttl("immortal").await.unwrap(), None);
439        assert_eq!(cache.ttl("nothing").await.unwrap(), None);
440        let remaining = cache.ttl("mortal").await.unwrap().expect("a mortal key has a ttl");
441        assert!(remaining <= Duration::from_secs(30) && remaining > Duration::from_secs(25));
442    }
443
444    #[tokio::test]
445    async fn putting_with_a_zero_ttl_forgets_instead_of_storing() {
446        let cache = MemoryStore::new();
447        cache.forever("doomed", Json::from(1)).await.unwrap();
448        cache.put("doomed", Json::from(2), Duration::ZERO).await.unwrap();
449
450        assert!(!cache.has("doomed").await.unwrap());
451    }
452}