aster_forge_cache/
memory.rs

1//! In-memory cache backend implementation.
2//!
3//! The backend is intended for local development, tests, and fallback paths where durability is not
4//! required. It layers explicit per-entry expiration and a reservation set over `moka` so common
5//! cache operations keep the same semantics as the Redis backend.
6
7use super::{CacheBackend, Result, reservation::ReservationSet};
8use async_trait::async_trait;
9use moka::future::Cache;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13const MEMORY_CACHE_MAX_BYTES: u64 = 64 * 1024 * 1024;
14
15/// In-process cache backend backed by `moka`.
16pub struct MemoryCache {
17    cache: Cache<String, MemoryCacheValue>,
18    default_ttl: u64,
19    reservations: ReservationSet,
20}
21
22#[derive(Clone)]
23struct MemoryCacheValue {
24    bytes: Vec<u8>,
25    expires_at: Instant,
26}
27
28impl MemoryCacheValue {
29    fn new(bytes: Vec<u8>, ttl_secs: u64) -> Self {
30        let now = Instant::now();
31        Self {
32            bytes,
33            expires_at: now
34                .checked_add(Duration::from_secs(ttl_secs))
35                .unwrap_or(now),
36        }
37    }
38
39    fn is_expired(&self) -> bool {
40        self.expires_at <= Instant::now()
41    }
42}
43
44/// Bridges each value's absolute `expires_at` into moka's per-entry expiration policy.
45///
46/// A builder-level `time_to_live(default_ttl)` would apply one duration to every entry and
47/// silently evict entries whose per-entry TTL is longer than the default (and make the whole
48/// cache write-only when `default_ttl` is 0). Computing the duration from the value's own
49/// `expires_at` keeps per-entry TTLs exact, matching the Redis backend's SETEX semantics.
50struct MemoryCacheExpiry;
51
52impl moka::Expiry<String, MemoryCacheValue> for MemoryCacheExpiry {
53    fn expire_after_create(
54        &self,
55        _key: &String,
56        value: &MemoryCacheValue,
57        created_at: Instant,
58    ) -> Option<Duration> {
59        Some(value.expires_at.saturating_duration_since(created_at))
60    }
61
62    fn expire_after_update(
63        &self,
64        _key: &String,
65        value: &MemoryCacheValue,
66        updated_at: Instant,
67        _duration_until_expiry: Option<Duration>,
68    ) -> Option<Duration> {
69        Some(value.expires_at.saturating_duration_since(updated_at))
70    }
71}
72
73impl MemoryCache {
74    /// Creates a memory cache with the provided default TTL in seconds.
75    #[must_use]
76    pub fn new(default_ttl: u64) -> Self {
77        let cache = Cache::builder()
78            .max_capacity(MEMORY_CACHE_MAX_BYTES)
79            .weigher(|key: &String, value: &MemoryCacheValue| {
80                entry_weight(key.len(), value.bytes.len())
81            })
82            .expire_after(MemoryCacheExpiry)
83            .build();
84        Self {
85            cache,
86            default_ttl,
87            reservations: ReservationSet::new(default_ttl),
88        }
89    }
90
91    fn cache_value(&self, value: Vec<u8>, ttl_secs: Option<u64>) -> MemoryCacheValue {
92        MemoryCacheValue::new(value, ttl_secs.unwrap_or(self.default_ttl))
93    }
94}
95
96fn entry_weight(key_len: usize, value_len: usize) -> u32 {
97    let total = key_len.saturating_add(value_len);
98    u32::try_from(total).unwrap_or(u32::MAX)
99}
100
101#[async_trait]
102impl CacheBackend for MemoryCache {
103    fn backend_name(&self) -> &'static str {
104        "memory"
105    }
106
107    async fn health_check(&self) -> Result<()> {
108        Ok(())
109    }
110
111    async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
112        let value = self.cache.get(key).await?;
113        if value.is_expired() {
114            // The value expired in the sliver between moka's read and ours. moka's
115            // per-entry expiry already hides the entry from future reads, so only the
116            // reservation co-lifetime needs help here. Do NOT `cache.remove`: between
117            // our get and that remove a concurrent `set_bytes` could insert a fresh
118            // value, and the remove would silently delete it.
119            self.reservations.remove(key);
120            return None;
121        }
122        Some(value.bytes)
123    }
124
125    async fn take_bytes(&self, key: &str) -> Option<Vec<u8>> {
126        self.reservations.remove(key);
127        let value = self.cache.remove(key).await?;
128        if value.is_expired() {
129            return None;
130        }
131        Some(value.bytes)
132    }
133
134    async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) {
135        self.cache
136            .insert(key.to_string(), self.cache_value(value, ttl_secs))
137            .await;
138    }
139
140    async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool {
141        if self.get_bytes(key).await.is_some() {
142            return false;
143        }
144        let Some(guard) = self.reservations.reserve_guarded(key, ttl_secs) else {
145            return false;
146        };
147        if self.get_bytes(key).await.is_some() {
148            // Lost the race to a concurrently inserted value. Dropping the guard
149            // releases our reservation: keeping it would falsely block every later
150            // `set_bytes_if_absent` until its TTL expired — including after the
151            // winning value itself is evicted (TTL eviction never touches the set).
152            return false;
153        }
154
155        self.cache
156            .insert(key.to_string(), self.cache_value(value, ttl_secs))
157            .await;
158        // The value is published; the reservation now lives for its co-lifetime
159        // with the value instead of ending with this call.
160        guard.commit();
161        true
162    }
163
164    async fn delete(&self, key: &str) {
165        self.reservations.remove(key);
166        self.cache.remove(key).await;
167    }
168
169    async fn delete_many(&self, keys: &[String]) {
170        for key in keys {
171            self.delete(key).await;
172        }
173    }
174
175    async fn invalidate_prefix(&self, prefix: &str) {
176        self.reservations.invalidate_prefix(prefix);
177        let keys: Vec<Arc<String>> = self
178            .cache
179            .iter()
180            .filter(|(k, _)| k.starts_with(prefix))
181            .map(|(k, _)| k.clone())
182            .collect();
183        for key in keys {
184            self.cache.remove(key.as_ref()).await;
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::{CacheBackend, MemoryCache, entry_weight};
192    use std::sync::Arc;
193    use std::time::Duration;
194
195    #[test]
196    fn entry_weight_counts_key_and_value_bytes() {
197        assert_eq!(entry_weight(3, 5), 8);
198    }
199
200    #[test]
201    fn entry_weight_saturates_at_u32_max() {
202        assert_eq!(entry_weight(usize::MAX, usize::MAX), u32::MAX);
203    }
204
205    #[tokio::test]
206    async fn set_bytes_if_absent_allows_one_concurrent_insert() {
207        let cache = Arc::new(MemoryCache::new(60));
208        let mut tasks = Vec::new();
209        for _ in 0..16 {
210            let cache = cache.clone();
211            tasks.push(tokio::spawn(async move {
212                cache
213                    .set_bytes_if_absent("nonce", Vec::new(), Some(60))
214                    .await
215            }));
216        }
217
218        let successes = futures::future::join_all(tasks)
219            .await
220            .into_iter()
221            .map(|result| result.expect("reservation task should not panic"))
222            .filter(|inserted| *inserted)
223            .count();
224
225        assert_eq!(successes, 1);
226    }
227
228    #[tokio::test]
229    async fn set_bytes_if_absent_keeps_reservation_after_successful_insert() {
230        let cache = MemoryCache::new(60);
231
232        assert!(
233            cache
234                .set_bytes_if_absent("nonce", b"claimed".to_vec(), Some(60))
235                .await
236        );
237
238        // The committed reservation outlives the call, co-living with the value.
239        assert!(!cache.reservations.reserve("nonce", Some(60)));
240    }
241
242    #[tokio::test]
243    async fn set_bytes_if_absent_takes_no_reservation_when_value_is_already_visible() {
244        let cache = MemoryCache::new(60);
245        cache.set_bytes("nonce", b"plain".to_vec(), Some(60)).await;
246
247        assert!(
248            !cache
249                .set_bytes_if_absent("nonce", b"claimed".to_vec(), Some(60))
250                .await
251        );
252
253        // The first visibility check lost before reserving, so the key stays
254        // reservable. The lose-after-reserving path releases its reservation via
255        // ReservationGuard (unit-tested in the reservation module).
256        assert!(cache.reservations.reserve("nonce", Some(60)));
257    }
258
259    #[tokio::test]
260    async fn set_bytes_if_absent_respects_existing_set_value() {
261        let cache = MemoryCache::new(60);
262
263        cache.set_bytes("nonce", b"first".to_vec(), Some(60)).await;
264
265        assert!(
266            !cache
267                .set_bytes_if_absent("nonce", b"second".to_vec(), Some(60))
268                .await
269        );
270        assert_eq!(cache.get_bytes("nonce").await, Some(b"first".to_vec()));
271    }
272
273    #[tokio::test]
274    async fn set_bytes_respects_entry_ttl() {
275        let cache = MemoryCache::new(60);
276
277        cache.set_bytes("short", b"value".to_vec(), Some(0)).await;
278
279        assert_eq!(cache.get_bytes("short").await, None);
280    }
281
282    #[test]
283    fn expiry_derives_duration_from_absolute_expires_at() {
284        use moka::Expiry;
285
286        let expiry = super::MemoryCacheExpiry;
287        let value = super::MemoryCacheValue::new(b"value".to_vec(), 60);
288        let zero_ttl = super::MemoryCacheValue::new(b"value".to_vec(), 0);
289        // moka passes its insertion instant, which always follows value construction.
290        let created_at = std::time::Instant::now();
291
292        let ttl = expiry
293            .expire_after_create(&"key".to_string(), &value, created_at)
294            .expect("entry should carry an expiration");
295        assert!(ttl > Duration::from_secs(59) && ttl <= Duration::from_mins(1));
296
297        // A zero-TTL value is already expired at insertion, so no lifetime remains.
298        assert_eq!(
299            expiry.expire_after_create(&"key".to_string(), &zero_ttl, created_at),
300            Some(Duration::ZERO)
301        );
302    }
303
304    #[tokio::test]
305    async fn per_entry_ttl_outlives_shorter_default_ttl() {
306        let cache = MemoryCache::new(1);
307        cache.set_bytes("default", b"default".to_vec(), None).await;
308        cache.set_bytes("long", b"long".to_vec(), Some(2)).await;
309
310        // moka's expiration clock is real time, so this test must really wait.
311        tokio::time::sleep(Duration::from_millis(1_100)).await;
312
313        // The M1 bug: a builder-level time_to_live evicted "long" at the 1s default even
314        // though its per-entry TTL is 2s.
315        assert_eq!(cache.get_bytes("default").await, None);
316        assert_eq!(cache.get_bytes("long").await, Some(b"long".to_vec()));
317
318        tokio::time::sleep(Duration::from_secs(1)).await;
319        assert_eq!(cache.get_bytes("long").await, None);
320    }
321
322    #[tokio::test]
323    async fn zero_default_ttl_still_stores_explicit_entry_ttl() {
324        let cache = MemoryCache::new(0);
325
326        cache
327            .set_bytes("explicit", b"value".to_vec(), Some(60))
328            .await;
329        cache.set_bytes("implicit", b"value".to_vec(), None).await;
330
331        // Before per-entry expiry, time_to_live(0) made the whole cache write-only.
332        assert_eq!(cache.get_bytes("explicit").await, Some(b"value".to_vec()));
333        assert_eq!(cache.get_bytes("implicit").await, None);
334    }
335
336    #[tokio::test]
337    async fn set_bytes_if_absent_can_replace_expired_entry() {
338        let cache = MemoryCache::new(60);
339
340        cache.set_bytes("nonce", b"expired".to_vec(), Some(0)).await;
341
342        assert!(
343            cache
344                .set_bytes_if_absent("nonce", b"fresh".to_vec(), Some(60))
345                .await
346        );
347        assert_eq!(cache.get_bytes("nonce").await, Some(b"fresh".to_vec()));
348    }
349
350    #[tokio::test]
351    async fn take_bytes_consumes_existing_entry_once() {
352        let cache = MemoryCache::new(60);
353
354        cache
355            .set_bytes("challenge", b"value".to_vec(), Some(60))
356            .await;
357
358        assert_eq!(cache.take_bytes("challenge").await, Some(b"value".to_vec()));
359        assert_eq!(cache.take_bytes("challenge").await, None);
360        assert_eq!(cache.get_bytes("challenge").await, None);
361    }
362
363    #[tokio::test]
364    async fn take_bytes_returns_none_for_missing_or_expired_entry() {
365        let cache = MemoryCache::new(60);
366
367        assert_eq!(cache.take_bytes("missing").await, None);
368        cache.set_bytes("expired", b"value".to_vec(), Some(0)).await;
369
370        assert_eq!(cache.take_bytes("expired").await, None);
371        assert_eq!(cache.get_bytes("expired").await, None);
372    }
373
374    #[tokio::test]
375    async fn take_bytes_allows_one_concurrent_consumer() {
376        let cache = Arc::new(MemoryCache::new(60));
377        cache
378            .set_bytes("challenge", b"value".to_vec(), Some(60))
379            .await;
380        let mut tasks = Vec::new();
381        for _ in 0..16 {
382            let cache = cache.clone();
383            tasks.push(tokio::spawn(
384                async move { cache.take_bytes("challenge").await },
385            ));
386        }
387
388        let values = futures::future::join_all(tasks)
389            .await
390            .into_iter()
391            .map(|result| result.expect("take task should not panic"))
392            .collect::<Vec<_>>();
393
394        assert_eq!(
395            values
396                .iter()
397                .filter(|value| value.as_deref() == Some(b"value".as_slice()))
398                .count(),
399            1
400        );
401        assert_eq!(values.iter().filter(|value| value.is_none()).count(), 15);
402    }
403
404    #[tokio::test]
405    async fn delete_many_removes_only_requested_entries() {
406        let cache = MemoryCache::new(60);
407        cache.set_bytes("remove:1", b"one".to_vec(), Some(60)).await;
408        cache.set_bytes("remove:2", b"two".to_vec(), Some(60)).await;
409        cache.set_bytes("keep", b"keep".to_vec(), Some(60)).await;
410
411        cache
412            .delete_many(&[
413                "remove:1".to_string(),
414                "remove:2".to_string(),
415                "remove:2".to_string(),
416                "missing".to_string(),
417            ])
418            .await;
419        cache.delete_many(&[]).await;
420
421        assert_eq!(cache.get_bytes("remove:1").await, None);
422        assert_eq!(cache.get_bytes("remove:2").await, None);
423        assert_eq!(cache.get_bytes("keep").await, Some(b"keep".to_vec()));
424    }
425}