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::cmp::Reverse;
7use std::collections::{BinaryHeap, HashMap, VecDeque};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Duration;
11use tokio::sync::RwLock;
12
13/// Multi-tier cache with L1 (in-memory) and L2 (distributed) layers
14pub struct TieredCache<L1, L2>
15where
16    L1: CacheStore,
17    L2: CacheStore,
18{
19    /// L1 cache (fast, local)
20    l1: Arc<L1>,
21
22    /// L2 cache (slower, distributed)
23    l2: Arc<L2>,
24
25    /// Configuration
26    config: TieredCacheConfig,
27
28    /// Live hit/miss/promotion counters, shared across clones.
29    metrics: Arc<TieredMetrics>,
30}
31
32/// Atomic counters backing [`TieredCache::stats`].
33#[derive(Debug, Default)]
34struct TieredMetrics {
35    l1_hits: AtomicU64,
36    l2_hits: AtomicU64,
37    misses: AtomicU64,
38    promotions: AtomicU64,
39}
40
41/// Tiered cache configuration
42#[derive(Debug, Clone)]
43pub struct TieredCacheConfig {
44    /// Enable L1 cache
45    pub enable_l1: bool,
46
47    /// Enable L2 cache
48    pub enable_l2: bool,
49
50    /// Write-through to L2 on L1 set
51    pub write_through: bool,
52
53    /// Promote L2 hits to L1
54    pub promote_to_l1: bool,
55
56    /// L1 TTL multiplier (fraction of L2 TTL)
57    pub l1_ttl_fraction: f64,
58
59    /// Fixed TTL applied to entries promoted from L2 into L1 on a read hit.
60    ///
61    /// # Policy
62    ///
63    /// Deriving the promoted L1 TTL from the *live* remaining L2 TTL would
64    /// require an extra `TTL` round-trip to L2 on **every** promotion (the hot
65    /// read path). To avoid that per-read cost we instead apply this fixed
66    /// default. `None` means promoted entries are stored without expiry and
67    /// rely on L1 capacity/eviction. Defaults to 60s.
68    ///
69    /// Note: the write path (`set`) still derives the L1 TTL as
70    /// `l1_ttl_fraction * ttl` because the TTL is already known there without
71    /// any extra round-trip.
72    pub l1_promote_ttl: Option<Duration>,
73}
74
75impl Default for TieredCacheConfig {
76    fn default() -> Self {
77        Self {
78            enable_l1: true,
79            enable_l2: true,
80            write_through: true,
81            promote_to_l1: true,
82            l1_ttl_fraction: 0.25, // L1 lives 1/4 as long as L2
83            l1_promote_ttl: Some(Duration::from_secs(60)),
84        }
85    }
86}
87
88impl<L1, L2> TieredCache<L1, L2>
89where
90    L1: CacheStore,
91    L2: CacheStore,
92{
93    /// Create new tiered cache
94    ///
95    /// # Examples
96    ///
97    /// ```rust,ignore
98    /// use armature_cache::*;
99    ///
100    /// let l1 = Arc::new(InMemoryCache::new());
101    /// let l2 = Arc::new(RedisCache::new(config).await?);
102    /// let cache = TieredCache::new(l1, l2);
103    /// ```
104    pub fn new(l1: Arc<L1>, l2: Arc<L2>) -> Self {
105        Self::with_config(l1, l2, TieredCacheConfig::default())
106    }
107
108    /// Create with custom configuration
109    pub fn with_config(l1: Arc<L1>, l2: Arc<L2>, config: TieredCacheConfig) -> Self {
110        Self {
111            l1,
112            l2,
113            config,
114            metrics: Arc::new(TieredMetrics::default()),
115        }
116    }
117
118    /// Get value from cache (checks L1 then L2)
119    pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
120        // Try L1 first
121        if self.config.enable_l1
122            && let Some(value) = self.l1.get_json(key).await?
123        {
124            self.metrics.l1_hits.fetch_add(1, Ordering::Relaxed);
125            return Ok(Some(value));
126        }
127
128        // Try L2
129        if self.config.enable_l2
130            && let Some(value) = self.l2.get_json(key).await?
131        {
132            self.metrics.l2_hits.fetch_add(1, Ordering::Relaxed);
133            // Promote to L1 if configured.
134            //
135            // Use the fixed `l1_promote_ttl` rather than issuing an extra
136            // `l2.ttl(key)` round-trip to derive it from the live L2 TTL. See
137            // `TieredCacheConfig::l1_promote_ttl` for the policy rationale.
138            if self.config.enable_l1 && self.config.promote_to_l1 {
139                let l1_ttl = self.config.l1_promote_ttl;
140                if self.l1.set_json(key, value.clone(), l1_ttl).await.is_ok() {
141                    self.metrics.promotions.fetch_add(1, Ordering::Relaxed);
142                }
143            }
144            return Ok(Some(value));
145        }
146
147        self.metrics.misses.fetch_add(1, Ordering::Relaxed);
148        Ok(None)
149    }
150
151    /// Set value in cache (writes to both L1 and L2)
152    pub async fn set(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
153        // Write to L2 first (source of truth)
154        if self.config.enable_l2 {
155            self.l2.set_json(key, value.clone(), ttl).await?;
156        }
157
158        // Write to L1 if write-through is enabled
159        if self.config.enable_l1 && (self.config.write_through || !self.config.enable_l2) {
160            let l1_ttl = ttl.map(|ttl| {
161                Duration::from_secs_f64(ttl.as_secs_f64() * self.config.l1_ttl_fraction)
162            });
163            self.l1.set_json(key, value, l1_ttl).await?;
164        }
165
166        Ok(())
167    }
168
169    /// Delete from both L1 and L2
170    pub async fn delete(&self, key: &str) -> CacheResult<()> {
171        if self.config.enable_l1 {
172            self.l1.delete(key).await?;
173        }
174        if self.config.enable_l2 {
175            self.l2.delete(key).await?;
176        }
177        Ok(())
178    }
179
180    /// Check if key exists (checks L1 then L2)
181    pub async fn exists(&self, key: &str) -> CacheResult<bool> {
182        if self.config.enable_l1 && self.l1.exists(key).await? {
183            return Ok(true);
184        }
185        if self.config.enable_l2 {
186            return self.l2.exists(key).await;
187        }
188        Ok(false)
189    }
190
191    /// Clear both L1 and L2
192    ///
193    /// `L2::clear()` is whatever the L2 backend's `CacheStore::clear()` does.
194    /// For `RedisCache` with a `key_prefix` configured, that is now scoped:
195    /// it `SCAN`s for and `UNLINK`s only the keys under that prefix, not the
196    /// whole database. **Remaining risk:** an L2 `RedisCache` with *no*
197    /// `key_prefix` configured has no distinct slice of the keyspace to
198    /// scope to and still falls back to unscoped `FLUSHDB`, wiping the
199    /// *entire* Redis database/instance — so a `TieredCache` wrapping an
200    /// unprefixed `RedisCache` that shares a Redis instance with other
201    /// services/tenants should not call `clear()`. A `MemcachedCache` L2 has
202    /// no prefix-scoped clear at all (the memcached protocol has no key
203    /// enumeration primitive), so its `clear()` always wipes the whole
204    /// memcached instance regardless of `key_prefix`.
205    pub async fn clear(&self) -> CacheResult<()> {
206        if self.config.enable_l1 {
207            self.l1.clear().await?;
208        }
209        if self.config.enable_l2 {
210            self.l2.clear().await?;
211        }
212        Ok(())
213    }
214
215    /// Get cache statistics.
216    ///
217    /// The `*_enabled`/`write_through`/`promote_to_l1` fields echo the static
218    /// configuration; the `l1_hits`/`l2_hits`/`misses`/`promotions` counters are
219    /// live totals accumulated across every `get` since construction (shared
220    /// across clones).
221    pub async fn stats(&self) -> CacheStats {
222        CacheStats {
223            l1_enabled: self.config.enable_l1,
224            l2_enabled: self.config.enable_l2,
225            write_through: self.config.write_through,
226            promote_to_l1: self.config.promote_to_l1,
227            l1_hits: self.metrics.l1_hits.load(Ordering::Relaxed),
228            l2_hits: self.metrics.l2_hits.load(Ordering::Relaxed),
229            misses: self.metrics.misses.load(Ordering::Relaxed),
230            promotions: self.metrics.promotions.load(Ordering::Relaxed),
231        }
232    }
233}
234
235impl<L1, L2> Clone for TieredCache<L1, L2>
236where
237    L1: CacheStore,
238    L2: CacheStore,
239{
240    fn clone(&self) -> Self {
241        Self {
242            l1: self.l1.clone(),
243            l2: self.l2.clone(),
244            config: self.config.clone(),
245            metrics: self.metrics.clone(),
246        }
247    }
248}
249
250/// Cache statistics.
251///
252/// The boolean fields reflect configuration; the `u64` counters are live
253/// running totals of `get` outcomes.
254#[derive(Debug, Clone)]
255pub struct CacheStats {
256    pub l1_enabled: bool,
257    pub l2_enabled: bool,
258    pub write_through: bool,
259    pub promote_to_l1: bool,
260    /// Number of `get` calls served from L1.
261    pub l1_hits: u64,
262    /// Number of `get` calls served from L2 (after an L1 miss).
263    pub l2_hits: u64,
264    /// Number of `get` calls that found nothing in either tier.
265    pub misses: u64,
266    /// Number of L2 hits successfully copied back into L1.
267    pub promotions: u64,
268}
269
270/// Default upper bound on the number of live entries an [`InMemoryCache`]
271/// retains. Prevents the backing map from growing without limit when callers
272/// never delete keys. Override with [`InMemoryCache::with_capacity`].
273pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
274
275/// In-memory cache for L1 tier.
276///
277/// The cache is bounded: it holds at most `max_entries` live entries. Expired
278/// entries are evicted lazily on read and eagerly when making room for a new
279/// key; when the map is full of live entries the one nearest to expiry is
280/// evicted to admit a new write.
281///
282/// # Eviction cost
283///
284/// Eviction order is maintained incrementally rather than recomputed: entries
285/// carrying a TTL are tracked in a min-heap keyed by expiry, and entries
286/// without one in an insertion-ordered queue. Making room is therefore
287/// `O(log n)` (amortised) instead of the full `O(n)` map scan a naive
288/// `min_by_key` would need on **every** admission once the map is full —
289/// which, because every L2 -> L1 promotion in [`TieredCache::get`] is such an
290/// admission, otherwise put a full scan of the (10,000-entry by default) map
291/// on the hot read path and made filling the cache `O(n^2)`.
292pub struct InMemoryCache {
293    data: Arc<RwLock<CacheState>>,
294    /// Maximum number of retained entries. `0` means unbounded.
295    max_entries: usize,
296}
297
298/// The map plus the auxiliary structures that keep eviction order.
299///
300/// The two order-tracking structures use **lazy deletion**: removing or
301/// overwriting a key does not touch them, so they can hold entries that no
302/// longer describe the map. Every pop is therefore validated against `entries`
303/// before it is acted on, and [`CacheState::compact`] rebuilds both once the
304/// accumulated slack outgrows the live set.
305#[derive(Default)]
306struct CacheState {
307    entries: HashMap<String, CacheEntry>,
308    /// Keys with a TTL, ordered soonest-expiry-first.
309    by_expiry: BinaryHeap<Reverse<(tokio::time::Instant, String)>>,
310    /// Keys without a TTL, in insertion order. These are evicted only once no
311    /// TTL-carrying entry remains, matching the documented policy that
312    /// unexpiring entries are evicted last.
313    without_expiry: VecDeque<String>,
314}
315
316#[derive(Clone)]
317struct CacheEntry {
318    value: String,
319    expires_at: Option<tokio::time::Instant>,
320}
321
322impl CacheState {
323    /// Insert or overwrite `key`, recording it in the matching order structure.
324    fn insert(&mut self, key: String, entry: CacheEntry) {
325        match entry.expires_at {
326            Some(expires_at) => self.by_expiry.push(Reverse((expires_at, key.clone()))),
327            None => self.without_expiry.push_back(key.clone()),
328        }
329        self.entries.insert(key, entry);
330        self.compact_if_slack();
331    }
332
333    /// Whether `key`'s live entry is the one that `expires_at` describes.
334    /// Guards against acting on a stale order-structure record.
335    fn is_current(&self, key: &str, expires_at: Option<tokio::time::Instant>) -> bool {
336        self.entries
337            .get(key)
338            .is_some_and(|entry| entry.expires_at == expires_at)
339    }
340
341    /// Drop every entry whose TTL has elapsed as of `now`.
342    ///
343    /// Only the expired prefix of the heap is examined, so this costs
344    /// `O(k log n)` in the number of entries actually reclaimed rather than
345    /// `O(n)` in the size of the map.
346    fn prune_expired(&mut self, now: tokio::time::Instant) {
347        while matches!(self.by_expiry.peek(), Some(Reverse((exp, _))) if *exp <= now) {
348            let Some(Reverse((expires_at, key))) = self.by_expiry.pop() else {
349                break;
350            };
351            if self.is_current(&key, Some(expires_at)) {
352                self.entries.remove(&key);
353            }
354        }
355    }
356
357    /// Evict a single entry to make room, preferring the one nearest to expiry;
358    /// entries without a TTL are evicted last, oldest first.
359    fn evict_one(&mut self) {
360        while let Some(Reverse((expires_at, key))) = self.by_expiry.pop() {
361            if self.is_current(&key, Some(expires_at)) {
362                self.entries.remove(&key);
363                return;
364            }
365        }
366
367        while let Some(key) = self.without_expiry.pop_front() {
368            if self.is_current(&key, None) {
369                self.entries.remove(&key);
370                return;
371            }
372        }
373    }
374
375    /// Rebuild both order structures from `entries` once lazy deletion has left
376    /// more slack than live data, so repeated overwrites/deletes cannot grow
377    /// them without bound.
378    fn compact_if_slack(&mut self) {
379        let tracked = self.by_expiry.len() + self.without_expiry.len();
380        if tracked > 2 * self.entries.len().max(16) {
381            self.compact();
382        }
383    }
384
385    /// Discard both order structures and rebuild them from the live entries.
386    ///
387    /// Insertion order among unexpiring entries is not recoverable from the
388    /// map, so their relative eviction order is reset. That order is not part
389    /// of the documented policy (which only fixes that they are evicted after
390    /// every TTL-carrying entry), and compaction is rare.
391    fn compact(&mut self) {
392        let mut by_expiry = BinaryHeap::with_capacity(self.entries.len());
393        let mut without_expiry = VecDeque::with_capacity(self.entries.len());
394
395        for (key, entry) in &self.entries {
396            match entry.expires_at {
397                Some(expires_at) => by_expiry.push(Reverse((expires_at, key.clone()))),
398                None => without_expiry.push_back(key.clone()),
399            }
400        }
401
402        self.by_expiry = by_expiry;
403        self.without_expiry = without_expiry;
404    }
405
406    fn clear(&mut self) {
407        self.entries.clear();
408        self.by_expiry.clear();
409        self.without_expiry.clear();
410    }
411}
412
413impl InMemoryCache {
414    /// Create a new in-memory cache bounded to [`DEFAULT_MAX_ENTRIES`] entries.
415    pub fn new() -> Self {
416        Self::with_capacity(DEFAULT_MAX_ENTRIES)
417    }
418
419    /// Create a new in-memory cache bounded to `max_entries` live entries.
420    ///
421    /// Pass `0` for an explicitly unbounded cache (growth is then the caller's
422    /// responsibility).
423    pub fn with_capacity(max_entries: usize) -> Self {
424        Self {
425            data: Arc::new(RwLock::new(CacheState::default())),
426            max_entries,
427        }
428    }
429
430    /// Number of entries currently held (including any not-yet-evicted expired
431    /// ones). Primarily useful for tests and capacity assertions.
432    pub async fn len(&self) -> usize {
433        self.data.read().await.entries.len()
434    }
435
436    /// Whether the cache currently holds no entries.
437    pub async fn is_empty(&self) -> bool {
438        self.data.read().await.entries.is_empty()
439    }
440
441    /// Eagerly remove every expired entry in one pass.
442    ///
443    /// Expired entries are also reclaimed lazily (on read) and opportunistically
444    /// (when making room for a new write); this method exposes an explicit full
445    /// sweep for callers that want to reclaim memory proactively.
446    pub async fn cleanup_expired(&self) {
447        let mut data = self.data.write().await;
448        let now = tokio::time::Instant::now();
449        data.prune_expired(now);
450    }
451}
452
453impl Default for InMemoryCache {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459#[async_trait]
460impl CacheStore for InMemoryCache {
461    async fn get_json(&self, key: &str) -> CacheResult<Option<String>> {
462        // Fast path under a read lock.
463        {
464            let data = self.data.read().await;
465            match data.entries.get(key) {
466                None => return Ok(None),
467                Some(entry) => match entry.expires_at {
468                    Some(expires_at) if tokio::time::Instant::now() > expires_at => {
469                        // Expired — fall through to evict it under a write lock.
470                    }
471                    _ => return Ok(Some(entry.value.clone())),
472                },
473            }
474        }
475
476        // Lazy eviction: drop the expired entry so the map does not accumulate
477        // dead keys that are read but never overwritten. The heap record is
478        // left behind and skipped when it surfaces (lazy deletion).
479        let mut data = self.data.write().await;
480        if let Some(entry) = data.entries.get(key)
481            && entry
482                .expires_at
483                .is_some_and(|exp| tokio::time::Instant::now() > exp)
484        {
485            data.entries.remove(key);
486        }
487        Ok(None)
488    }
489
490    async fn set_json(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
491        let now = tokio::time::Instant::now();
492        let expires_at = ttl.map(|d| now + d);
493        let entry = CacheEntry { value, expires_at };
494
495        let mut data = self.data.write().await;
496
497        // Enforce the capacity bound only when admitting a genuinely new key.
498        if self.max_entries != 0
499            && data.entries.len() >= self.max_entries
500            && !data.entries.contains_key(key)
501        {
502            // Reclaim expired entries first; only evict a live one if still full.
503            data.prune_expired(now);
504            if data.entries.len() >= self.max_entries {
505                data.evict_one();
506            }
507        }
508
509        data.insert(key.to_string(), entry);
510        Ok(())
511    }
512
513    async fn delete(&self, key: &str) -> CacheResult<()> {
514        self.data.write().await.entries.remove(key);
515        Ok(())
516    }
517
518    async fn exists(&self, key: &str) -> CacheResult<bool> {
519        self.get_json(key).await.map(|v| v.is_some())
520    }
521
522    async fn clear(&self) -> CacheResult<()> {
523        self.data.write().await.clear();
524        Ok(())
525    }
526
527    async fn ttl(&self, key: &str) -> CacheResult<Option<Duration>> {
528        let data = self.data.read().await;
529        let now = tokio::time::Instant::now();
530        Ok(data
531            .entries
532            .get(key)
533            .and_then(|e| e.expires_at)
534            .filter(|&x| x > now)
535            .map(|x| x - now))
536    }
537
538    async fn expire(&self, key: &str, ttl: Duration) -> CacheResult<()> {
539        let mut data = self.data.write().await;
540        let expires_at = tokio::time::Instant::now() + ttl;
541
542        let updated = match data.entries.get_mut(key) {
543            Some(entry) => {
544                entry.expires_at = Some(expires_at);
545                true
546            }
547            None => false,
548        };
549
550        if updated {
551            // The key now sorts by expiry, so record it where eviction can find
552            // it. Any earlier record for it is stale and gets skipped on pop.
553            data.by_expiry.push(Reverse((expires_at, key.to_string())));
554            data.compact_if_slack();
555        }
556        Ok(())
557    }
558
559    async fn increment(&self, key: &str, delta: i64) -> CacheResult<i64> {
560        let mut data = self.data.write().await;
561
562        let new_value = match data.entries.get_mut(key) {
563            Some(entry) => {
564                let current: i64 = entry.value.parse().unwrap_or(0);
565                let new_value = current + delta;
566                entry.value = new_value.to_string();
567                new_value
568            }
569            None => {
570                // A counter created here has no TTL, so it goes through
571                // `insert` to be registered for eviction like any other write.
572                data.insert(
573                    key.to_string(),
574                    CacheEntry {
575                        value: delta.to_string(),
576                        expires_at: None,
577                    },
578                );
579                delta
580            }
581        };
582
583        Ok(new_value)
584    }
585
586    async fn decrement(&self, key: &str, delta: i64) -> CacheResult<i64> {
587        self.increment(key, -delta).await
588    }
589}
590
591#[cfg(test)]
592mod tests_tiered {
593    use super::*;
594
595    #[tokio::test]
596    async fn test_tiered_cache() {
597        let l1 = Arc::new(InMemoryCache::new());
598        let l2 = Arc::new(InMemoryCache::new());
599        let cache = TieredCache::new(l1.clone(), l2.clone());
600
601        // Set value
602        cache.set("test", "value".to_string(), None).await.unwrap();
603
604        // Get from L1
605        let value = l1.get_json("test").await.unwrap();
606        assert!(value.is_some());
607
608        // Get from tiered cache
609        let value = cache.get("test").await.unwrap();
610        assert_eq!(value, Some("value".to_string()));
611
612        // Delete
613        cache.delete("test").await.unwrap();
614        let value = cache.get("test").await.unwrap();
615        assert_eq!(value, None);
616    }
617
618    #[tokio::test]
619    async fn test_l2_promotion() {
620        let l1 = Arc::new(InMemoryCache::new());
621        let l2 = Arc::new(InMemoryCache::new());
622        let cache = TieredCache::new(l1.clone(), l2.clone());
623
624        // Set in L2 only
625        l2.set_json("key", "value".to_string(), None).await.unwrap();
626
627        // Get from tiered cache (should promote to L1)
628        let value = cache.get("key").await.unwrap();
629        assert_eq!(value, Some("value".to_string()));
630
631        // Check L1 was populated
632        let l1_value = l1.get_json("key").await.unwrap();
633        assert!(l1_value.is_some());
634    }
635
636    #[tokio::test]
637    async fn test_promotion_uses_fixed_l1_ttl_no_l2_roundtrip() {
638        let l1 = Arc::new(InMemoryCache::new());
639        let l2 = Arc::new(InMemoryCache::new());
640        let config = TieredCacheConfig {
641            l1_promote_ttl: Some(Duration::from_secs(30)),
642            ..TieredCacheConfig::default()
643        };
644        let cache = TieredCache::with_config(l1.clone(), l2.clone(), config);
645
646        // Set in L2 only, with NO TTL. The old implementation would have read
647        // L2's (absent) TTL and stored L1 without expiry; the new one applies
648        // the fixed `l1_promote_ttl` regardless of L2's TTL.
649        l2.set_json("key", "value".to_string(), None).await.unwrap();
650
651        // Trigger promotion.
652        let value = cache.get("key").await.unwrap();
653        assert_eq!(value, Some("value".to_string()));
654
655        // L1 entry should carry the fixed promote TTL (<= 30s and > 0), proving
656        // it was derived from config, not from L2's (missing) TTL.
657        let l1_ttl = l1.ttl("key").await.unwrap();
658        let l1_ttl = l1_ttl.expect("promoted L1 entry should have a TTL");
659        assert!(l1_ttl > Duration::from_secs(0));
660        assert!(l1_ttl <= Duration::from_secs(30));
661    }
662
663    #[tokio::test]
664    async fn test_promotion_with_no_l1_ttl_stores_without_expiry() {
665        let l1 = Arc::new(InMemoryCache::new());
666        let l2 = Arc::new(InMemoryCache::new());
667        let config = TieredCacheConfig {
668            l1_promote_ttl: None,
669            ..TieredCacheConfig::default()
670        };
671        let cache = TieredCache::with_config(l1.clone(), l2.clone(), config);
672
673        l2.set_json("key", "value".to_string(), None).await.unwrap();
674        let _ = cache.get("key").await.unwrap();
675
676        // No promote TTL configured -> promoted entry has no expiry.
677        assert_eq!(l1.ttl("key").await.unwrap(), None);
678        assert!(l1.get_json("key").await.unwrap().is_some());
679    }
680
681    /// Regression: `stats()` must report live hit/miss/promotion totals, not
682    /// merely echo the configuration booleans.
683    #[tokio::test]
684    async fn test_stats_track_hits_misses_promotions() {
685        let l1 = Arc::new(InMemoryCache::new());
686        let l2 = Arc::new(InMemoryCache::new());
687        let cache = TieredCache::new(l1.clone(), l2.clone());
688
689        // Miss: absent from both tiers.
690        assert_eq!(cache.get("absent").await.unwrap(), None);
691
692        // Write-through populates L1; the read is an L1 hit.
693        cache.set("k", "v".to_string(), None).await.unwrap();
694        assert_eq!(cache.get("k").await.unwrap(), Some("v".to_string()));
695
696        // L2-only key: an L2 hit that also promotes into L1.
697        l2.set_json("only2", "v2".to_string(), None).await.unwrap();
698        assert_eq!(cache.get("only2").await.unwrap(), Some("v2".to_string()));
699
700        let stats = cache.stats().await;
701        assert_eq!(stats.misses, 1, "one miss expected");
702        assert_eq!(stats.l1_hits, 1, "one L1 hit expected");
703        assert_eq!(stats.l2_hits, 1, "one L2 hit expected");
704        assert_eq!(stats.promotions, 1, "one promotion expected");
705    }
706
707    /// Regression: expired L1 entries must actually be removed from the backing
708    /// map on read, not just reported as `None` while lingering forever.
709    #[tokio::test(start_paused = true)]
710    async fn test_l1_expired_entries_are_evicted_on_read() {
711        let cache = InMemoryCache::new();
712        cache
713            .set_json("k", "v".to_string(), Some(Duration::from_secs(1)))
714            .await
715            .unwrap();
716        assert_eq!(cache.len().await, 1);
717
718        tokio::time::advance(Duration::from_secs(2)).await;
719
720        assert_eq!(cache.get_json("k").await.unwrap(), None);
721        assert_eq!(
722            cache.len().await,
723            0,
724            "expired entry must be evicted from the map, not retained"
725        );
726    }
727
728    /// Regression: the map must stay bounded — admitting a new key when full
729    /// evicts an existing entry instead of growing without limit.
730    #[tokio::test]
731    async fn test_l1_capacity_bound_is_enforced() {
732        let cache = InMemoryCache::with_capacity(2);
733        cache.set_json("a", "1".to_string(), None).await.unwrap();
734        cache.set_json("b", "2".to_string(), None).await.unwrap();
735        cache.set_json("c", "3".to_string(), None).await.unwrap();
736
737        assert!(
738            cache.len().await <= 2,
739            "cache must not exceed its configured capacity of 2, got {}",
740            cache.len().await
741        );
742        // The most recently written key must survive.
743        assert_eq!(cache.get_json("c").await.unwrap(), Some("3".to_string()));
744    }
745
746    /// A full cache prefers to reclaim expired entries before evicting a live
747    /// one, so unexpired keys survive when there is expired garbage to drop.
748    #[tokio::test(start_paused = true)]
749    async fn test_capacity_prefers_reclaiming_expired() {
750        let cache = InMemoryCache::with_capacity(2);
751        cache
752            .set_json("short", "x".to_string(), Some(Duration::from_secs(1)))
753            .await
754            .unwrap();
755        cache.set_json("keep", "y".to_string(), None).await.unwrap();
756
757        tokio::time::advance(Duration::from_secs(2)).await;
758
759        // Admitting "new" should reclaim the expired "short" rather than "keep".
760        cache.set_json("new", "z".to_string(), None).await.unwrap();
761        assert!(cache.len().await <= 2);
762        assert_eq!(cache.get_json("keep").await.unwrap(), Some("y".to_string()));
763        assert_eq!(cache.get_json("new").await.unwrap(), Some("z".to_string()));
764    }
765
766    /// The heap-ordered eviction must pick the same victim the old linear
767    /// `min_by_key` scan did: among live entries, the one nearest to expiry.
768    #[tokio::test(start_paused = true)]
769    async fn test_evicts_the_entry_nearest_to_expiry() {
770        let cache = InMemoryCache::with_capacity(3);
771        cache
772            .set_json("far", "1".to_string(), Some(Duration::from_secs(300)))
773            .await
774            .unwrap();
775        cache
776            .set_json("soon", "2".to_string(), Some(Duration::from_secs(10)))
777            .await
778            .unwrap();
779        cache
780            .set_json("mid", "3".to_string(), Some(Duration::from_secs(60)))
781            .await
782            .unwrap();
783
784        // Full and nothing has expired yet, so a new key evicts a live one.
785        cache.set_json("new", "4".to_string(), None).await.unwrap();
786
787        assert_eq!(cache.len().await, 3);
788        assert_eq!(
789            cache.get_json("soon").await.unwrap(),
790            None,
791            "the soonest-to-expire entry must be the victim"
792        );
793        assert_eq!(cache.get_json("far").await.unwrap(), Some("1".to_string()));
794        assert_eq!(cache.get_json("mid").await.unwrap(), Some("3".to_string()));
795        assert_eq!(cache.get_json("new").await.unwrap(), Some("4".to_string()));
796    }
797
798    /// Entries without a TTL are evicted only once no TTL-carrying entry is
799    /// left, matching the documented policy.
800    #[tokio::test(start_paused = true)]
801    async fn test_entries_without_ttl_are_evicted_last() {
802        let cache = InMemoryCache::with_capacity(2);
803        cache
804            .set_json("nottl", "1".to_string(), None)
805            .await
806            .unwrap();
807        cache
808            .set_json("ttl", "2".to_string(), Some(Duration::from_secs(300)))
809            .await
810            .unwrap();
811
812        cache.set_json("new", "3".to_string(), None).await.unwrap();
813
814        assert_eq!(
815            cache.get_json("ttl").await.unwrap(),
816            None,
817            "a TTL-carrying entry must be evicted before an unexpiring one"
818        );
819        assert_eq!(
820            cache.get_json("nottl").await.unwrap(),
821            Some("1".to_string())
822        );
823
824        // With no TTL-carrying entry left, the oldest unexpiring one goes.
825        cache
826            .set_json("newest", "4".to_string(), None)
827            .await
828            .unwrap();
829        assert_eq!(cache.get_json("nottl").await.unwrap(), None);
830        assert_eq!(cache.get_json("new").await.unwrap(), Some("3".to_string()));
831        assert_eq!(
832            cache.get_json("newest").await.unwrap(),
833            Some("4".to_string())
834        );
835    }
836
837    /// `expire()` re-registers the key in the expiry ordering, so a key given a
838    /// TTL after the fact is still evicted ahead of unexpiring entries.
839    #[tokio::test(start_paused = true)]
840    async fn test_expire_updates_eviction_order() {
841        let cache = InMemoryCache::with_capacity(2);
842        cache.set_json("a", "1".to_string(), None).await.unwrap();
843        cache.set_json("b", "2".to_string(), None).await.unwrap();
844
845        // "b" was written second, so FIFO would evict "a" first; giving "b" a
846        // TTL must move it ahead of every unexpiring entry instead.
847        cache.expire("b", Duration::from_secs(300)).await.unwrap();
848        cache.set_json("c", "3".to_string(), None).await.unwrap();
849
850        assert_eq!(cache.get_json("b").await.unwrap(), None);
851        assert_eq!(cache.get_json("a").await.unwrap(), Some("1".to_string()));
852        assert_eq!(cache.get_json("c").await.unwrap(), Some("3".to_string()));
853    }
854
855    /// Lazy deletion must not leak: repeatedly overwriting the same keys leaves
856    /// stale ordering records behind, and compaction has to reclaim them rather
857    /// than let them grow without bound.
858    #[tokio::test(start_paused = true)]
859    async fn test_repeated_overwrites_do_not_grow_ordering_structures() {
860        let cache = InMemoryCache::with_capacity(8);
861
862        for round in 0..500 {
863            for key in ["a", "b", "c", "d"] {
864                cache
865                    .set_json(key, round.to_string(), Some(Duration::from_secs(300)))
866                    .await
867                    .unwrap();
868            }
869        }
870
871        let state = cache.data.read().await;
872        assert_eq!(state.entries.len(), 4);
873        assert!(
874            state.by_expiry.len() + state.without_expiry.len() <= 2 * 16,
875            "stale ordering records must be compacted away, got {} tracked for {} entries",
876            state.by_expiry.len() + state.without_expiry.len(),
877            state.entries.len()
878        );
879    }
880
881    /// Filling a bounded cache with far more distinct keys than it can hold
882    /// must stay correct: the bound holds and the most recent writes survive.
883    #[tokio::test(start_paused = true)]
884    async fn test_admission_churn_keeps_cache_bounded() {
885        let cache = InMemoryCache::with_capacity(64);
886
887        for i in 0..2_000 {
888            cache
889                .set_json(
890                    &format!("k{i}"),
891                    i.to_string(),
892                    // Alternate so both ordering structures are exercised.
893                    if i % 2 == 0 {
894                        Some(Duration::from_secs(300 + i as u64))
895                    } else {
896                        None
897                    },
898                )
899                .await
900                .unwrap();
901        }
902
903        assert_eq!(cache.len().await, 64);
904        assert_eq!(
905            cache.get_json("k1999").await.unwrap(),
906            Some("1999".to_string()),
907            "the most recent write must survive"
908        );
909    }
910}