Skip to main content

armature_cache/
manager.rs

1//! High-level cache manager with convenience methods.
2
3use crate::error::CacheResult;
4use crate::traits::CacheStore;
5use serde::{Serialize, de::DeserializeOwned};
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex as StdMutex};
8use std::time::Duration;
9use tokio::sync::Mutex as AsyncMutex;
10
11/// High-level cache manager with type-safe operations.
12pub struct CacheManager<S: CacheStore> {
13    store: Arc<S>,
14    /// Per-key single-flight locks for `get_or_set`.
15    ///
16    /// Coalesces concurrent misses on the same key so only one `factory()`
17    /// (typically a DB hit) runs; the rest await it and then read the
18    /// now-populated cache entry. The outer `std::sync::Mutex` only guards the
19    /// short map lookup/insert; the actual loader runs while holding the
20    /// per-key `tokio::sync::Mutex`.
21    inflight: StdMutex<HashMap<String, Arc<AsyncMutex<()>>>>,
22}
23
24impl<S: CacheStore> CacheManager<S> {
25    /// Create a new cache manager.
26    pub fn new(store: S) -> Self {
27        Self {
28            store: Arc::new(store),
29            inflight: StdMutex::new(HashMap::new()),
30        }
31    }
32
33    /// Get a typed value from the cache.
34    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> CacheResult<Option<T>> {
35        if let Some(json) = self.store.get_json(key).await? {
36            let value: T = serde_json::from_str(&json)
37                .map_err(|e| crate::error::CacheError::Deserialization(e.to_string()))?;
38            Ok(Some(value))
39        } else {
40            Ok(None)
41        }
42    }
43
44    /// Set a typed value in the cache.
45    pub async fn set<T: Serialize>(
46        &self,
47        key: &str,
48        value: &T,
49        ttl: Option<Duration>,
50    ) -> CacheResult<()> {
51        let json = serde_json::to_string(value)
52            .map_err(|e| crate::error::CacheError::Serialization(e.to_string()))?;
53        self.store.set_json(key, json, ttl).await
54    }
55
56    /// Get or set a value using a factory function.
57    ///
58    /// If the key exists, returns the cached value.
59    /// If not, calls the factory function, caches the result, and returns it.
60    ///
61    /// # Single-flight
62    ///
63    /// Concurrent misses on the same key are coalesced: only one caller runs
64    /// `factory()` while the others wait and then read the value it cached.
65    /// This prevents a cache-miss stampede (many simultaneous DB loads) for hot
66    /// keys. The returned value is identical to the non-coalesced behavior.
67    pub async fn get_or_set<T, F, Fut>(
68        &self,
69        key: &str,
70        ttl: Option<Duration>,
71        factory: F,
72    ) -> CacheResult<T>
73    where
74        T: Serialize + DeserializeOwned,
75        F: FnOnce() -> Fut,
76        Fut: std::future::Future<Output = CacheResult<T>>,
77    {
78        // Fast path: a cache hit avoids taking the single-flight lock entirely.
79        if let Some(value) = self.get(key).await? {
80            return Ok(value);
81        }
82
83        // Slow path: acquire a per-key lock so only one loader runs.
84        let key_lock = {
85            let mut map = self.inflight.lock().unwrap();
86            map.entry(key.to_string())
87                .or_insert_with(|| Arc::new(AsyncMutex::new(())))
88                .clone()
89        };
90        let guard = key_lock.lock().await;
91
92        // Double-check: another loader may have populated the cache while we
93        // were waiting for the lock.
94        let result = if let Some(value) = self.get(key).await? {
95            Ok(value)
96        } else {
97            match factory().await {
98                Ok(value) => {
99                    self.set(key, &value, ttl).await?;
100                    Ok(value)
101                }
102                Err(e) => Err(e),
103            }
104        };
105        drop(guard);
106
107        // Clean up the map entry once no other caller is still referencing this
108        // key's lock, to keep the map from growing unbounded across many keys.
109        {
110            let mut map = self.inflight.lock().unwrap();
111            if let Some(existing) = map.get(key)
112                && Arc::ptr_eq(existing, &key_lock)
113                && Arc::strong_count(&key_lock) == 2
114            {
115                // strong_count == 2 => only the map and our local `key_lock`
116                // hold a reference; no other task is waiting.
117                map.remove(key);
118            }
119        }
120
121        result
122    }
123
124    /// Delete a key from the cache.
125    pub async fn delete(&self, key: &str) -> CacheResult<()> {
126        self.store.delete(key).await
127    }
128
129    /// Check if a key exists.
130    pub async fn exists(&self, key: &str) -> CacheResult<bool> {
131        self.store.exists(key).await
132    }
133
134    /// Clear all keys.
135    pub async fn clear(&self) -> CacheResult<()> {
136        self.store.clear().await
137    }
138
139    /// Get the TTL of a key.
140    pub async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
141        self.store.ttl(key).await
142    }
143
144    /// Set the expiration of a key.
145    pub async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
146        self.store.expire(key, ttl).await
147    }
148
149    /// Create a namespaced cache manager.
150    pub fn namespace(&self, prefix: &str) -> NamespacedCache<S> {
151        NamespacedCache {
152            store: self.store.clone(),
153            prefix: prefix.to_string(),
154        }
155    }
156}
157
158/// Namespaced cache manager that automatically prefixes all keys.
159pub struct NamespacedCache<S: CacheStore> {
160    store: Arc<S>,
161    prefix: String,
162}
163
164impl<S: CacheStore> NamespacedCache<S> {
165    fn build_key(&self, key: &str) -> String {
166        format!("{}:{}", self.prefix, key)
167    }
168
169    /// Get a typed value from the cache.
170    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> CacheResult<Option<T>> {
171        let key = self.build_key(key);
172        if let Some(json) = self.store.get_json(&key).await? {
173            let value: T = serde_json::from_str(&json)
174                .map_err(|e| crate::error::CacheError::Deserialization(e.to_string()))?;
175            Ok(Some(value))
176        } else {
177            Ok(None)
178        }
179    }
180
181    /// Set a typed value in the cache.
182    pub async fn set<T: Serialize>(
183        &self,
184        key: &str,
185        value: &T,
186        ttl: Option<Duration>,
187    ) -> CacheResult<()> {
188        let key = self.build_key(key);
189        let json = serde_json::to_string(value)
190            .map_err(|e| crate::error::CacheError::Serialization(e.to_string()))?;
191        self.store.set_json(&key, json, ttl).await
192    }
193
194    /// Delete a key from the cache.
195    pub async fn delete(&self, key: &str) -> CacheResult<()> {
196        let key = self.build_key(key);
197        self.store.delete(&key).await
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::tiered::InMemoryCache;
205    use std::sync::atomic::{AtomicUsize, Ordering};
206
207    #[tokio::test]
208    async fn test_get_or_set_single_flight_coalesces_factory() {
209        let manager = CacheManager::new(InMemoryCache::new());
210        let calls = Arc::new(AtomicUsize::new(0));
211
212        // Launch many concurrent get_or_set on the SAME key. The factory yields
213        // once so the concurrent futures interleave and all reach the miss path
214        // before the first loader finishes; single-flight must ensure the
215        // factory runs exactly once.
216        let make_fut = |calls: Arc<AtomicUsize>| {
217            let manager = &manager;
218            async move {
219                manager
220                    .get_or_set::<i64, _, _>("hot-key", None, || {
221                        let calls = calls.clone();
222                        async move {
223                            tokio::task::yield_now().await;
224                            calls.fetch_add(1, Ordering::SeqCst);
225                            Ok(42)
226                        }
227                    })
228                    .await
229                    .unwrap()
230            }
231        };
232
233        let futs = (0..16).map(|_| make_fut(calls.clone()));
234        let results = futures::future::join_all(futs).await;
235
236        assert!(results.iter().all(|&v| v == 42));
237        assert_eq!(
238            calls.load(Ordering::SeqCst),
239            1,
240            "factory should run exactly once under single-flight"
241        );
242    }
243
244    #[tokio::test]
245    async fn test_get_or_set_returns_cached_value_without_calling_factory() {
246        let manager = CacheManager::new(InMemoryCache::new());
247        manager.set("k", &7_i64, None).await.unwrap();
248
249        let calls = Arc::new(AtomicUsize::new(0));
250        let calls_c = calls.clone();
251        let value: i64 = manager
252            .get_or_set("k", None, || {
253                let calls = calls_c.clone();
254                async move {
255                    calls.fetch_add(1, Ordering::SeqCst);
256                    Ok(99)
257                }
258            })
259            .await
260            .unwrap();
261
262        assert_eq!(value, 7);
263        assert_eq!(calls.load(Ordering::SeqCst), 0);
264    }
265
266    #[tokio::test]
267    async fn test_get_or_set_inflight_map_cleaned_up() {
268        let manager = CacheManager::new(InMemoryCache::new());
269        let _: i64 = manager
270            .get_or_set("k", None, || async { Ok(1) })
271            .await
272            .unwrap();
273
274        // After completion the per-key lock entry should be removed.
275        assert!(manager.inflight.lock().unwrap().is_empty());
276    }
277
278    #[test]
279    fn test_namespace_build_key() {
280        struct MockStore;
281
282        #[async_trait::async_trait]
283        impl CacheStore for MockStore {
284            async fn get_json(&self, _key: &str) -> CacheResult<Option<String>> {
285                Ok(None)
286            }
287            async fn set_json(
288                &self,
289                _key: &str,
290                _value: String,
291                _ttl: Option<Duration>,
292            ) -> CacheResult<()> {
293                Ok(())
294            }
295            async fn delete(&self, _key: &str) -> CacheResult<()> {
296                Ok(())
297            }
298            async fn exists(&self, _key: &str) -> CacheResult<bool> {
299                Ok(false)
300            }
301            async fn clear(&self) -> CacheResult<()> {
302                Ok(())
303            }
304            async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
305                Ok(None)
306            }
307            async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
308                Ok(())
309            }
310            async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
311                Ok(0)
312            }
313            async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
314                Ok(0)
315            }
316        }
317
318        let namespaced = NamespacedCache {
319            store: Arc::new(MockStore),
320            prefix: "users".to_string(),
321        };
322
323        assert_eq!(namespaced.build_key("123"), "users:123");
324    }
325
326    #[test]
327    fn test_namespace_build_key_empty() {
328        struct MockStore;
329
330        #[async_trait::async_trait]
331        impl CacheStore for MockStore {
332            async fn get_json(&self, _key: &str) -> CacheResult<Option<String>> {
333                Ok(None)
334            }
335            async fn set_json(
336                &self,
337                _key: &str,
338                _value: String,
339                _ttl: Option<Duration>,
340            ) -> CacheResult<()> {
341                Ok(())
342            }
343            async fn delete(&self, _key: &str) -> CacheResult<()> {
344                Ok(())
345            }
346            async fn exists(&self, _key: &str) -> CacheResult<bool> {
347                Ok(false)
348            }
349            async fn clear(&self) -> CacheResult<()> {
350                Ok(())
351            }
352            async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
353                Ok(None)
354            }
355            async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
356                Ok(())
357            }
358            async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
359                Ok(0)
360            }
361            async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
362                Ok(0)
363            }
364        }
365
366        let namespaced = NamespacedCache {
367            store: Arc::new(MockStore),
368            prefix: "app".to_string(),
369        };
370
371        assert_eq!(namespaced.build_key(""), "app:");
372    }
373
374    #[test]
375    fn test_namespace_build_key_with_colons() {
376        struct MockStore;
377
378        #[async_trait::async_trait]
379        impl CacheStore for MockStore {
380            async fn get_json(&self, _key: &str) -> CacheResult<Option<String>> {
381                Ok(None)
382            }
383            async fn set_json(
384                &self,
385                _key: &str,
386                _value: String,
387                _ttl: Option<Duration>,
388            ) -> CacheResult<()> {
389                Ok(())
390            }
391            async fn delete(&self, _key: &str) -> CacheResult<()> {
392                Ok(())
393            }
394            async fn exists(&self, _key: &str) -> CacheResult<bool> {
395                Ok(false)
396            }
397            async fn clear(&self) -> CacheResult<()> {
398                Ok(())
399            }
400            async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
401                Ok(None)
402            }
403            async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
404                Ok(())
405            }
406            async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
407                Ok(0)
408            }
409            async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
410                Ok(0)
411            }
412        }
413
414        let namespaced = NamespacedCache {
415            store: Arc::new(MockStore),
416            prefix: "app".to_string(),
417        };
418
419        assert_eq!(namespaced.build_key("user:123"), "app:user:123");
420    }
421
422    #[test]
423    fn test_namespace_multiple_prefixes() {
424        struct MockStore;
425
426        #[async_trait::async_trait]
427        impl CacheStore for MockStore {
428            async fn get_json(&self, _key: &str) -> CacheResult<Option<String>> {
429                Ok(None)
430            }
431            async fn set_json(
432                &self,
433                _key: &str,
434                _value: String,
435                _ttl: Option<Duration>,
436            ) -> CacheResult<()> {
437                Ok(())
438            }
439            async fn delete(&self, _key: &str) -> CacheResult<()> {
440                Ok(())
441            }
442            async fn exists(&self, _key: &str) -> CacheResult<bool> {
443                Ok(false)
444            }
445            async fn clear(&self) -> CacheResult<()> {
446                Ok(())
447            }
448            async fn ttl(&self, _key: &str) -> CacheResult<Option<Duration>> {
449                Ok(None)
450            }
451            async fn expire(&self, _key: &str, _ttl: Duration) -> CacheResult<()> {
452                Ok(())
453            }
454            async fn increment(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
455                Ok(0)
456            }
457            async fn decrement(&self, _key: &str, _delta: i64) -> CacheResult<i64> {
458                Ok(0)
459            }
460        }
461
462        let ns1 = NamespacedCache {
463            store: Arc::new(MockStore),
464            prefix: "app1".to_string(),
465        };
466
467        let ns2 = NamespacedCache {
468            store: Arc::new(MockStore),
469            prefix: "app2".to_string(),
470        };
471
472        assert_eq!(ns1.build_key("key"), "app1:key");
473        assert_eq!(ns2.build_key("key"), "app2:key");
474        assert_ne!(ns1.build_key("key"), ns2.build_key("key"));
475    }
476}