tower_http_cache/backend/
multi_tier.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PromotionStrategy {
19 HitCount { threshold: u64 },
21
22 HitRate { threshold_per_minute: u64 },
24}
25
26impl Default for PromotionStrategy {
27 fn default() -> Self {
28 Self::HitCount { threshold: 3 }
29 }
30}
31
32#[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#[derive(Debug, Clone)]
43pub struct MultiTierConfig {
44 pub promotion_strategy: PromotionStrategy,
46
47 pub write_through: bool,
49
50 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), }
62 }
63}
64
65struct 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#[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 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 pub fn builder() -> MultiTierBuilder<L1, L2> {
121 MultiTierBuilder::new()
122 }
123
124 pub fn l1(&self) -> &L1 {
126 &self.l1
127 }
128
129 pub fn l2(&self) -> &L2 {
131 &self.l2
132 }
133
134 pub fn stats(&self) -> &TierStats {
136 &self.tier_stats
137 }
138
139 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 stats.hits() >= 3
154 }
155 }
156 }
157
158 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 #[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 self.l1.set(key.to_string(), entry, ttl, stale_for).await?;
177
178 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 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 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 self.record_hit(key);
208
209 if self.should_promote(key) {
210 let entry_size = read.entry.body.len();
211
212 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 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 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 self.l2
283 .set(key.clone(), entry.clone(), ttl, stale_for)
284 .await?;
285
286 if self.config.write_through {
288 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 let l1_result = self.l1.invalidate(key).await;
321 let l2_result = self.l2.invalidate(key).await;
322
323 self.key_stats.remove(key);
325
326 l1_result.and(l2_result)
328 }
329
330 async fn get_keys_by_tag(&self, tag: &str) -> Result<Vec<String>, CacheError> {
331 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 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 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 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
364pub struct MultiTierBuilder<L1, L2> {
366 l1: Option<L1>,
367 l2: Option<L2>,
368 config: MultiTierConfig,
369}
370
371impl<L1, L2> MultiTierBuilder<L1, L2> {
372 pub fn new() -> Self {
374 Self {
375 l1: None,
376 l2: None,
377 config: MultiTierConfig::default(),
378 }
379 }
380
381 pub fn l1(mut self, backend: L1) -> Self {
383 self.l1 = Some(backend);
384 self
385 }
386
387 pub fn l2(mut self, backend: L2) -> Self {
389 self.l2 = Some(backend);
390 self
391 }
392
393 pub fn promotion_strategy(mut self, strategy: PromotionStrategy) -> Self {
395 self.config.promotion_strategy = strategy;
396 self
397 }
398
399 pub fn promotion_threshold(mut self, threshold: u64) -> Self {
401 self.config.promotion_strategy = PromotionStrategy::HitCount { threshold };
402 self
403 }
404
405 pub fn write_through(mut self, enabled: bool) -> Self {
407 self.config.write_through = enabled;
408 self
409 }
410
411 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 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 l1.set(
460 "key".to_string(),
461 test_entry(),
462 Duration::from_secs(60),
463 Duration::ZERO,
464 )
465 .await
466 .unwrap();
467
468 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 l2.set(
486 "key".to_string(),
487 test_entry(),
488 Duration::from_secs(60),
489 Duration::ZERO,
490 )
491 .await
492 .unwrap();
493
494 for _ in 0..3 {
496 let result = backend.get("key").await.unwrap();
497 assert!(result.is_some());
498 }
499
500 tokio::time::sleep(Duration::from_millis(50)).await;
502
503 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 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 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 backend.invalidate("key").await.unwrap();
559
560 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 for _ in 0..5 {
598 backend.get("key").await.unwrap();
599 }
600
601 tokio::time::sleep(Duration::from_millis(50)).await;
602
603 assert!(l1.get("key").await.unwrap().is_some());
605 }
606}