tibba_cache/two_level_store.rs
1// Copyright 2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{Error, Expired, RedisCache, TtlLruStore};
16use chrono::Utc;
17use serde::{Serialize, de::DeserializeOwned};
18use std::num::NonZeroUsize;
19use std::time::Duration;
20
21type Result<T> = std::result::Result<T, Error>;
22
23/// Calculates TTL (Time-To-Live) aligned to interval boundaries
24/// # Arguments
25/// * `unit` - The base duration unit to align with
26/// # Returns
27/// * Duration - Calculated TTL that aligns with the unit boundaries
28/// # Notes
29/// * If remaining time is less than 1/10 of unit, extends to next interval
30/// * Helps prevent cache stampede by aligning expiration times
31fn get_ttl_by_unit(unit: Duration) -> Duration {
32 let now = Utc::now();
33 // Calculate the remaining time until next interval
34 let seconds = unit.as_secs() - (now.timestamp() as u64 % unit.as_secs());
35 // If less than 1/10 of the unit, extend to next interval
36 if seconds < unit.as_secs() / 10 {
37 return Duration::from_secs(unit.as_secs() + seconds);
38 }
39 Duration::from_secs(seconds)
40}
41
42/// Gets current timestamp in seconds
43/// # Returns
44/// * `i64` - Current Unix timestamp
45fn now_utc() -> i64 {
46 Utc::now().timestamp()
47}
48/// Wrapper struct that adds expiration time to cached data
49/// # Type Parameters
50/// * `T` - The type of data being cached
51#[derive(Clone)]
52struct ExpiredCache<T> {
53 /// The actual cached data
54 data: T,
55 /// Unix timestamp when this cache entry expires
56 expired_at: i64,
57}
58
59impl<T> Expired for ExpiredCache<T> {
60 /// Checks if the cached data has expired
61 /// # Returns
62 /// * `true` - Cache entry has not expired yet
63 /// * `false` - Cache entry has expired
64 fn is_expired(&self) -> bool {
65 now_utc() >= self.expired_at
66 }
67}
68
69/// Two-level cache implementation combining in-memory LRU cache and Redis
70/// # Type Parameters
71/// * `T` - The type of data being cached
72pub struct TwoLevelStore<T> {
73 /// First level: In-memory LRU cache with TTL
74 lru: TtlLruStore<ExpiredCache<T>>,
75 /// Default TTL for cache entries
76 ttl: Duration,
77 /// Second level: Redis cache
78 redis: RedisCache,
79}
80
81impl<T: Clone + Serialize + DeserializeOwned> TwoLevelStore<T> {
82 /// Creates a new TwoLevelStore instance
83 /// # Arguments
84 /// * `cache` - Redis cache instance for second level storage
85 /// * `size` - Maximum number of entries in the LRU cache
86 /// * `ttl` - Default time-to-live for cache entries
87 /// # Returns
88 /// * New TwoLevelStore instance
89 pub fn new(cache: RedisCache, size: NonZeroUsize, ttl: Duration) -> Self {
90 TwoLevelStore {
91 lru: TtlLruStore::new(size),
92 ttl,
93 redis: cache,
94 }
95 }
96 async fn fill_lru(&self, key: &str, value: T, ttl: Duration) {
97 if ttl.is_zero() {
98 return;
99 }
100 let expired_at = now_utc() + ttl.as_secs() as i64;
101 let data = ExpiredCache {
102 data: value,
103 expired_at,
104 };
105 self.lru.set(key, data).await;
106 }
107
108 /// Stores a value in both cache levels
109 /// # Arguments
110 /// * `key` - The key under which to store the value
111 /// * `value` - The value to store
112 /// # Returns
113 /// * `Ok(())` - Successfully stored in both caches
114 /// * `Err(Error)` - Failed to store in Redis
115 /// # Notes
116 /// * Calculates TTL aligned with interval boundaries
117 /// * Stores in Redis first, then LRU cache
118 pub async fn set(&self, key: &str, value: T) -> Result<()> {
119 // Calculate the remaining time
120 let ttl = get_ttl_by_unit(self.ttl);
121
122 // Set redis cache first
123 self.redis.set_struct(key, &value, Some(ttl)).await?;
124 self.fill_lru(key, value, ttl).await;
125
126 Ok(())
127 }
128
129 /// Retrieves a value from the cache
130 /// # Arguments
131 /// * `key` - The key to look up
132 /// # Returns
133 /// * `Ok(Some(T))` - Value found in either cache level
134 /// * `Ok(None)` - Value not found in either cache
135 /// * `Err(Error)` - Redis operation failed
136 /// # Notes
137 /// * Checks LRU cache first
138 /// * If not in LRU, checks Redis and updates LRU if found
139 /// * Only updates LRU if Redis TTL is within expected range
140 pub async fn get(&self, key: &str) -> Result<Option<T>> {
141 // Try LRU cache first (get ensures it is not expired)
142 if let Some(value) = self.lru.get(key).await {
143 return Ok(Some(value.data));
144 }
145 // Try Redis if not in LRU
146 let result: Option<T> = self.redis.get_struct(key).await?;
147 // If found in Redis, potentially update LRU
148 if let Some(ref value) = result {
149 let ttl = get_ttl_by_unit(self.ttl);
150 // Only cache in LRU if TTL is within expected range
151 // Prevents caching nearly-expired values
152 if ttl <= self.ttl {
153 self.fill_lru(key, value.clone(), ttl).await;
154 }
155 }
156 Ok(result)
157 }
158}