Skip to main content

mytheclipse_cache/
memory.rs

1//! A simple, dependency-free in-process cache (L1, `l1-memory`).
2//!
3//! Backed by a `HashMap<String, (Vec<u8>, Instant)>` guarded by a `Mutex`.
4//! Entries are lazily expired on access by comparing against `Instant`; a
5//! monotonic clock keeps TTLs robust against wall-clock discontinuities.
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use async_trait::async_trait;
12
13use crate::traits::{Cache, CacheError};
14
15/// A wrapping entry: `None` expiry means the value never expires.
16type Entry = (Vec<u8>, Option<Instant>);
17
18/// An in-process [`Cache`] for L1 caching.
19///
20/// Default instance is **unbounded** — it grows until the process runs out of
21/// memory. For memory-constrained workloads, use [`MemoryCache::with_max_entries`]
22/// to install a simple LRU-style cap: when the cap is exceeded, the oldest
23/// (least-recently-inserted) entry is evicted.
24#[derive(Debug, Clone)]
25pub struct MemoryCache {
26    inner: Arc<Mutex<HashMap<String, Entry>>>,
27    /// When `Some(n)`, the cache refuses more than `n` live entries and evicts
28    /// the oldest on overflow. `None` = unbounded (legacy default).
29    max_entries: Option<usize>,
30    /// Insertion order, for eviction when `max_entries` is set.
31    order: Arc<Mutex<VecDeque<String>>>,
32}
33
34impl Default for MemoryCache {
35    fn default() -> Self {
36        Self {
37            inner: Arc::new(Mutex::new(HashMap::new())),
38            max_entries: None,
39            order: Arc::new(Mutex::new(VecDeque::new())),
40        }
41    }
42}
43
44impl MemoryCache {
45    /// Builds an empty in-memory cache (unbounded by default).
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Pre-allocates space for `capacity` entries to reduce reallocation.
51    pub fn with_capacity(self, capacity: usize) -> Self {
52        self.inner.lock().unwrap().reserve(capacity);
53        self
54    }
55
56    /// Installs a bounded LRU-style cap. When the cache exceeds `max`, the
57    /// oldest (least-recently-inserted) entry is evicted on each `set`.
58    ///
59    /// This is the recommended constructor for production L1 caches: a
60    /// [`MemoryCache::new()`] (unbounded) left unmanaged can grow without bound
61    /// and exhaust process memory.
62    pub fn with_max_entries(mut self, max: usize) -> Self {
63        assert!(max > 0, "mytheclipse-cache: with_max_entries must be > 0");
64        self.max_entries = Some(max);
65        self
66    }
67
68    /// The configured max entries, if any.
69    pub fn max_entries(&self) -> Option<usize> {
70        self.max_entries
71    }
72}
73
74#[async_trait]
75impl Cache for MemoryCache {
76    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
77        let mut map = self.inner.lock().unwrap();
78        match map.get(key) {
79            Some((value, Some(expires))) if *expires <= Instant::now() => {
80                map.remove(key);
81                self.remove_order(key);
82                Ok(None)
83            }
84            Some((value, _)) => Ok(Some(value.clone())),
85            None => Ok(None),
86        }
87    }
88
89    async fn set(
90        &self,
91        key: &str,
92        value: Vec<u8>,
93        ttl: Option<Duration>,
94    ) -> Result<(), CacheError> {
95        let expires = ttl.map(|d| Instant::now() + d);
96        let mut map = self.inner.lock().unwrap();
97        let is_new = !map.contains_key(key);
98        map.insert(key.to_string(), (value, expires));
99        if is_new {
100            let mut order = self.order.lock().unwrap();
101            order.push_back(key.to_string());
102            if let Some(cap) = self.max_entries {
103                while order.len() > cap {
104                    if let Some(oldest) = order.pop_front() {
105                        map.remove(&oldest);
106                    }
107                }
108            }
109        }
110        Ok(())
111    }
112
113    async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
114        self.inner.lock().unwrap().remove(key);
115        self.remove_order(key);
116        Ok(())
117    }
118
119    async fn clear(&self) -> Result<(), CacheError> {
120        self.inner.lock().unwrap().clear();
121        self.order.lock().unwrap().clear();
122        Ok(())
123    }
124}
125
126impl MemoryCache {
127    /// Removes `key` from the insertion-order deque (if present).
128    fn remove_order(&self, key: &str) {
129        let mut order = self.order.lock().unwrap();
130        order.retain(|k| k != key);
131    }
132}
133
134/// A typed view over a byte cache using `serde`-compatible (JSON) encoding.
135///
136/// Only enabled with the `cache-aside` feature, which pulls in `serde`.
137#[cfg(feature = "cache-aside")]
138pub mod typed {
139    use serde::{de::DeserializeOwned, Serialize};
140
141    use super::*;
142
143    /// Wraps a [`Cache`] with JSON-based typed get/set.
144    #[derive(Clone)]
145    pub struct TypedCache<C> {
146        inner: C,
147    }
148
149    impl<C: Cache> TypedCache<C> {
150        /// Wraps `inner`.
151        pub fn new(inner: C) -> Self {
152            Self { inner }
153        }
154
155        /// Fetches and deserializes a value.
156        pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CacheError> {
157            match self.inner.get(key).await? {
158                Some(bytes) => serde_json::from_slice(&bytes)
159                    .map(Some)
160                    .map_err(|e| CacheError::Serialization(e.to_string())),
161                None => Ok(None),
162            }
163        }
164
165        /// Serializes and stores a value.
166        pub async fn set<T: Serialize>(
167            &self,
168            key: &str,
169            value: &T,
170            ttl: Option<Duration>,
171        ) -> Result<(), CacheError> {
172            let bytes =
173                serde_json::to_vec(value).map_err(|e| CacheError::Serialization(e.to_string()))?;
174            self.inner.set(key, bytes, ttl).await
175        }
176
177        /// Returns the underlying byte cache.
178        pub fn into_inner(self) -> C {
179            self.inner
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[tokio::test]
189    async fn set_get_roundtrip() {
190        let c = MemoryCache::new();
191        c.set("k", b"v".to_vec(), None).await.unwrap();
192        assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
193        assert_eq!(c.get("missing").await.unwrap(), None);
194    }
195
196    #[tokio::test]
197    async fn ttl_expires_entry() {
198        let c = MemoryCache::new();
199        c.set("k", b"v".to_vec(), Some(Duration::from_millis(30)))
200            .await
201            .unwrap();
202        assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
203        tokio::time::sleep(Duration::from_millis(60)).await;
204        assert_eq!(c.get("k").await.unwrap(), None);
205    }
206
207    #[tokio::test]
208    async fn invalidate_and_clear() {
209        let c = MemoryCache::new();
210        c.set("a", b"1".to_vec(), None).await.unwrap();
211        c.set("b", b"2".to_vec(), None).await.unwrap();
212        c.invalidate("a").await.unwrap();
213        assert_eq!(c.get("a").await.unwrap(), None);
214        assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
215        c.clear().await.unwrap();
216        assert_eq!(c.get("b").await.unwrap(), None);
217    }
218
219    /// Asserts that an unbounded `MemoryCache::with_max_entries(0)` panics,
220    /// preventing a no-op cache that accepts zero entries.
221    #[test]
222    #[should_panic(expected = "must be > 0")]
223    fn zero_max_panics() {
224        let _ = MemoryCache::new().with_max_entries(0);
225    }
226
227    #[tokio::test]
228    async fn bounded_cache_evicts_oldest() {
229        let c = MemoryCache::new().with_max_entries(2);
230        c.set("a", b"1".to_vec(), None).await.unwrap();
231        c.set("b", b"2".to_vec(), None).await.unwrap();
232        c.set("c", b"3".to_vec(), None).await.unwrap();
233        // "a" (oldest) should have been evicted.
234        assert_eq!(c.get("a").await.unwrap(), None);
235        assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
236        assert_eq!(c.get("c").await.unwrap(), Some(b"3".to_vec()));
237    }
238
239    #[cfg(feature = "cache-aside")]
240    #[tokio::test]
241    async fn typed_cache_roundtrip() {
242        use typed::TypedCache;
243        #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
244        struct User {
245            id: u64,
246            name: String,
247        }
248        let typed = TypedCache::new(MemoryCache::new());
249        typed
250            .set(
251                "u",
252                &User {
253                    id: 1,
254                    name: "alice".into(),
255                },
256                None,
257            )
258            .await
259            .unwrap();
260        let got: User = typed.get("u").await.unwrap().unwrap();
261        assert_eq!(
262            got,
263            User {
264                id: 1,
265                name: "alice".into()
266            }
267        );
268    }
269}