Skip to main content

rskit_cache/adapters/memory/
mod.rs

1//! In-memory cache adapter.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use parking_lot::Mutex;
8use rskit_errors::{AppError, AppResult, ErrorCode};
9
10use crate::config::CacheConfig;
11use crate::registry::{CacheRegistry, CacheStore, CacheStoreFactory};
12
13#[derive(Clone)]
14struct Entry {
15    value: String,
16    expires_at: Option<Instant>,
17}
18
19impl Entry {
20    fn is_expired(&self, now: Instant) -> bool {
21        self.expires_at.is_some_and(|expires_at| expires_at <= now)
22    }
23}
24
25/// Lean in-process cache store adapter for local development and tests.
26pub struct MemoryCache {
27    prefix: Option<String>,
28    max_entries: Option<usize>,
29    entries: Mutex<BTreeMap<String, Entry>>,
30    clock: Arc<dyn Fn() -> Instant + Send + Sync>,
31}
32
33impl MemoryCache {
34    /// Create an empty in-memory cache.
35    #[must_use]
36    pub fn new(prefix: Option<String>, max_entries: Option<usize>) -> Self {
37        Self::new_with_clock(prefix, max_entries, Instant::now)
38    }
39
40    /// Create an in-memory cache with an injected clock.
41    ///
42    /// This is primarily useful for deterministic tests and simulations that
43    /// need to advance cache expiry without sleeping.
44    #[must_use]
45    pub fn new_with_clock(
46        prefix: Option<String>,
47        max_entries: Option<usize>,
48        clock: impl Fn() -> Instant + Send + Sync + 'static,
49    ) -> Self {
50        Self {
51            prefix,
52            max_entries: max_entries.filter(|entries| *entries > 0),
53            entries: Mutex::new(BTreeMap::new()),
54            clock: Arc::new(clock),
55        }
56    }
57
58    fn now(&self) -> Instant {
59        (self.clock)()
60    }
61
62    fn key(&self, key: &str) -> String {
63        self.prefix
64            .as_ref()
65            .map_or_else(|| key.to_owned(), |prefix| format!("{prefix}:{key}"))
66    }
67
68    fn prune_expired(entries: &mut BTreeMap<String, Entry>, now: Instant) {
69        entries.retain(|_, entry| !entry.is_expired(now));
70    }
71
72    fn expires_at(now: Instant, ttl: Option<Duration>) -> AppResult<Option<Instant>> {
73        let Some(duration) = ttl else {
74            return Ok(None);
75        };
76        now.checked_add(duration).map(Some).ok_or_else(|| {
77            AppError::new(
78                ErrorCode::InvalidInput,
79                "cache TTL is too large to represent safely",
80            )
81        })
82    }
83}
84
85#[async_trait::async_trait]
86impl CacheStore for MemoryCache {
87    async fn get(&self, key: &str) -> AppResult<Option<String>> {
88        let mut entries = self.entries.lock();
89        Self::prune_expired(&mut entries, self.now());
90        let full_key = self.key(key);
91        Ok(entries.get(&full_key).map(|entry| entry.value.clone()))
92    }
93
94    async fn set(&self, key: &str, val: &str, ttl: Option<Duration>) -> AppResult<()> {
95        let mut entries = self.entries.lock();
96        let now = self.now();
97        Self::prune_expired(&mut entries, now);
98        let key = self.key(key);
99
100        if ttl.is_some_and(|ttl| ttl.is_zero()) {
101            return Err(AppError::new(
102                ErrorCode::InvalidInput,
103                "cache TTL must be greater than zero",
104            ));
105        }
106        let expires_at = Self::expires_at(now, ttl)?;
107
108        if let Some(max_entries) = self.max_entries
109            && entries.len() >= max_entries
110            && !entries.contains_key(&key)
111            && let Some(first_key) = entries.keys().next().cloned()
112        {
113            entries.remove(&first_key);
114        }
115
116        entries.insert(
117            key,
118            Entry {
119                value: val.to_owned(),
120                expires_at,
121            },
122        );
123        Ok(())
124    }
125
126    async fn delete(&self, key: &str) -> AppResult<bool> {
127        let mut entries = self.entries.lock();
128        Self::prune_expired(&mut entries, self.now());
129        let key = self.key(key);
130        Ok(entries.remove(&key).is_some())
131    }
132
133    async fn exists(&self, key: &str) -> AppResult<bool> {
134        self.get(key).await.map(|value| value.is_some())
135    }
136}
137
138impl Default for MemoryCache {
139    fn default() -> Self {
140        Self::new(None, None)
141    }
142}
143
144struct MemoryFactory;
145
146#[async_trait::async_trait]
147impl CacheStoreFactory for MemoryFactory {
148    async fn create(&self, config: &CacheConfig) -> AppResult<Arc<dyn CacheStore>> {
149        Ok(Arc::new(MemoryCache::new(
150            config.key_prefix.clone(),
151            config.memory.max_entries,
152        )))
153    }
154}
155
156/// Explicitly register the in-memory adapter.
157pub fn register_memory(registry: &mut CacheRegistry) -> AppResult<()> {
158    registry.register("memory", Arc::new(MemoryFactory))
159}
160
161#[cfg(test)]
162mod tests {
163    use std::sync::Arc;
164
165    use parking_lot::Mutex;
166
167    use super::*;
168
169    #[tokio::test]
170    async fn set_rejects_unrepresentable_ttl() {
171        let cache = MemoryCache::default();
172
173        let err = cache
174            .set("too-long", "value", Some(Duration::MAX))
175            .await
176            .expect_err("TTL overflow must be rejected");
177
178        assert_eq!(err.code(), ErrorCode::InvalidInput);
179        assert!(err.to_string().contains("too large"));
180    }
181
182    #[tokio::test]
183    async fn get_prunes_unrelated_expired_entries() {
184        let now = Arc::new(Mutex::new(Instant::now()));
185        let clock = Arc::clone(&now);
186        let cache = MemoryCache::new_with_clock(None, None, move || *clock.lock());
187        cache
188            .set("expired", "value", Some(Duration::from_secs(1)))
189            .await
190            .unwrap();
191        cache.set("live", "value", None).await.unwrap();
192        *now.lock() += Duration::from_secs(2);
193
194        assert_eq!(cache.get("live").await.unwrap().as_deref(), Some("value"));
195        assert_eq!(cache.entries.lock().len(), 1);
196    }
197
198    #[tokio::test]
199    async fn delete_prunes_unrelated_expired_entries() {
200        let now = Arc::new(Mutex::new(Instant::now()));
201        let clock = Arc::clone(&now);
202        let cache = MemoryCache::new_with_clock(None, None, move || *clock.lock());
203        cache
204            .set("expired", "value", Some(Duration::from_secs(1)))
205            .await
206            .unwrap();
207        cache.set("live", "value", None).await.unwrap();
208        *now.lock() += Duration::from_secs(2);
209
210        assert!(!cache.delete("missing").await.unwrap());
211        assert_eq!(cache.entries.lock().len(), 1);
212    }
213}