1use 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
12pub struct TieredCache<L1, L2>
14where
15 L1: CacheStore,
16 L2: CacheStore,
17{
18 l1: Arc<L1>,
20
21 l2: Arc<L2>,
23
24 config: TieredCacheConfig,
26
27 metrics: Arc<TieredMetrics>,
29}
30
31#[derive(Debug, Default)]
33struct TieredMetrics {
34 l1_hits: AtomicU64,
35 l2_hits: AtomicU64,
36 misses: AtomicU64,
37 promotions: AtomicU64,
38}
39
40#[derive(Debug, Clone)]
42pub struct TieredCacheConfig {
43 pub enable_l1: bool,
45
46 pub enable_l2: bool,
48
49 pub write_through: bool,
51
52 pub promote_to_l1: bool,
54
55 pub l1_ttl_fraction: f64,
57
58 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_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 pub fn new(l1: Arc<L1>, l2: Arc<L2>) -> Self {
104 Self::with_config(l1, l2, TieredCacheConfig::default())
105 }
106
107 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 pub async fn get(&self, key: &str) -> CacheResult<Option<String>> {
119 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 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 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 pub async fn set(&self, key: &str, value: String, ttl: Option<Duration>) -> CacheResult<()> {
152 if self.config.enable_l2 {
154 self.l2.set_json(key, value.clone(), ttl).await?;
155 }
156
157 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 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 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 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 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#[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 pub l1_hits: u64,
261 pub l2_hits: u64,
263 pub misses: u64,
265 pub promotions: u64,
267}
268
269pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
273
274pub struct InMemoryCache {
281 data: Arc<RwLock<HashMap<String, CacheEntry>>>,
282 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 pub fn new() -> Self {
295 Self::with_capacity(DEFAULT_MAX_ENTRIES)
296 }
297
298 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 pub async fn len(&self) -> usize {
312 self.data.read().await.len()
313 }
314
315 pub async fn is_empty(&self) -> bool {
317 self.data.read().await.is_empty()
318 }
319
320 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 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 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 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 {
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 }
373 _ => return Ok(Some(entry.value.clone())),
374 },
375 }
376 }
377
378 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 if self.max_entries != 0 && data.len() >= self.max_entries && !data.contains_key(key) {
400 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 cache.set("test", "value".to_string(), None).await.unwrap();
474
475 let value = l1.get_json("test").await.unwrap();
477 assert!(value.is_some());
478
479 let value = cache.get("test").await.unwrap();
481 assert_eq!(value, Some("value".to_string()));
482
483 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 l2.set_json("key", "value".to_string(), None).await.unwrap();
497
498 let value = cache.get("key").await.unwrap();
500 assert_eq!(value, Some("value".to_string()));
501
502 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 l2.set_json("key", "value".to_string(), None).await.unwrap();
521
522 let value = cache.get("key").await.unwrap();
524 assert_eq!(value, Some("value".to_string()));
525
526 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 assert_eq!(l1.ttl("key").await.unwrap(), None);
549 assert!(l1.get_json("key").await.unwrap().is_some());
550 }
551
552 #[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 assert_eq!(cache.get("absent").await.unwrap(), None);
562
563 cache.set("k", "v".to_string(), None).await.unwrap();
565 assert_eq!(cache.get("k").await.unwrap(), Some("v".to_string()));
566
567 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 #[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 #[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 assert_eq!(cache.get_json("c").await.unwrap(), Some("3".to_string()));
615 }
616
617 #[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 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}