Skip to main content

rustio_core/
cache.rs

1//! In-process LRU cache for read queries.
2//!
3//! Keys are namespaced by table: `"posts:list:limit=20,offset=0"`.
4//! Invalidation happens on any write to a given table — we simply
5//! blow away every key whose prefix matches `"tablename:"`.
6//!
7//! The LRU is behind a `Mutex` because `lru::LruCache::get` needs
8//! `&mut self` (it updates recency). We keep the cache small and
9//! the critical section tiny — a few µs per lookup in the worst case.
10
11use std::num::NonZeroUsize;
12use std::sync::Mutex;
13
14use bytes::Bytes;
15
16pub struct QueryCache {
17    inner: Mutex<lru::LruCache<String, Bytes>>,
18}
19
20impl QueryCache {
21    pub fn new(capacity: usize) -> Self {
22        let cap = NonZeroUsize::new(capacity.max(1)).expect("capacity > 0");
23        Self {
24            inner: Mutex::new(lru::LruCache::new(cap)),
25        }
26    }
27
28    pub fn get(&self, key: &str) -> Option<Bytes> {
29        self.inner.lock().ok()?.get(key).cloned()
30    }
31
32    pub fn put(&self, key: String, value: Bytes) {
33        if let Ok(mut lru) = self.inner.lock() {
34            lru.put(key, value);
35        }
36    }
37
38    /// Drop every entry whose key starts with `prefix:`. Called on
39    /// writes to keep stale reads out.
40    pub fn invalidate_prefix(&self, prefix: &str) {
41        let prefix = format!("{prefix}:");
42        let mut lru = match self.inner.lock() {
43            Ok(g) => g,
44            Err(_) => return,
45        };
46        let stale: Vec<String> = lru
47            .iter()
48            .filter(|(k, _)| k.starts_with(&prefix))
49            .map(|(k, _)| k.clone())
50            .collect();
51        for k in stale {
52            lru.pop(&k);
53        }
54    }
55
56    pub fn clear(&self) {
57        if let Ok(mut lru) = self.inner.lock() {
58            lru.clear();
59        }
60    }
61
62    pub fn len(&self) -> usize {
63        self.inner.lock().map(|l| l.len()).unwrap_or(0)
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.len() == 0
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn put_then_get() {
77        let c = QueryCache::new(4);
78        c.put("posts:list".into(), Bytes::from_static(b"hello"));
79        assert_eq!(c.get("posts:list"), Some(Bytes::from_static(b"hello")));
80    }
81
82    #[test]
83    fn invalidate_prefix_only_hits_table() {
84        let c = QueryCache::new(16);
85        c.put("posts:list".into(), Bytes::from_static(b"a"));
86        c.put("posts:count".into(), Bytes::from_static(b"b"));
87        c.put("users:list".into(), Bytes::from_static(b"c"));
88        c.invalidate_prefix("posts");
89        assert!(c.get("posts:list").is_none());
90        assert!(c.get("posts:count").is_none());
91        assert_eq!(c.get("users:list"), Some(Bytes::from_static(b"c")));
92    }
93}