1use dashmap::DashMap;
4use ntex::time::interval;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use crate::error::{AuthError, AuthResult};
10
11#[derive(Debug, Clone)]
13pub struct CacheEntry {
14 pub value: bool,
15 pub expires_at: u64,
16 pub created_at: u64,
17 pub access_count: u64,
18 pub last_accessed: u64,
19}
20
21impl CacheEntry {
22 pub fn new(value: bool, ttl_seconds: u64) -> Self {
23 let now = current_timestamp();
24
25 Self {
26 value,
27 expires_at: now + ttl_seconds,
28 created_at: now,
29 access_count: 1,
30 last_accessed: now,
31 }
32 }
33
34 pub fn is_expired(&self) -> bool {
35 current_timestamp() > self.expires_at
36 }
37
38 pub fn age_seconds(&self) -> u64 {
39 current_timestamp().saturating_sub(self.created_at)
40 }
41
42 pub fn time_since_last_access(&self) -> u64 {
43 current_timestamp().saturating_sub(self.last_accessed)
44 }
45
46 pub fn mark_accessed(&mut self) {
48 self.access_count += 1;
49 self.last_accessed = current_timestamp();
50 }
51
52 pub fn hotness_score(&self) -> f64 {
54 let age = self.age_seconds() as f64;
55 let access_rate = self.access_count as f64 / age.max(1.0);
56 let recency = 1.0 / (self.time_since_last_access() as f64 + 1.0);
57
58 access_rate * recency
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct CacheConfig {
65 pub max_size: usize,
67 pub ttl_seconds: u64,
69 pub cleanup_interval_seconds: u64,
71 pub auto_cleanup: bool,
73 pub soft_limit_ratio: f64,
75 pub cleanup_batch_size: usize,
77}
78
79impl Default for CacheConfig {
80 fn default() -> Self {
81 Self {
82 max_size: 1000,
83 ttl_seconds: 300, cleanup_interval_seconds: 60, auto_cleanup: true,
86 soft_limit_ratio: 0.8, cleanup_batch_size: 100,
88 }
89 }
90}
91
92impl CacheConfig {
93 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn max_size(mut self, size: usize) -> Self {
100 self.max_size = size;
101 self
102 }
103
104 pub fn ttl_seconds(mut self, seconds: u64) -> Self {
106 self.ttl_seconds = seconds;
107 self
108 }
109
110 pub fn ttl_minutes(self, minutes: u64) -> Self {
112 self.ttl_seconds(minutes * 60)
113 }
114
115 pub fn ttl_hours(self, hours: u64) -> Self {
117 self.ttl_seconds(hours * 3600)
118 }
119
120 pub fn cleanup_interval_seconds(mut self, seconds: u64) -> Self {
122 self.cleanup_interval_seconds = seconds;
123 self
124 }
125
126 pub fn disable_auto_cleanup(mut self) -> Self {
128 self.auto_cleanup = false;
129 self
130 }
131
132 pub fn soft_limit_ratio(mut self, ratio: f64) -> Self {
134 self.soft_limit_ratio = ratio;
135 self
136 }
137
138 pub fn cleanup_batch_size(mut self, size: usize) -> Self {
140 self.cleanup_batch_size = size;
141 self
142 }
143
144 pub fn validate(&self) -> AuthResult<()> {
146 if self.max_size == 0 {
147 return Err(AuthError::ConfigError(
148 "max_size must be greater than 0".to_string(),
149 ));
150 }
151 if self.ttl_seconds == 0 {
152 return Err(AuthError::ConfigError(
153 "ttl_seconds must be greater than 0".to_string(),
154 ));
155 }
156 if self.cleanup_interval_seconds == 0 {
157 return Err(AuthError::ConfigError(
158 "cleanup_interval_seconds must be greater than 0".to_string(),
159 ));
160 }
161 if !(0.1..=0.95).contains(&self.soft_limit_ratio) {
162 return Err(AuthError::ConfigError(
163 "soft_limit_ratio must be between 0.1 and 0.95".to_string(),
164 ));
165 }
166 Ok(())
167 }
168}
169
170pub struct AuthCache {
172 cache: Arc<DashMap<[u8; 32], CacheEntry>>,
173 config: CacheConfig,
174 stats: CacheStatistics,
175 _cleanup_handle: Mutex<Option<ntex::rt::JoinHandle<()>>>,
179}
180
181impl AuthCache {
182 pub fn new(config: CacheConfig) -> AuthResult<Self> {
184 config.validate()?;
185
186 let cache = Arc::new(DashMap::new());
187 let stats = CacheStatistics::new();
188
189 let cleanup_handle = if config.auto_cleanup {
190 Some(Self::start_cleanup_task(
191 Arc::clone(&cache),
192 config.clone(),
193 stats.clone(),
194 ))
195 } else {
196 None
197 };
198
199 Ok(Self {
200 cache,
201 config,
202 stats,
203 _cleanup_handle: Mutex::new(cleanup_handle),
204 })
205 }
206
207 fn start_cleanup_task(
209 cache: Arc<DashMap<[u8; 32], CacheEntry>>,
210 config: CacheConfig,
211 stats: CacheStatistics,
212 ) -> ntex::rt::JoinHandle<()> {
213 ntex::rt::spawn(async move {
214 let interval = interval(Duration::from_secs(config.cleanup_interval_seconds));
215
216 loop {
217 interval.tick().await;
218
219 let expired_count = Self::cleanup_expired(&cache);
221 stats.add_expired_cleaned(expired_count);
222
223 let soft_limit = (config.max_size as f64 * config.soft_limit_ratio) as usize;
225 if cache.len() > soft_limit {
226 let cleaned = Self::cleanup_by_hotness(&cache, config.cleanup_batch_size);
227 stats.add_size_cleaned(cleaned);
228 }
229 }
230 })
231 }
232
233 pub fn get(&self, key: &[u8; 32]) -> Option<bool> {
235 self.stats.add_access();
236
237 if let Some(mut entry) = self.cache.get_mut(key) {
238 if entry.is_expired() {
239 drop(entry); self.cache.remove(key);
241 self.stats.add_miss();
242 None
243 } else {
244 entry.mark_accessed();
245 let value = entry.value;
246 self.stats.add_hit();
247 Some(value)
248 }
249 } else {
250 self.stats.add_miss();
251 None
252 }
253 }
254
255 pub fn insert(&self, key: [u8; 32], value: bool) -> AuthResult<()> {
257 if self.cache.len() >= self.config.max_size {
259 self.force_cleanup();
260 }
261
262 let entry = CacheEntry::new(value, self.config.ttl_seconds);
263 self.cache.insert(key, entry);
264 self.stats.add_insertion();
265 Ok(())
266 }
267
268 pub fn remove(&self, key: &[u8; 32]) -> Option<bool> {
270 self.cache.remove(key).map(|(_, entry)| {
271 self.stats.add_removal();
272 entry.value
273 })
274 }
275
276 pub fn force_cleanup(&self) {
278 let expired_count = Self::cleanup_expired(&self.cache);
279 self.stats.add_expired_cleaned(expired_count);
280
281 if self.cache.len() > self.config.max_size {
283 let cleaned = Self::cleanup_by_hotness(&self.cache, self.config.cleanup_batch_size);
284 self.stats.add_size_cleaned(cleaned);
285 }
286 }
287
288 pub fn clear(&self) {
290 let count = self.cache.len();
291 self.cache.clear();
292 self.stats.add_cleared(count);
293 }
294
295 pub fn stats(&self) -> CacheStats {
297 let total_entries = self.cache.len() as u64;
298 let expired_count = self.cache.iter().filter(|entry| entry.is_expired()).count() as u64;
299
300 let (total_age, min_age, max_age) = if total_entries > 0 {
301 let ages: Vec<u64> = self.cache.iter().map(|entry| entry.age_seconds()).collect();
302
303 let total: u64 = ages.iter().sum();
304 let min = *ages.iter().min().unwrap_or(&0);
305 let max = *ages.iter().max().unwrap_or(&0);
306
307 (total, min, max)
308 } else {
309 (0, 0, 0)
310 };
311
312 CacheStats {
313 total_entries,
314 expired_entries: expired_count,
315 valid_entries: total_entries - expired_count,
316 average_age_seconds: total_age.checked_div(total_entries).unwrap_or(0),
317 min_age_seconds: min_age,
318 max_age_seconds: max_age,
319 memory_usage_estimate: total_entries as usize * std::mem::size_of::<CacheEntry>(),
320 hit_count: self.stats.hit_count.load(Ordering::Relaxed),
321 miss_count: self.stats.miss_count.load(Ordering::Relaxed),
322 total_accesses: self.stats.total_accesses.load(Ordering::Relaxed),
323 insertions: self.stats.insertions.load(Ordering::Relaxed),
324 removals: self.stats.removals.load(Ordering::Relaxed),
325 expired_cleaned: self.stats.expired_cleaned.load(Ordering::Relaxed),
326 size_cleaned: self.stats.size_cleaned.load(Ordering::Relaxed),
327 }
328 }
329
330 pub fn contains_key(&self, key: &[u8; 32]) -> bool {
332 self.cache.contains_key(key)
333 }
334
335 pub fn len(&self) -> usize {
337 self.cache.len()
338 }
339
340 pub fn is_empty(&self) -> bool {
342 self.cache.is_empty()
343 }
344
345 pub fn config(&self) -> &CacheConfig {
347 &self.config
348 }
349
350 fn cleanup_expired(cache: &DashMap<[u8; 32], CacheEntry>) -> u64 {
352 let initial_len = cache.len();
353 cache.retain(|_, entry| !entry.is_expired());
354 (initial_len - cache.len()) as u64
355 }
356
357 fn cleanup_by_hotness(cache: &DashMap<[u8; 32], CacheEntry>, max_remove: usize) -> u64 {
359 if cache.is_empty() {
360 return 0;
361 }
362
363 let mut entries: Vec<([u8; 32], f64)> = cache
365 .iter()
366 .map(|item| (*item.key(), item.value().hotness_score()))
367 .collect();
368
369 entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
371
372 let remove_count = max_remove.min(entries.len());
374 let mut removed = 0;
375
376 for (key, _) in entries.into_iter().take(remove_count) {
377 if cache.remove(&key).is_some() {
378 removed += 1;
379 }
380 }
381
382 removed
383 }
384}
385
386#[derive(Debug, Clone)]
388pub struct CacheStats {
389 pub total_entries: u64,
391 pub expired_entries: u64,
393 pub valid_entries: u64,
395 pub average_age_seconds: u64,
397 pub min_age_seconds: u64,
399 pub max_age_seconds: u64,
401 pub memory_usage_estimate: usize,
403 pub hit_count: u64,
405 pub miss_count: u64,
407 pub total_accesses: u64,
409 pub insertions: u64,
411 pub removals: u64,
413 pub expired_cleaned: u64,
415 pub size_cleaned: u64,
417}
418
419impl CacheStats {
420 pub fn hit_ratio(&self) -> f64 {
422 if self.total_accesses == 0 {
423 0.0
424 } else {
425 self.hit_count as f64 / self.total_accesses as f64
426 }
427 }
428
429 pub fn miss_ratio(&self) -> f64 {
431 1.0 - self.hit_ratio()
432 }
433
434 pub fn is_healthy(&self) -> bool {
436 self.hit_ratio() > 0.8 && self.expired_entries < self.total_entries / 4
437 }
438
439 pub fn efficiency_score(&self) -> f64 {
441 let hit_ratio = self.hit_ratio();
442 let expired_ratio = if self.total_entries > 0 {
443 self.expired_entries as f64 / self.total_entries as f64
444 } else {
445 0.0
446 };
447
448 hit_ratio * (1.0 - expired_ratio)
449 }
450}
451
452#[derive(Debug, Clone)]
454struct CacheStatistics {
455 hit_count: Arc<AtomicU64>,
456 miss_count: Arc<AtomicU64>,
457 total_accesses: Arc<AtomicU64>,
458 insertions: Arc<AtomicU64>,
459 removals: Arc<AtomicU64>,
460 expired_cleaned: Arc<AtomicU64>,
461 size_cleaned: Arc<AtomicU64>,
462}
463
464impl CacheStatistics {
465 fn new() -> Self {
466 Self {
467 hit_count: Arc::new(AtomicU64::new(0)),
468 miss_count: Arc::new(AtomicU64::new(0)),
469 total_accesses: Arc::new(AtomicU64::new(0)),
470 insertions: Arc::new(AtomicU64::new(0)),
471 removals: Arc::new(AtomicU64::new(0)),
472 expired_cleaned: Arc::new(AtomicU64::new(0)),
473 size_cleaned: Arc::new(AtomicU64::new(0)),
474 }
475 }
476
477 fn add_hit(&self) {
478 self.hit_count.fetch_add(1, Ordering::Relaxed);
479 }
480
481 fn add_miss(&self) {
482 self.miss_count.fetch_add(1, Ordering::Relaxed);
483 }
484
485 fn add_access(&self) {
486 self.total_accesses.fetch_add(1, Ordering::Relaxed);
487 }
488
489 fn add_insertion(&self) {
490 self.insertions.fetch_add(1, Ordering::Relaxed);
491 }
492
493 fn add_removal(&self) {
494 self.removals.fetch_add(1, Ordering::Relaxed);
495 }
496
497 fn add_expired_cleaned(&self, count: u64) {
498 self.expired_cleaned.fetch_add(count, Ordering::Relaxed);
499 }
500
501 fn add_size_cleaned(&self, count: u64) {
502 self.size_cleaned.fetch_add(count, Ordering::Relaxed);
503 }
504
505 fn add_cleared(&self, count: usize) {
506 self.removals.fetch_add(count as u64, Ordering::Relaxed);
507 }
508}
509
510fn current_timestamp() -> u64 {
512 SystemTime::now()
513 .duration_since(UNIX_EPOCH)
514 .unwrap_or_default()
515 .as_secs()
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use tokio::time::{Duration, sleep};
522
523 #[test]
524 fn test_cache_entry() {
525 let entry = CacheEntry::new(true, 60);
526 assert!(entry.value);
527 assert!(!entry.is_expired());
528 assert_eq!(entry.access_count, 1);
529 }
530
531 #[ntex::test]
532 async fn test_cache_basic_operations() {
533 let config = CacheConfig::new().max_size(100).ttl_seconds(60);
534 let cache = AuthCache::new(config).unwrap();
535
536 let key1 = [1u8; 32];
538 let key2 = [2u8; 32];
539 cache.insert(key1, true).unwrap();
540 assert_eq!(cache.get(&key1), Some(true));
541
542 assert_eq!(cache.get(&key2), None);
544
545 assert_eq!(cache.remove(&key1), Some(true));
547 assert_eq!(cache.get(&key1), None);
548 }
549
550 #[tokio::test]
551 async fn test_cache_expiration() {
552 let config = CacheConfig::new()
553 .max_size(100)
554 .ttl_seconds(1)
555 .disable_auto_cleanup();
556
557 let cache = AuthCache::new(config).unwrap();
558
559 let key1 = [1u8; 32];
560 cache.insert(key1, true).unwrap();
561 assert_eq!(cache.get(&key1), Some(true));
562
563 sleep(Duration::from_secs(2)).await;
565
566 assert_eq!(cache.get(&key1), None);
568 }
569
570 #[test]
571 fn test_cache_stats() {
572 let config = CacheConfig::new().disable_auto_cleanup();
573 let cache = AuthCache::new(config).unwrap();
574
575 let key1 = [1u8; 32];
576 let key2 = [2u8; 32];
577 let key3 = [3u8; 32];
578 cache.insert(key1, true).unwrap();
579 cache.insert(key2, false).unwrap();
580
581 cache.get(&key1);
583 cache.get(&key2);
584 cache.get(&key3);
585
586 let stats = cache.stats();
587 assert_eq!(stats.total_entries, 2);
588 assert!(stats.hit_ratio() > 0.0);
589 assert!(stats.efficiency_score() > 0.0);
590 }
591
592 #[test]
593 fn test_hotness_score() {
594 let entry1 = CacheEntry::new(true, 60);
595 let mut entry2 = CacheEntry::new(true, 60);
596
597 for _ in 0..10 {
599 entry2.mark_accessed();
600 }
601
602 assert!(entry2.hotness_score() > entry1.hotness_score());
604 }
605
606 #[test]
607 fn test_config_validation() {
608 assert!(CacheConfig::new().max_size(0).validate().is_err());
609 assert!(CacheConfig::new().ttl_seconds(0).validate().is_err());
610 assert!(CacheConfig::new().soft_limit_ratio(1.5).validate().is_err());
611 assert!(CacheConfig::new().validate().is_ok());
612 }
613}