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}