Skip to main content

crates_docs/cache/
memory.rs

1//! Memory cache implementation
2//!
3//! Memory cache using `moka::sync::Cache` with `TinyLFU` eviction policy.
4//! This provides better performance and hit rate than simple LRU.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9/// Cache entry with optional TTL
10#[derive(Clone, Debug)]
11struct CacheEntry {
12    value: Arc<str>,
13    ttl: Option<Duration>,
14}
15
16/// Expiry implementation for per-entry TTL support
17#[derive(Debug, Clone, Default)]
18struct CacheExpiry;
19
20impl moka::Expiry<String, CacheEntry> for CacheExpiry {
21    fn expire_after_create(
22        &self,
23        _key: &String,
24        value: &CacheEntry,
25        _created_at: std::time::Instant,
26    ) -> Option<Duration> {
27        value.ttl
28    }
29}
30
31/// Memory cache implementation using `moka::sync::Cache`
32///
33/// Features:
34/// - Lock-free concurrent access
35/// - `TinyLFU` eviction policy (better hit rate than LRU)
36/// - Per-entry TTL support via Expiry trait
37/// - Automatic expiration cleanup
38pub struct MemoryCache {
39    cache: moka::sync::Cache<String, CacheEntry>,
40}
41
42impl MemoryCache {
43    /// Create a new memory cache
44    ///
45    /// # Arguments
46    /// * `max_size` - Maximum number of cache entries
47    #[must_use]
48    pub fn new(max_size: usize) -> Self {
49        Self {
50            cache: moka::sync::Cache::builder()
51                .max_capacity(max_size as u64)
52                .expire_after(CacheExpiry)
53                .build(),
54        }
55    }
56
57    /// Run pending maintenance tasks on the cache.
58    /// This is primarily used in tests to ensure TTL expiration is processed.
59    ///
60    /// # Note
61    /// This method is only available in test builds via `#[cfg(test)]`.
62    #[cfg(test)]
63    pub fn run_pending_tasks(&self) {
64        self.cache.run_pending_tasks();
65    }
66
67    /// Get the number of entries in the cache.
68    /// This is primarily used in tests to verify cache state.
69    ///
70    /// # Note
71    /// This method is only available in test builds via `#[cfg(test)]`.
72    #[cfg(test)]
73    #[must_use]
74    pub fn entry_count(&self) -> usize {
75        usize::try_from(self.cache.entry_count()).expect("cache entry count should fit in usize")
76    }
77}
78
79#[async_trait::async_trait]
80impl super::Cache for MemoryCache {
81    #[tracing::instrument(skip(self), level = "trace")]
82    async fn get(&self, key: &str) -> Option<Arc<str>> {
83        let result = self.cache.get(key).map(|entry| Arc::clone(&entry.value));
84        if result.is_some() {
85            tracing::trace!(cache_type = "memory", key = %key, "Cache hit");
86        } else {
87            tracing::trace!(cache_type = "memory", key = %key, "Cache miss");
88        }
89        result
90    }
91
92    #[tracing::instrument(skip(self), level = "trace")]
93    async fn set(
94        &self,
95        key: String,
96        value: String,
97        ttl: Option<Duration>,
98    ) -> crate::error::Result<()> {
99        let entry = CacheEntry {
100            value: Arc::from(value.into_boxed_str()),
101            ttl,
102        };
103        tracing::trace!(cache_type = "memory", key = %key, "Setting cache entry");
104        self.cache.insert(key, entry);
105        Ok(())
106    }
107
108    #[tracing::instrument(skip(self), level = "trace")]
109    async fn delete(&self, key: &str) -> crate::error::Result<()> {
110        tracing::trace!(cache_type = "memory", key = %key, "Deleting cache entry");
111        self.cache.invalidate(key);
112        Ok(())
113    }
114
115    #[tracing::instrument(skip(self), level = "trace")]
116    async fn clear(&self) -> crate::error::Result<()> {
117        tracing::trace!(cache_type = "memory", "Clearing all cache entries");
118        self.cache.invalidate_all();
119        Ok(())
120    }
121
122    #[tracing::instrument(skip(self), level = "trace")]
123    async fn exists(&self, key: &str) -> bool {
124        let result = self.cache.contains_key(key);
125        tracing::trace!(cache_type = "memory", key = %key, exists = result, "Checking cache entry existence");
126        result
127    }
128
129    fn as_any(&self) -> &dyn std::any::Any {
130        self
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::cache::Cache;
138    use tokio::time::sleep;
139
140    /// Default cache capacity for tests
141    const DEFAULT_TEST_CACHE_CAPACITY: usize = 10;
142
143    /// Test TTL duration in milliseconds
144    const TEST_TTL_MS: u64 = 100;
145
146    /// Test TTL wait duration in milliseconds
147    const TEST_TTL_WAIT_MS: u64 = 150;
148
149    #[tokio::test]
150    async fn test_memory_cache_basic() {
151        let cache = MemoryCache::new(DEFAULT_TEST_CACHE_CAPACITY);
152
153        // Test set and get
154        cache
155            .set("key1".to_string(), "value1".to_string(), None)
156            .await
157            .expect("set should succeed");
158        let result = cache.get("key1").await;
159        assert!(result.is_some());
160        assert_eq!(result.unwrap().as_ref(), "value1");
161
162        // Test delete
163        cache.delete("key1").await.expect("delete should succeed");
164        assert_eq!(cache.get("key1").await, None);
165
166        // Test clear
167        cache
168            .set("key2".to_string(), "value2".to_string(), None)
169            .await
170            .expect("set should succeed");
171        cache.clear().await.expect("clear should succeed");
172        // Wait for async invalidation to complete
173        cache.run_pending_tasks();
174        assert_eq!(cache.get("key2").await, None);
175    }
176
177    #[tokio::test]
178    async fn test_memory_cache_ttl() {
179        let cache = MemoryCache::new(DEFAULT_TEST_CACHE_CAPACITY);
180
181        // Test cache with TTL
182        cache
183            .set(
184                "key1".to_string(),
185                "value1".to_string(),
186                Some(Duration::from_millis(TEST_TTL_MS)),
187            )
188            .await
189            .expect("set should succeed");
190        let result = cache.get("key1").await;
191        assert!(result.is_some());
192        assert_eq!(result.unwrap().as_ref(), "value1");
193
194        // Wait for expiration
195        sleep(Duration::from_millis(TEST_TTL_WAIT_MS)).await;
196        // Run pending tasks to ensure expiration is processed
197        cache.run_pending_tasks();
198        assert_eq!(cache.get("key1").await, None);
199    }
200
201    #[tokio::test]
202    async fn test_memory_cache_eviction() {
203        // Test that cache respects max capacity
204        // Note: moka uses TinyLFU algorithm which may reject new entries
205        // based on frequency, so we test capacity constraint differently
206        let cache = MemoryCache::new(3);
207
208        // Fill cache with more entries than capacity
209        for i in 0..5 {
210            cache
211                .set(format!("key{i}"), format!("value{i}"), None)
212                .await
213                .expect("set should succeed");
214        }
215
216        // Run pending tasks to ensure eviction is processed
217        cache.run_pending_tasks();
218
219        // Cache should not exceed max capacity significantly
220        let entry_count = cache.entry_count();
221        assert!(
222            entry_count <= 5,
223            "Entry count should be at most 5, got {entry_count}"
224        );
225    }
226
227    #[tokio::test]
228    async fn test_memory_cache_exists() {
229        let cache = MemoryCache::new(DEFAULT_TEST_CACHE_CAPACITY);
230
231        cache
232            .set("key1".to_string(), "value1".to_string(), None)
233            .await
234            .expect("set should succeed");
235        assert!(cache.exists("key1").await);
236        assert!(!cache.exists("key2").await);
237    }
238}