Skip to main content

pingap_core/
ttl_lru_limit.rs

1// Copyright 2024-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::{LOG_TARGET, now_ms};
16use ahash::RandomState;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
19use std::time::Duration;
20use tinyufo::TinyUfo;
21use tracing::debug;
22
23#[derive(Debug, Default)]
24struct TtlLimit {
25    count: AtomicUsize,
26    /// Start of the current window. Zero once the window expired and the
27    /// entry was reset; the next `inc` opens a new one.
28    created_at: AtomicU64,
29}
30
31pub struct TtlLruLimit {
32    ttl: u64,
33    /// Keys are stored as stable hashes so `validate`/`inc` can look up with
34    /// `&str` without allocating a `String` on every call. TinyUFO itself only
35    /// indexes by `hash_one(key)`, so a `u64` key is equivalent for lookup.
36    /// Two keys that hash alike share a counter, which only makes the limit
37    /// stricter for both.
38    ///
39    /// Values are shared, so `inc` bumps the counter in place instead of
40    /// reading, adding and writing back: concurrent increments on one key do
41    /// not lose each other.
42    ufo: TinyUfo<u64, Arc<TtlLimit>>,
43    max: usize,
44    hasher: RandomState,
45}
46
47impl TtlLruLimit {
48    /// Creates a new TTL-based LRU limit with the specified parameters.
49    ///
50    /// # Arguments
51    ///
52    /// * `size` - The maximum number of entries to store in the LRU cache
53    /// * `ttl` - The time-to-live duration after which entries are considered expired
54    /// * `max` - The maximum count allowed per key within the TTL window
55    pub fn new(size: usize, ttl: Duration, max: usize) -> Self {
56        Self {
57            ttl: ttl.as_millis() as u64,
58            max,
59            ufo: TinyUfo::new(size, size),
60            hasher: RandomState::new(),
61        }
62    }
63    /// Creates a new compact TTL-based LRU limit with the specified parameters.
64    ///
65    /// # Arguments
66    ///
67    /// * `size` - The maximum number of entries to store in the LRU cache
68    /// * `ttl` - The time-to-live duration after which entries are considered expired
69    pub fn new_compact(size: usize, ttl: Duration, max: usize) -> Self {
70        Self {
71            ttl: ttl.as_millis() as u64,
72            max,
73            ufo: TinyUfo::new_compact(size, size),
74            hasher: RandomState::new(),
75        }
76    }
77
78    #[inline]
79    fn hash_key(&self, key: &str) -> u64 {
80        self.hasher.hash_one(key)
81    }
82
83    /// Validates whether a key has not exceeded its rate limit.
84    ///
85    /// # Arguments
86    ///
87    /// * `key` - The key to validate
88    ///
89    /// # Returns
90    ///
91    /// Returns `true` if the key is within its limit or has expired, `false` otherwise.
92    pub fn validate(&self, key: &str) -> bool {
93        let key = self.hash_key(key);
94        let Some(value) = self.ufo.get(&key) else {
95            return true;
96        };
97        debug!(
98            target: LOG_TARGET,
99            key,
100            ?value,
101            "ttl lru limit"
102        );
103        let created_at = value.created_at.load(Ordering::Relaxed);
104        // Reset and waiting for the next `inc` to open a window.
105        if created_at == 0 {
106            return true;
107        }
108        if now_ms().saturating_sub(created_at) > self.ttl {
109            // Expired: reset in place, TinyUFO has no remove. An `inc` that
110            // lands between the two stores is counted against the next
111            // window, or lost; either way at most one off.
112            value.created_at.store(0, Ordering::Relaxed);
113            value.count.store(0, Ordering::Relaxed);
114            return true;
115        }
116        value.count.load(Ordering::Relaxed) < self.max
117    }
118    /// Increments the counter for the specified key.
119    /// If the key doesn't exist, creates a new entry with count 1.
120    /// If the key exists but was reset (count = 0), updates its creation timestamp.
121    ///
122    /// # Arguments
123    ///
124    /// * `key` - The key to increment
125    pub fn inc(&self, key: &str) {
126        let key = self.hash_key(key);
127        if let Some(value) = self.ufo.get(&key) {
128            // A reset entry opens a new window on its first increment.
129            let _ = value.created_at.compare_exchange(
130                0,
131                now_ms(),
132                Ordering::Relaxed,
133                Ordering::Relaxed,
134            );
135            value.count.fetch_add(1, Ordering::Relaxed);
136            return;
137        }
138        self.ufo.put(
139            key,
140            Arc::new(TtlLimit {
141                count: AtomicUsize::new(1),
142                created_at: AtomicU64::new(now_ms()),
143            }),
144            1,
145        );
146    }
147}
148
149#[cfg(test)]
150mod test {
151    use super::TtlLruLimit;
152    use pretty_assertions::assert_eq;
153    use std::sync::Arc;
154    use std::time::Duration;
155
156    #[test]
157    fn test_ttl_lru_limit() {
158        for limit in [
159            TtlLruLimit::new(5, Duration::from_millis(500), 3),
160            TtlLruLimit::new_compact(5, Duration::from_millis(500), 3),
161        ] {
162            let key = "abc";
163            assert_eq!(true, limit.validate(key));
164            limit.inc(key);
165            limit.inc(key);
166            assert_eq!(true, limit.validate(key));
167            limit.inc(key);
168            assert_eq!(false, limit.validate(key));
169            std::thread::sleep(Duration::from_millis(600));
170            assert_eq!(true, limit.validate(key));
171            // The expired window was reset; the next increments open a new
172            // one that fills up again.
173            limit.inc(key);
174            limit.inc(key);
175            limit.inc(key);
176            assert_eq!(false, limit.validate(key));
177        }
178    }
179
180    /// Concurrent increments on one key must all count: the limit is
181    /// reached exactly when the increments add up to it.
182    #[test]
183    fn test_concurrent_inc_loses_nothing() {
184        let limit = Arc::new(TtlLruLimit::new(5, Duration::from_secs(10), 800));
185        // The first increment inserts the entry; do it alone so the threads
186        // below all bump the same counter.
187        limit.inc("abc");
188        let threads: Vec<_> = (0..8)
189            .map(|_| {
190                let limit = limit.clone();
191                std::thread::spawn(move || {
192                    for _ in 0..100 {
193                        limit.inc("abc");
194                    }
195                })
196            })
197            .collect();
198        for thread in threads {
199            thread.join().expect("thread");
200        }
201        // 801 >= 800: over the limit, which a lost increment would hide.
202        assert_eq!(false, limit.validate("abc"));
203    }
204}