Skip to main content

tower_http_cache/backend/
multi_tier.rs

1//! Multi-tier caching backend with automatic promotion.
2//!
3//! This module implements a two-tier caching architecture where frequently
4//! accessed entries are automatically promoted from a slower L2 cache to
5//! a faster L1 cache based on configurable promotion strategies.
6
7use async_trait::async_trait;
8use dashmap::DashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13use super::{CacheBackend, CacheEntry, CacheRead};
14use crate::error::CacheError;
15
16/// Strategy for promoting entries from L2 to L1.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PromotionStrategy {
19    /// Promote after a fixed number of hits
20    HitCount { threshold: u64 },
21
22    /// Promote based on hit rate over time window
23    HitRate { threshold_per_minute: u64 },
24}
25
26impl Default for PromotionStrategy {
27    fn default() -> Self {
28        Self::HitCount { threshold: 3 }
29    }
30}
31
32/// Statistics for a cache tier.
33#[derive(Debug, Clone, Default)]
34pub struct TierStats {
35    pub l1_hits: u64,
36    pub l2_hits: u64,
37    pub misses: u64,
38    pub promotions: u64,
39}
40
41/// Configuration for multi-tier caching.
42#[derive(Debug, Clone)]
43pub struct MultiTierConfig {
44    /// Strategy for promoting entries from L2 to L1
45    pub promotion_strategy: PromotionStrategy,
46
47    /// Whether to write to both tiers on set (true) or L2 only (false)
48    pub write_through: bool,
49
50    /// Don't store entries larger than this in L1 (default: 256KB)
51    /// Large entries are only stored in L2 to prevent L1 pollution
52    pub max_l1_entry_size: Option<usize>,
53}
54
55impl Default for MultiTierConfig {
56    fn default() -> Self {
57        Self {
58            promotion_strategy: PromotionStrategy::default(),
59            write_through: true,
60            max_l1_entry_size: Some(256 * 1024), // 256KB
61        }
62    }
63}
64
65/// Per-key statistics for promotion tracking.
66struct KeyStats {
67    l2_hits: AtomicU64,
68}
69
70impl KeyStats {
71    fn new() -> Self {
72        Self {
73            l2_hits: AtomicU64::new(0),
74        }
75    }
76
77    fn record_hit(&self) -> u64 {
78        self.l2_hits.fetch_add(1, Ordering::Relaxed) + 1
79    }
80
81    fn reset(&self) {
82        self.l2_hits.store(0, Ordering::Relaxed);
83    }
84
85    fn hits(&self) -> u64 {
86        self.l2_hits.load(Ordering::Relaxed)
87    }
88}
89
90/// Multi-tier cache backend combining fast L1 and persistent L2.
91///
92/// The multi-tier backend automatically promotes frequently accessed entries
93/// from L2 to L1 based on the configured promotion strategy.
94#[derive(Clone)]
95pub struct MultiTierBackend<L1, L2> {
96    l1: L1,
97    l2: L2,
98    config: MultiTierConfig,
99    key_stats: Arc<DashMap<String, Arc<KeyStats>>>,
100    tier_stats: Arc<TierStats>,
101}
102
103impl<L1, L2> MultiTierBackend<L1, L2>
104where
105    L1: CacheBackend,
106    L2: CacheBackend,
107{
108    /// Creates a new multi-tier backend with default configuration.
109    pub fn new(l1: L1, l2: L2) -> Self {
110        Self {
111            l1,
112            l2,
113            config: MultiTierConfig::default(),
114            key_stats: Arc::new(DashMap::new()),
115            tier_stats: Arc::new(TierStats::default()),
116        }
117    }
118
119    /// Creates a builder for configuring the multi-tier backend.
120    pub fn builder() -> MultiTierBuilder<L1, L2> {
121        MultiTierBuilder::new()
122    }
123
124    /// Returns a reference to the L1 backend.
125    pub fn l1(&self) -> &L1 {
126        &self.l1
127    }
128
129    /// Returns a reference to the L2 backend.
130    pub fn l2(&self) -> &L2 {
131        &self.l2
132    }
133
134    /// Returns a reference to the current tier statistics.
135    pub fn stats(&self) -> &TierStats {
136        &self.tier_stats
137    }
138
139    /// Checks if an entry should be promoted from L2 to L1.
140    fn should_promote(&self, key: &str) -> bool {
141        let stats = self
142            .key_stats
143            .entry(key.to_string())
144            .or_insert_with(|| Arc::new(KeyStats::new()));
145
146        match self.config.promotion_strategy {
147            PromotionStrategy::HitCount { threshold } => stats.hits() >= threshold,
148            PromotionStrategy::HitRate {
149                threshold_per_minute: _,
150            } => {
151                // For simplicity, use hit count for now
152                // A full implementation would track timestamps
153                stats.hits() >= 3
154            }
155        }
156    }
157
158    /// Records a hit on a key and returns the hit count.
159    fn record_hit(&self, key: &str) -> u64 {
160        self.key_stats
161            .entry(key.to_string())
162            .or_insert_with(|| Arc::new(KeyStats::new()))
163            .record_hit()
164    }
165
166    /// Promotes an entry from L2 to L1.
167    #[allow(dead_code)]
168    async fn promote(
169        &self,
170        key: &str,
171        entry: CacheEntry,
172        ttl: Duration,
173        stale_for: Duration,
174    ) -> Result<(), CacheError> {
175        // Store in L1
176        self.l1.set(key.to_string(), entry, ttl, stale_for).await?;
177
178        // Reset promotion counter
179        if let Some(stats) = self.key_stats.get(key) {
180            stats.reset();
181        }
182
183        Ok(())
184    }
185}
186
187#[async_trait]
188impl<L1, L2> CacheBackend for MultiTierBackend<L1, L2>
189where
190    L1: CacheBackend,
191    L2: CacheBackend,
192{
193    async fn get(&self, key: &str) -> Result<Option<CacheRead>, CacheError> {
194        // Try L1 first
195        if let Some(entry) = self.l1.get(key).await? {
196            #[cfg(feature = "metrics")]
197            metrics::counter!("tower_http_cache.tier.l1_hit").increment(1);
198            return Ok(Some(entry));
199        }
200
201        // Try L2
202        if let Some(read) = self.l2.get(key).await? {
203            #[cfg(feature = "metrics")]
204            metrics::counter!("tower_http_cache.tier.l2_hit").increment(1);
205
206            // Record hit and check for promotion
207            self.record_hit(key);
208
209            if self.should_promote(key) {
210                let entry_size = read.entry.body.len();
211
212                // Check if entry is small enough for L1
213                let should_promote_l1 = if let Some(max_size) = self.config.max_l1_entry_size {
214                    entry_size <= max_size
215                } else {
216                    true
217                };
218
219                if should_promote_l1 {
220                    #[cfg(feature = "metrics")]
221                    metrics::counter!("tower_http_cache.tier.promoted").increment(1);
222
223                    // Calculate remaining TTL for promotion
224                    let ttl = if let Some(expires_at) = read.expires_at {
225                        expires_at
226                            .duration_since(std::time::SystemTime::now())
227                            .unwrap_or(Duration::from_secs(60))
228                    } else {
229                        Duration::from_secs(60)
230                    };
231
232                    let stale_for = if let (Some(stale_until), Some(expires_at)) =
233                        (read.stale_until, read.expires_at)
234                    {
235                        stale_until.duration_since(expires_at).unwrap_or_default()
236                    } else {
237                        Duration::ZERO
238                    };
239
240                    // Promote asynchronously (best effort)
241                    let entry = read.entry.clone();
242                    let key = key.to_string();
243                    let l1 = self.l1.clone();
244                    let key_stats = self.key_stats.clone();
245
246                    tokio::spawn(async move {
247                        let _ = l1.set(key.clone(), entry, ttl, stale_for).await;
248                        if let Some(stats) = key_stats.get(&key) {
249                            stats.reset();
250                        }
251                    });
252                } else {
253                    #[cfg(feature = "metrics")]
254                    metrics::counter!("tower_http_cache.tier.promotion_skipped_large").increment(1);
255
256                    #[cfg(feature = "tracing")]
257                    tracing::debug!(
258                        key = %key,
259                        size = entry_size,
260                        max_l1_size = ?self.config.max_l1_entry_size,
261                        "skipping promotion for large entry"
262                    );
263                }
264            }
265
266            return Ok(Some(read));
267        }
268
269        Ok(None)
270    }
271
272    async fn set(
273        &self,
274        key: String,
275        entry: CacheEntry,
276        ttl: Duration,
277        stale_for: Duration,
278    ) -> Result<(), CacheError> {
279        let entry_size = entry.body.len();
280
281        // Always write to L2
282        self.l2
283            .set(key.clone(), entry.clone(), ttl, stale_for)
284            .await?;
285
286        // Optionally write to L1 if write-through is enabled and size is acceptable
287        if self.config.write_through {
288            // Written as a mutable flag rather than an if/else returning bool
289            // literals: with `metrics` and `tracing` both off, the cfg'd
290            // statements vanish and the else branch collapses to a bare
291            // `false`, which trips `clippy::needless_bool`.
292            let mut should_write_l1 = true;
293            if let Some(max_size) = self.config.max_l1_entry_size {
294                if entry_size > max_size {
295                    #[cfg(feature = "metrics")]
296                    metrics::counter!("tower_http_cache.tier.l1_skipped_large").increment(1);
297
298                    #[cfg(feature = "tracing")]
299                    tracing::debug!(
300                        key = %key,
301                        size = entry_size,
302                        max_l1_size = max_size,
303                        "skipping L1 write for large entry"
304                    );
305
306                    should_write_l1 = false;
307                }
308            }
309
310            if should_write_l1 {
311                let _ = self.l1.set(key.clone(), entry, ttl, stale_for).await;
312            }
313        }
314
315        Ok(())
316    }
317
318    async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
319        // Invalidate both tiers
320        let l1_result = self.l1.invalidate(key).await;
321        let l2_result = self.l2.invalidate(key).await;
322
323        // Remove stats
324        self.key_stats.remove(key);
325
326        // Return first error if any
327        l1_result.and(l2_result)
328    }
329
330    async fn get_keys_by_tag(&self, tag: &str) -> Result<Vec<String>, CacheError> {
331        // Query both tiers and merge results
332        let mut keys = self.l1.get_keys_by_tag(tag).await?;
333        let l2_keys = self.l2.get_keys_by_tag(tag).await?;
334
335        // Deduplicate
336        keys.extend(l2_keys);
337        keys.sort();
338        keys.dedup();
339
340        Ok(keys)
341    }
342
343    async fn invalidate_by_tag(&self, tag: &str) -> Result<usize, CacheError> {
344        // Invalidate in both tiers
345        let l1_count = self.l1.invalidate_by_tag(tag).await?;
346        let l2_count = self.l2.invalidate_by_tag(tag).await?;
347
348        Ok(l1_count + l2_count)
349    }
350
351    async fn list_tags(&self) -> Result<Vec<String>, CacheError> {
352        // Merge tags from both tiers
353        let mut tags = self.l1.list_tags().await?;
354        let l2_tags = self.l2.list_tags().await?;
355
356        tags.extend(l2_tags);
357        tags.sort();
358        tags.dedup();
359
360        Ok(tags)
361    }
362}
363
364/// Builder for configuring a multi-tier backend.
365pub struct MultiTierBuilder<L1, L2> {
366    l1: Option<L1>,
367    l2: Option<L2>,
368    config: MultiTierConfig,
369}
370
371impl<L1, L2> MultiTierBuilder<L1, L2> {
372    /// Creates a new builder.
373    pub fn new() -> Self {
374        Self {
375            l1: None,
376            l2: None,
377            config: MultiTierConfig::default(),
378        }
379    }
380
381    /// Sets the L1 (fast) cache backend.
382    pub fn l1(mut self, backend: L1) -> Self {
383        self.l1 = Some(backend);
384        self
385    }
386
387    /// Sets the L2 (persistent) cache backend.
388    pub fn l2(mut self, backend: L2) -> Self {
389        self.l2 = Some(backend);
390        self
391    }
392
393    /// Sets the promotion strategy.
394    pub fn promotion_strategy(mut self, strategy: PromotionStrategy) -> Self {
395        self.config.promotion_strategy = strategy;
396        self
397    }
398
399    /// Sets the promotion threshold for hit-count based promotion.
400    pub fn promotion_threshold(mut self, threshold: u64) -> Self {
401        self.config.promotion_strategy = PromotionStrategy::HitCount { threshold };
402        self
403    }
404
405    /// Enables or disables write-through to L1.
406    pub fn write_through(mut self, enabled: bool) -> Self {
407        self.config.write_through = enabled;
408        self
409    }
410
411    /// Sets the maximum entry size for L1 cache.
412    /// Entries larger than this will only be stored in L2.
413    pub fn max_l1_entry_size(mut self, size: Option<usize>) -> Self {
414        self.config.max_l1_entry_size = size;
415        self
416    }
417
418    /// Builds the multi-tier backend.
419    pub fn build(self) -> MultiTierBackend<L1, L2> {
420        MultiTierBackend {
421            l1: self.l1.expect("L1 backend is required"),
422            l2: self.l2.expect("L2 backend is required"),
423            config: self.config,
424            key_stats: Arc::new(DashMap::new()),
425            tier_stats: Arc::new(TierStats::default()),
426        }
427    }
428}
429
430impl<L1, L2> Default for MultiTierBuilder<L1, L2> {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436#[cfg(all(test, feature = "in-memory"))]
437mod tests {
438    use super::*;
439    use crate::backend::memory::InMemoryBackend;
440    use bytes::Bytes;
441    use http::{StatusCode, Version};
442
443    fn test_entry() -> CacheEntry {
444        CacheEntry::new(
445            StatusCode::OK,
446            Version::HTTP_11,
447            Vec::new(),
448            Bytes::from_static(b"test"),
449        )
450    }
451
452    #[tokio::test]
453    async fn multi_tier_l1_hit() {
454        let l1 = InMemoryBackend::new(100);
455        let l2 = InMemoryBackend::new(1000);
456        let backend = MultiTierBackend::new(l1.clone(), l2);
457
458        // Store in L1
459        l1.set(
460            "key".to_string(),
461            test_entry(),
462            Duration::from_secs(60),
463            Duration::ZERO,
464        )
465        .await
466        .unwrap();
467
468        // Should hit L1
469        let result = backend.get("key").await.unwrap();
470        assert!(result.is_some());
471    }
472
473    #[tokio::test]
474    async fn multi_tier_l2_hit_and_promote() {
475        let l1 = InMemoryBackend::new(100);
476        let l2 = InMemoryBackend::new(1000);
477
478        let backend = MultiTierBackend::builder()
479            .l1(l1.clone())
480            .l2(l2.clone())
481            .promotion_threshold(3)
482            .build();
483
484        // Store in L2 only
485        l2.set(
486            "key".to_string(),
487            test_entry(),
488            Duration::from_secs(60),
489            Duration::ZERO,
490        )
491        .await
492        .unwrap();
493
494        // First few hits should be from L2
495        for _ in 0..3 {
496            let result = backend.get("key").await.unwrap();
497            assert!(result.is_some());
498        }
499
500        // Give promotion task time to complete
501        tokio::time::sleep(Duration::from_millis(50)).await;
502
503        // After promotion threshold, should be in L1
504        let l1_result = l1.get("key").await.unwrap();
505        assert!(l1_result.is_some());
506    }
507
508    #[tokio::test]
509    async fn multi_tier_set_writes_to_both_tiers() {
510        let l1 = InMemoryBackend::new(100);
511        let l2 = InMemoryBackend::new(1000);
512        let backend = MultiTierBackend::builder()
513            .l1(l1.clone())
514            .l2(l2.clone())
515            .write_through(true)
516            .build();
517
518        backend
519            .set(
520                "key".to_string(),
521                test_entry(),
522                Duration::from_secs(60),
523                Duration::ZERO,
524            )
525            .await
526            .unwrap();
527
528        // Should be in both L1 and L2
529        assert!(l1.get("key").await.unwrap().is_some());
530        assert!(l2.get("key").await.unwrap().is_some());
531    }
532
533    #[tokio::test]
534    async fn multi_tier_invalidate_both_tiers() {
535        let l1 = InMemoryBackend::new(100);
536        let l2 = InMemoryBackend::new(1000);
537        let backend = MultiTierBackend::new(l1.clone(), l2.clone());
538
539        // Store in both
540        l1.set(
541            "key".to_string(),
542            test_entry(),
543            Duration::from_secs(60),
544            Duration::ZERO,
545        )
546        .await
547        .unwrap();
548        l2.set(
549            "key".to_string(),
550            test_entry(),
551            Duration::from_secs(60),
552            Duration::ZERO,
553        )
554        .await
555        .unwrap();
556
557        // Invalidate through multi-tier
558        backend.invalidate("key").await.unwrap();
559
560        // Should be removed from both
561        assert!(l1.get("key").await.unwrap().is_none());
562        assert!(l2.get("key").await.unwrap().is_none());
563    }
564
565    #[tokio::test]
566    async fn multi_tier_miss() {
567        let l1 = InMemoryBackend::new(100);
568        let l2 = InMemoryBackend::new(1000);
569        let backend = MultiTierBackend::new(l1, l2);
570
571        let result = backend.get("nonexistent").await.unwrap();
572        assert!(result.is_none());
573    }
574
575    #[tokio::test]
576    async fn promotion_strategy_hit_count() {
577        let strategy = PromotionStrategy::HitCount { threshold: 5 };
578        let l1 = InMemoryBackend::new(100);
579        let l2 = InMemoryBackend::new(1000);
580
581        let backend = MultiTierBackend::builder()
582            .l1(l1.clone())
583            .l2(l2.clone())
584            .promotion_strategy(strategy)
585            .build();
586
587        l2.set(
588            "key".to_string(),
589            test_entry(),
590            Duration::from_secs(60),
591            Duration::ZERO,
592        )
593        .await
594        .unwrap();
595
596        // Hit 5 times to trigger promotion
597        for _ in 0..5 {
598            backend.get("key").await.unwrap();
599        }
600
601        tokio::time::sleep(Duration::from_millis(50)).await;
602
603        // Should be promoted to L1
604        assert!(l1.get("key").await.unwrap().is_some());
605    }
606}