Skip to main content

armature_cache/
tiered.rs

1//! Multi-tier caching (L1/L2 cache layers)
2
3use crate::error::CacheResult;
4use crate::traits::CacheStore;
5use async_trait::async_trait;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::Duration;
10use tokio::sync::RwLock;
11
12/// Multi-tier cache with L1 (in-memory) and L2 (distributed) layers
13pub struct TieredCache<L1, L2>
14where
15    L1: CacheStore,
16    L2: CacheStore,
17{
18    /// L1 cache (fast, local)
19    l1: Arc<L1>,
20
21    /// L2 cache (slower, distributed)
22    l2: Arc<L2>,
23
24    /// Configuration
25    config: TieredCacheConfig,
26
27    /// Live hit/miss/promotion counters, shared across clones.
28    metrics: Arc<TieredMetrics>,
29}
30
31/// Atomic counters backing [`TieredCache::stats`].
32#[derive(Debug, Default)]
33struct TieredMetrics {
34    l1_hits: AtomicU64,
35    l2_hits: AtomicU64,
36    misses: AtomicU64,
37    promotions: AtomicU64,
38}
39
40/// Tiered cache configuration
41#[derive(Debug, Clone)]
42pub struct TieredCacheConfig {
43    /// Enable L1 cache
44    pub enable_l1: bool,
45
46    /// Enable L2 cache
47    pub enable_l2: bool,
48
49    /// Write-through to L2 on L1 set
50    pub write_through: bool,
51
52    /// Promote L2 hits to L1
53    pub promote_to_l1: bool,
54
55    /// L1 TTL multiplier (fraction of L2 TTL)
56    pub l1_ttl_fraction: f64,
57
58    /// Fixed TTL applied to entries promoted from L2 into L1 on a read hit.
59    ///
60    /// # Policy
61    ///
62    /// Deriving the promoted L1 TTL from the *live* remaining L2 TTL would
63    /// require an extra `TTL` round-trip to L2 on **every** promotion (the hot
64    /// read path). To avoid that per-read cost we instead apply this fixed
65    /// default. `None` means promoted entries are stored without expiry and
66    /// rely on L1 capacity/eviction. Defaults to 60s.
67    ///
68    /// Note: the write path (`set`) still derives the L1 TTL as
69    /// `l1_ttl_fraction * ttl` because the TTL is already known there without
70    /// any extra round-trip.
71    pub l1_promote_ttl: Option<Duration>,
72}
73
74impl Default for TieredCacheConfig {
75    fn default() -> Self {
76        Self {
77            enable_l1: true,
78            enable_l2: true,
79            write_through: true,
80            promote_to_l1: true,
81            l1_ttl_fraction: 0.25, // L1 lives 1/4 as long as L2
82            l1_promote_ttl: Some(Duration::from_secs(60)),
83        }
84    }
85}
86
87impl<L1, L2> TieredCache<L1, L2>
88where
89    L1: CacheStore,
90    L2: CacheStore,
91{
92    /// Create new tiered cache
93    ///
94    /// # Examples
95    ///
96    /// ```rust,ignore
97    /// use armature_cache::*;
98    ///
99    /// let l1 = Arc::new(InMemoryCache::new());
100    /// let l2 = Arc::new(RedisCache::new(config).await?);
101    /// let cache = TieredCache::new(l1, l2);
102    /// ```
103    pub fn new(l1: Arc<L1>, l2: Arc<L2>) -> Self {
104        Self::with_config(l1, l2, TieredCacheConfig::default())
105    }
106
107    /// Create with custom configuration
108    pub fn with_config(l1: Arc<L1>, l2: Arc<L2>, config: TieredCacheConfig) -> Self {
109        Self {
110            l1,
111            l2,
112            config,
113            metrics: Arc::new(TieredMetrics::default()),
114        }
115    }
116
117    /// Get value from cache (checks L1 then L2)
118    pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
119        // Try L1 first
120        if self.config.enable_l1
121            && let Some(value) = self.l1.get_json(key).await?
122        {
123            self.metrics.l1_hits.fetch_add(1, Ordering::Relaxed);
124            return Ok(Some(value));
125        }
126
127        // Try L2
128        if self.config.enable_l2
129            && let Some(value) = self.l2.get_json(key).await?
130        {
131            self.metrics.l2_hits.fetch_add(1, Ordering::Relaxed);
132            // Promote to L1 if configured.
133            //
134            // Use the fixed `l1_promote_ttl` rather than issuing an extra
135            // `l2.ttl(key)` round-trip to derive it from the live L2 TTL. See
136            // `TieredCacheConfig::l1_promote_ttl` for the policy rationale.
137            if self.config.enable_l1 && self.config.promote_to_l1 {
138                let l1_ttl = self.config.l1_promote_ttl;
139                if self.l1.set_json(key, value.clone(), l1_ttl).await.is_ok() {
140                    self.metrics.promotions.fetch_add(1, Ordering::Relaxed);
141                }
142            }
143            return Ok(Some(value));
144        }
145
146        self.metrics.misses.fetch_add(1, Ordering::Relaxed);
147        Ok(None)
148    }
149
150    /// Set value in cache (writes to both L1 and L2)
151    pub async fn set(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
152        // Write to L2 first (source of truth)
153        if self.config.enable_l2 {
154            self.l2.set_json(key, value.clone(), ttl).await?;
155        }
156
157        // Write to L1 if write-through is enabled
158        if self.config.enable_l1 && (self.config.write_through || !self.config.enable_l2) {
159            let l1_ttl = ttl.map(|ttl| {
160                Duration::from_secs_f64(ttl.as_secs_f64() * self.config.l1_ttl_fraction)
161            });
162            self.l1.set_json(key, value, l1_ttl).await?;
163        }
164
165        Ok(())
166    }
167
168    /// Delete from both L1 and L2
169    pub async fn delete(&self, key: &str) -> CacheResult<()> {
170        if self.config.enable_l1 {
171            self.l1.delete(key).await?;
172        }
173        if self.config.enable_l2 {
174            self.l2.delete(key).await?;
175        }
176        Ok(())
177    }
178
179    /// Check if key exists (checks L1 then L2)
180    pub async fn exists(&self, key: &str) -> CacheResult<bool> {
181        if self.config.enable_l1 && self.l1.exists(key).await? {
182            return Ok(true);
183        }
184        if self.config.enable_l2 {
185            return self.l2.exists(key).await;
186        }
187        Ok(false)
188    }
189
190    /// Clear both L1 and L2
191    ///
192    /// `L2::clear()` is whatever the L2 backend's `CacheStore::clear()` does.
193    /// For `RedisCache` with a `key_prefix` configured, that is now scoped:
194    /// it `SCAN`s for and `UNLINK`s only the keys under that prefix, not the
195    /// whole database. **Remaining risk:** an L2 `RedisCache` with *no*
196    /// `key_prefix` configured has no distinct slice of the keyspace to
197    /// scope to and still falls back to unscoped `FLUSHDB`, wiping the
198    /// *entire* Redis database/instance — so a `TieredCache` wrapping an
199    /// unprefixed `RedisCache` that shares a Redis instance with other
200    /// services/tenants should not call `clear()`. A `MemcachedCache` L2 has
201    /// no prefix-scoped clear at all (the memcached protocol has no key
202    /// enumeration primitive), so its `clear()` always wipes the whole
203    /// memcached instance regardless of `key_prefix`.
204    pub async fn clear(&self) -> CacheResult<()> {
205        if self.config.enable_l1 {
206            self.l1.clear().await?;
207        }
208        if self.config.enable_l2 {
209            self.l2.clear().await?;
210        }
211        Ok(())
212    }
213
214    /// Get cache statistics.
215    ///
216    /// The `*_enabled`/`write_through`/`promote_to_l1` fields echo the static
217    /// configuration; the `l1_hits`/`l2_hits`/`misses`/`promotions` counters are
218    /// live totals accumulated across every `get` since construction (shared
219    /// across clones).
220    pub async fn stats(&self) -> CacheStats {
221        CacheStats {
222            l1_enabled: self.config.enable_l1,
223            l2_enabled: self.config.enable_l2,
224            write_through: self.config.write_through,
225            promote_to_l1: self.config.promote_to_l1,
226            l1_hits: self.metrics.l1_hits.load(Ordering::Relaxed),
227            l2_hits: self.metrics.l2_hits.load(Ordering::Relaxed),
228            misses: self.metrics.misses.load(Ordering::Relaxed),
229            promotions: self.metrics.promotions.load(Ordering::Relaxed),
230        }
231    }
232}
233
234impl<L1, L2> Clone for TieredCache<L1, L2>
235where
236    L1: CacheStore,
237    L2: CacheStore,
238{
239    fn clone(&self) -> Self {
240        Self {
241            l1: self.l1.clone(),
242            l2: self.l2.clone(),
243            config: self.config.clone(),
244            metrics: self.metrics.clone(),
245        }
246    }
247}
248
249/// Cache statistics.
250///
251/// The boolean fields reflect configuration; the `u64` counters are live
252/// running totals of `get` outcomes.
253#[derive(Debug, Clone)]
254pub struct CacheStats {
255    pub l1_enabled: bool,
256    pub l2_enabled: bool,
257    pub write_through: bool,
258    pub promote_to_l1: bool,
259    /// Number of `get` calls served from L1.
260    pub l1_hits: u64,
261    /// Number of `get` calls served from L2 (after an L1 miss).
262    pub l2_hits: u64,
263    /// Number of `get` calls that found nothing in either tier.
264    pub misses: u64,
265    /// Number of L2 hits successfully copied back into L1.
266    pub promotions: u64,
267}
268
269/// Default upper bound on the number of live entries an [`InMemoryCache`]
270/// retains. Prevents the backing map from growing without limit when callers
271/// never delete keys. Override with [`InMemoryCache::with_capacity`].
272pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
273
274/// In-memory cache for L1 tier.
275///
276/// The cache is bounded: it holds at most `max_entries` live entries. Expired
277/// entries are evicted lazily on read and eagerly when making room for a new
278/// key; when the map is full of live entries the one nearest to expiry is
279/// evicted to admit a new write.
280pub struct InMemoryCache {
281    data: Arc<RwLock<HashMap<String, CacheEntry>>>,
282    /// Maximum number of retained entries. `0` means unbounded.
283    max_entries: usize,
284}
285
286#[derive(Clone)]
287struct CacheEntry {
288    value: String,
289    expires_at: Option<tokio::time::Instant>,
290}
291
292impl InMemoryCache {
293    /// Create a new in-memory cache bounded to [`DEFAULT_MAX_ENTRIES`] entries.
294    pub fn new() -> Self {
295        Self::with_capacity(DEFAULT_MAX_ENTRIES)
296    }
297
298    /// Create a new in-memory cache bounded to `max_entries` live entries.
299    ///
300    /// Pass `0` for an explicitly unbounded cache (growth is then the caller's
301    /// responsibility).
302    pub fn with_capacity(max_entries: usize) -> Self {
303        Self {
304            data: Arc::new(RwLock::new(HashMap::new())),
305            max_entries,
306        }
307    }
308
309    /// Number of entries currently held (including any not-yet-evicted expired
310    /// ones). Primarily useful for tests and capacity assertions.
311    pub async fn len(&self) -> usize {
312        self.data.read().await.len()
313    }
314
315    /// Whether the cache currently holds no entries.
316    pub async fn is_empty(&self) -> bool {
317        self.data.read().await.is_empty()
318    }
319
320    /// Eagerly remove every expired entry in one pass.
321    ///
322    /// Expired entries are also reclaimed lazily (on read) and opportunistically
323    /// (when making room for a new write); this method exposes an explicit full
324    /// sweep for callers that want to reclaim memory proactively.
325    pub async fn cleanup_expired(&self) {
326        let mut data = self.data.write().await;
327        let now = tokio::time::Instant::now();
328        Self::prune_expired(&mut data, now);
329    }
330
331    /// Drop all entries whose TTL has elapsed as of `now`. Operates on an
332    /// already-held write guard so callers avoid re-locking.
333    fn prune_expired(data: &mut HashMap<String, CacheEntry>, now: tokio::time::Instant) {
334        data.retain(|_, entry| entry.expires_at.is_none_or(|exp| exp > now));
335    }
336
337    /// Evict a single entry to make room, preferring the one nearest to expiry
338    /// (entries without a TTL are evicted last).
339    fn evict_one(data: &mut HashMap<String, CacheEntry>, now: tokio::time::Instant) {
340        if let Some(victim) = data
341            .iter()
342            .min_by_key(|(_, entry)| match entry.expires_at {
343                // Entries with a TTL sort before those without; among them the
344                // soonest-to-expire is evicted first.
345                Some(exp) => (0u8, exp),
346                None => (1u8, now),
347            })
348            .map(|(key, _)| key.clone())
349        {
350            data.remove(&victim);
351        }
352    }
353}
354
355impl Default for InMemoryCache {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361#[async_trait]
362impl CacheStore for InMemoryCache {
363    async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
364        // Fast path under a read lock.
365        {
366            let data = self.data.read().await;
367            match data.get(key) {
368                None => return Ok(None),
369                Some(entry) => match entry.expires_at {
370                    Some(expires_at) if tokio::time::Instant::now() > expires_at => {
371                        // Expired — fall through to evict it under a write lock.
372                    }
373                    _ => return Ok(Some(entry.value.clone())),
374                },
375            }
376        }
377
378        // Lazy eviction: drop the expired entry so the map does not accumulate
379        // dead keys that are read but never overwritten.
380        let mut data = self.data.write().await;
381        if let Some(entry) = data.get(key)
382            && entry
383                .expires_at
384                .is_some_and(|exp| tokio::time::Instant::now() > exp)
385        {
386            data.remove(key);
387        }
388        Ok(None)
389    }
390
391    async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
392        let now = tokio::time::Instant::now();
393        let expires_at = ttl.map(|d| now + d);
394        let entry = CacheEntry { value, expires_at };
395
396        let mut data = self.data.write().await;
397
398        // Enforce the capacity bound only when admitting a genuinely new key.
399        if self.max_entries != 0 && data.len() >= self.max_entries && !data.contains_key(key) {
400            // Reclaim expired entries first; only evict a live one if still full.
401            Self::prune_expired(&mut data, now);
402            if data.len() >= self.max_entries {
403                Self::evict_one(&mut data, now);
404            }
405        }
406
407        data.insert(key.to_string(), entry);
408        Ok(())
409    }
410
411    async fn delete(&self, key: &str) -> CacheResult<()> {
412        self.data.write().await.remove(key);
413        Ok(())
414    }
415
416    async fn exists(&self, key: &str) -> CacheResult<bool> {
417        self.get_json(key).await.map(|v| v.is_some())
418    }
419
420    async fn clear(&self) -> CacheResult<()> {
421        self.data.write().await.clear();
422        Ok(())
423    }
424
425    async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
426        let data = self.data.read().await;
427        let now = tokio::time::Instant::now();
428        Ok(data
429            .get(key)
430            .and_then(|e| e.expires_at)
431            .filter(|&x| x > now)
432            .map(|x| x - now))
433    }
434
435    async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
436        let mut data = self.data.write().await;
437        if let Some(entry) = data.get_mut(key) {
438            entry.expires_at = Some(tokio::time::Instant::now() + ttl);
439        }
440        Ok(())
441    }
442
443    async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64> {
444        let mut data = self.data.write().await;
445        let entry = data.entry(key.to_string()).or_insert_with(|| CacheEntry {
446            value: "0".to_string(),
447            expires_at: None,
448        });
449
450        let current: i64 = entry.value.parse().unwrap_or(0);
451        let new_value = current + delta;
452        entry.value = new_value.to_string();
453
454        Ok(new_value)
455    }
456
457    async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64> {
458        self.increment(key, -delta).await
459    }
460}
461
462#[cfg(test)]
463mod tests_tiered {
464    use super::*;
465
466    #[tokio::test]
467    async fn test_tiered_cache() {
468        let l1 = Arc::new(InMemoryCache::new());
469        let l2 = Arc::new(InMemoryCache::new());
470        let cache = TieredCache::new(l1.clone(), l2.clone());
471
472        // Set value
473        cache.set("test", "value".to_string(), None).await.unwrap();
474
475        // Get from L1
476        let value = l1.get_json("test").await.unwrap();
477        assert!(value.is_some());
478
479        // Get from tiered cache
480        let value = cache.get("test").await.unwrap();
481        assert_eq!(value, Some("value".to_string()));
482
483        // Delete
484        cache.delete("test").await.unwrap();
485        let value = cache.get("test").await.unwrap();
486        assert_eq!(value, None);
487    }
488
489    #[tokio::test]
490    async fn test_l2_promotion() {
491        let l1 = Arc::new(InMemoryCache::new());
492        let l2 = Arc::new(InMemoryCache::new());
493        let cache = TieredCache::new(l1.clone(), l2.clone());
494
495        // Set in L2 only
496        l2.set_json("key", "value".to_string(), None).await.unwrap();
497
498        // Get from tiered cache (should promote to L1)
499        let value = cache.get("key").await.unwrap();
500        assert_eq!(value, Some("value".to_string()));
501
502        // Check L1 was populated
503        let l1_value = l1.get_json("key").await.unwrap();
504        assert!(l1_value.is_some());
505    }
506
507    #[tokio::test]
508    async fn test_promotion_uses_fixed_l1_ttl_no_l2_roundtrip() {
509        let l1 = Arc::new(InMemoryCache::new());
510        let l2 = Arc::new(InMemoryCache::new());
511        let config = TieredCacheConfig {
512            l1_promote_ttl: Some(Duration::from_secs(30)),
513            ..TieredCacheConfig::default()
514        };
515        let cache = TieredCache::with_config(l1.clone(), l2.clone(), config);
516
517        // Set in L2 only, with NO TTL. The old implementation would have read
518        // L2's (absent) TTL and stored L1 without expiry; the new one applies
519        // the fixed `l1_promote_ttl` regardless of L2's TTL.
520        l2.set_json("key", "value".to_string(), None).await.unwrap();
521
522        // Trigger promotion.
523        let value = cache.get("key").await.unwrap();
524        assert_eq!(value, Some("value".to_string()));
525
526        // L1 entry should carry the fixed promote TTL (<= 30s and > 0), proving
527        // it was derived from config, not from L2's (missing) TTL.
528        let l1_ttl = l1.ttl("key").await.unwrap();
529        let l1_ttl = l1_ttl.expect("promoted L1 entry should have a TTL");
530        assert!(l1_ttl > Duration::from_secs(0));
531        assert!(l1_ttl <= Duration::from_secs(30));
532    }
533
534    #[tokio::test]
535    async fn test_promotion_with_no_l1_ttl_stores_without_expiry() {
536        let l1 = Arc::new(InMemoryCache::new());
537        let l2 = Arc::new(InMemoryCache::new());
538        let config = TieredCacheConfig {
539            l1_promote_ttl: None,
540            ..TieredCacheConfig::default()
541        };
542        let cache = TieredCache::with_config(l1.clone(), l2.clone(), config);
543
544        l2.set_json("key", "value".to_string(), None).await.unwrap();
545        let _ = cache.get("key").await.unwrap();
546
547        // No promote TTL configured -> promoted entry has no expiry.
548        assert_eq!(l1.ttl("key").await.unwrap(), None);
549        assert!(l1.get_json("key").await.unwrap().is_some());
550    }
551
552    /// Regression: `stats()` must report live hit/miss/promotion totals, not
553    /// merely echo the configuration booleans.
554    #[tokio::test]
555    async fn test_stats_track_hits_misses_promotions() {
556        let l1 = Arc::new(InMemoryCache::new());
557        let l2 = Arc::new(InMemoryCache::new());
558        let cache = TieredCache::new(l1.clone(), l2.clone());
559
560        // Miss: absent from both tiers.
561        assert_eq!(cache.get("absent").await.unwrap(), None);
562
563        // Write-through populates L1; the read is an L1 hit.
564        cache.set("k", "v".to_string(), None).await.unwrap();
565        assert_eq!(cache.get("k").await.unwrap(), Some("v".to_string()));
566
567        // L2-only key: an L2 hit that also promotes into L1.
568        l2.set_json("only2", "v2".to_string(), None).await.unwrap();
569        assert_eq!(cache.get("only2").await.unwrap(), Some("v2".to_string()));
570
571        let stats = cache.stats().await;
572        assert_eq!(stats.misses, 1, "one miss expected");
573        assert_eq!(stats.l1_hits, 1, "one L1 hit expected");
574        assert_eq!(stats.l2_hits, 1, "one L2 hit expected");
575        assert_eq!(stats.promotions, 1, "one promotion expected");
576    }
577
578    /// Regression: expired L1 entries must actually be removed from the backing
579    /// map on read, not just reported as `None` while lingering forever.
580    #[tokio::test(start_paused = true)]
581    async fn test_l1_expired_entries_are_evicted_on_read() {
582        let cache = InMemoryCache::new();
583        cache
584            .set_json("k", "v".to_string(), Some(Duration::from_secs(1)))
585            .await
586            .unwrap();
587        assert_eq!(cache.len().await, 1);
588
589        tokio::time::advance(Duration::from_secs(2)).await;
590
591        assert_eq!(cache.get_json("k").await.unwrap(), None);
592        assert_eq!(
593            cache.len().await,
594            0,
595            "expired entry must be evicted from the map, not retained"
596        );
597    }
598
599    /// Regression: the map must stay bounded — admitting a new key when full
600    /// evicts an existing entry instead of growing without limit.
601    #[tokio::test]
602    async fn test_l1_capacity_bound_is_enforced() {
603        let cache = InMemoryCache::with_capacity(2);
604        cache.set_json("a", "1".to_string(), None).await.unwrap();
605        cache.set_json("b", "2".to_string(), None).await.unwrap();
606        cache.set_json("c", "3".to_string(), None).await.unwrap();
607
608        assert!(
609            cache.len().await <= 2,
610            "cache must not exceed its configured capacity of 2, got {}",
611            cache.len().await
612        );
613        // The most recently written key must survive.
614        assert_eq!(cache.get_json("c").await.unwrap(), Some("3".to_string()));
615    }
616
617    /// A full cache prefers to reclaim expired entries before evicting a live
618    /// one, so unexpired keys survive when there is expired garbage to drop.
619    #[tokio::test(start_paused = true)]
620    async fn test_capacity_prefers_reclaiming_expired() {
621        let cache = InMemoryCache::with_capacity(2);
622        cache
623            .set_json("short", "x".to_string(), Some(Duration::from_secs(1)))
624            .await
625            .unwrap();
626        cache.set_json("keep", "y".to_string(), None).await.unwrap();
627
628        tokio::time::advance(Duration::from_secs(2)).await;
629
630        // Admitting "new" should reclaim the expired "short" rather than "keep".
631        cache.set_json("new", "z".to_string(), None).await.unwrap();
632        assert!(cache.len().await <= 2);
633        assert_eq!(cache.get_json("keep").await.unwrap(), Some("y".to_string()));
634        assert_eq!(cache.get_json("new").await.unwrap(), Some("z".to_string()));
635    }
636}