Skip to main content

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