1use 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
13pub struct TieredCache<L1, L2>
15where
16 L1: CacheStore,
17 L2: CacheStore,
18{
19 l1: Arc<L1>,
21
22 l2: Arc<L2>,
24
25 config: TieredCacheConfig,
27
28 metrics: Arc<TieredMetrics>,
30}
31
32#[derive(Debug, Default)]
34struct TieredMetrics {
35 l1_hits: AtomicU64,
36 l2_hits: AtomicU64,
37 misses: AtomicU64,
38 promotions: AtomicU64,
39}
40
41#[derive(Debug, Clone)]
43pub struct TieredCacheConfig {
44 pub enable_l1: bool,
46
47 pub enable_l2: bool,
49
50 pub write_through: bool,
52
53 pub promote_to_l1: bool,
55
56 pub l1_ttl_fraction: f64,
58
59 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_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 pub fn new(l1: Arc<L1>, l2: Arc<L2>) -> Self {
105 Self::with_config(l1, l2, TieredCacheConfig::default())
106 }
107
108 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 pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
120 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 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 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 pub async fn set(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
153 if self.config.enable_l2 {
155 self.l2.set_json(key, value.clone(), ttl).await?;
156 }
157
158 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 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 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 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 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#[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 pub l1_hits: u64,
262 pub l2_hits: u64,
264 pub misses: u64,
266 pub promotions: u64,
268}
269
270pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
274
275pub struct InMemoryCache {
293 data: Arc<RwLock<CacheState>>,
294 max_entries: usize,
296}
297
298#[derive(Default)]
306struct CacheState {
307 entries: HashMap<String, CacheEntry>,
308 by_expiry: BinaryHeap<Reverse<(tokio::time::Instant, String)>>,
310 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 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 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 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 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 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 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 pub fn new() -> Self {
416 Self::with_capacity(DEFAULT_MAX_ENTRIES)
417 }
418
419 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 pub async fn len(&self) -> usize {
433 self.data.read().await.entries.len()
434 }
435
436 pub async fn is_empty(&self) -> bool {
438 self.data.read().await.entries.is_empty()
439 }
440
441 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 {
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 }
471 _ => return Ok(Some(entry.value.clone())),
472 },
473 }
474 }
475
476 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 if self.max_entries != 0
499 && data.entries.len() >= self.max_entries
500 && !data.entries.contains_key(key)
501 {
502 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 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 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 cache.set("test", "value".to_string(), None).await.unwrap();
603
604 let value = l1.get_json("test").await.unwrap();
606 assert!(value.is_some());
607
608 let value = cache.get("test").await.unwrap();
610 assert_eq!(value, Some("value".to_string()));
611
612 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 l2.set_json("key", "value".to_string(), None).await.unwrap();
626
627 let value = cache.get("key").await.unwrap();
629 assert_eq!(value, Some("value".to_string()));
630
631 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 l2.set_json("key", "value".to_string(), None).await.unwrap();
650
651 let value = cache.get("key").await.unwrap();
653 assert_eq!(value, Some("value".to_string()));
654
655 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 assert_eq!(l1.ttl("key").await.unwrap(), None);
678 assert!(l1.get_json("key").await.unwrap().is_some());
679 }
680
681 #[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 assert_eq!(cache.get("absent").await.unwrap(), None);
691
692 cache.set("k", "v".to_string(), None).await.unwrap();
694 assert_eq!(cache.get("k").await.unwrap(), Some("v".to_string()));
695
696 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 #[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 #[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 assert_eq!(cache.get_json("c").await.unwrap(), Some("3".to_string()));
744 }
745
746 #[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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 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}