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}