Skip to main content

ntex_basicauth/
cache.rs

1//! Auth cache implementation with TTL and size management
2
3use 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/// Cache entry with TTL support
12#[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    /// Update access info
47    pub fn mark_accessed(&mut self) {
48        self.access_count += 1;
49        self.last_accessed = current_timestamp();
50    }
51
52    /// Calculate entry hotness score (for cleanup decisions)
53    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/// Cache configuration
63#[derive(Debug, Clone)]
64pub struct CacheConfig {
65    /// Maximum number of entries
66    pub max_size: usize,
67    /// TTL (seconds)
68    pub ttl_seconds: u64,
69    /// Cleanup interval (seconds)
70    pub cleanup_interval_seconds: u64,
71    /// Enable auto cleanup
72    pub auto_cleanup: bool,
73    /// Soft limit cleanup threshold (start cleanup when exceeded)
74    pub soft_limit_ratio: f64,
75    /// Max entries to clean per batch
76    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,             // 5 minutes
84            cleanup_interval_seconds: 60, // 1 minute
85            auto_cleanup: true,
86            soft_limit_ratio: 0.8, // Start cleanup at 80%
87            cleanup_batch_size: 100,
88        }
89    }
90}
91
92impl CacheConfig {
93    /// Create new default config
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Set max entries
99    pub fn max_size(mut self, size: usize) -> Self {
100        self.max_size = size;
101        self
102    }
103
104    /// Set TTL (seconds)
105    pub fn ttl_seconds(mut self, seconds: u64) -> Self {
106        self.ttl_seconds = seconds;
107        self
108    }
109
110    /// Set TTL (minutes)
111    pub fn ttl_minutes(self, minutes: u64) -> Self {
112        self.ttl_seconds(minutes * 60)
113    }
114
115    /// Set TTL (hours)
116    pub fn ttl_hours(self, hours: u64) -> Self {
117        self.ttl_seconds(hours * 3600)
118    }
119
120    /// Set cleanup interval (seconds)
121    pub fn cleanup_interval_seconds(mut self, seconds: u64) -> Self {
122        self.cleanup_interval_seconds = seconds;
123        self
124    }
125
126    /// Disable auto cleanup
127    pub fn disable_auto_cleanup(mut self) -> Self {
128        self.auto_cleanup = false;
129        self
130    }
131
132    /// Set soft limit ratio (start cleanup when exceeded)
133    pub fn soft_limit_ratio(mut self, ratio: f64) -> Self {
134        self.soft_limit_ratio = ratio;
135        self
136    }
137
138    /// Set max entries to clean per batch
139    pub fn cleanup_batch_size(mut self, size: usize) -> Self {
140        self.cleanup_batch_size = size;
141        self
142    }
143
144    /// Validate config
145    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
170/// Auth cache with TTL and auto cleanup
171pub struct AuthCache {
172    cache: Arc<DashMap<[u8; 32], CacheEntry>>,
173    config: CacheConfig,
174    stats: CacheStatistics,
175    // Held to keep the background cleanup task alive. Wrapped in a `Mutex` so
176    // that `AuthCache` stays `Send + Sync` even though the runtime's
177    // `JoinHandle` is `Send` but not `Sync` (the handle is never accessed).
178    _cleanup_handle: Mutex<Option<ntex::rt::JoinHandle<()>>>,
179}
180
181impl AuthCache {
182    /// Create a new auth cache instance
183    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    /// Start background cleanup task
208    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                // Clean up expired entries
220                let expired_count = Self::cleanup_expired(&cache);
221                stats.add_expired_cleaned(expired_count);
222
223                // Check if size cleanup is needed
224                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    /// Get value from cache
234    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); // Release the lock before removing
240                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    /// Insert value into cache
256    pub fn insert(&self, key: [u8; 32], value: bool) -> AuthResult<()> {
257        // Check the size limit before insertion
258        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    /// Remove entry from cache
269    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    /// Force cleanup (sync)
277    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 size exceeds max_size, clean up by hotness
282        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    /// Clear the entire cache
289    pub fn clear(&self) {
290        let count = self.cache.len();
291        self.cache.clear();
292        self.stats.add_cleared(count);
293    }
294
295    /// Get cache statistics
296    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    /// Check if the cache contains a key
331    pub fn contains_key(&self, key: &[u8; 32]) -> bool {
332        self.cache.contains_key(key)
333    }
334
335    /// Get the number of entries in the cache
336    pub fn len(&self) -> usize {
337        self.cache.len()
338    }
339
340    /// Check if the cache is empty
341    pub fn is_empty(&self) -> bool {
342        self.cache.is_empty()
343    }
344
345    /// Get the cache configuration
346    pub fn config(&self) -> &CacheConfig {
347        &self.config
348    }
349
350    /// Cleanup expired entries
351    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    /// Cleanup by hotness score
358    fn cleanup_by_hotness(cache: &DashMap<[u8; 32], CacheEntry>, max_remove: usize) -> u64 {
359        if cache.is_empty() {
360            return 0;
361        }
362
363        // Collect entries and their hotness scores
364        let mut entries: Vec<([u8; 32], f64)> = cache
365            .iter()
366            .map(|item| (*item.key(), item.value().hotness_score()))
367            .collect();
368
369        // Sort by hotness score (ascending)
370        entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
371
372        // Remove the least hot entries
373        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/// Cache statistics
387#[derive(Debug, Clone)]
388pub struct CacheStats {
389    /// Total entries
390    pub total_entries: u64,
391    /// Expired entries
392    pub expired_entries: u64,
393    /// Valid entries
394    pub valid_entries: u64,
395    /// Average entry age (seconds)
396    pub average_age_seconds: u64,
397    /// Minimum entry age (seconds)
398    pub min_age_seconds: u64,
399    /// Maximum entry age (seconds)
400    pub max_age_seconds: u64,
401    /// Memory usage estimate (bytes)
402    pub memory_usage_estimate: usize,
403    /// Hit count
404    pub hit_count: u64,
405    /// Miss count
406    pub miss_count: u64,
407    /// Total accesses
408    pub total_accesses: u64,
409    /// Insertions
410    pub insertions: u64,
411    /// Removals
412    pub removals: u64,
413    /// Expired cleaned count
414    pub expired_cleaned: u64,
415    /// Size cleaned count
416    pub size_cleaned: u64,
417}
418
419impl CacheStats {
420    /// Hit ratio
421    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    /// Miss ratio
430    pub fn miss_ratio(&self) -> f64 {
431        1.0 - self.hit_ratio()
432    }
433
434    /// Is healthy (hit ratio > 0.8 and expired entries < 1/4 of total)
435    pub fn is_healthy(&self) -> bool {
436        self.hit_ratio() > 0.8 && self.expired_entries < self.total_entries / 4
437    }
438
439    /// Efficiency score (hit ratio * (1 - expired ratio))
440    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/// Internal cache statistics structure
453#[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
510/// Get the current timestamp in seconds since UNIX epoch
511fn 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        // Test insert and get
537        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        // Test non-existent key
543        assert_eq!(cache.get(&key2), None);
544
545        // Test remove
546        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        // Wait for expiration
564        sleep(Duration::from_secs(2)).await;
565
566        // Should return None due to expiration
567        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        // Trigger some accesses to generate stats
582        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        // Simulate multiple accesses
598        for _ in 0..10 {
599            entry2.mark_accessed();
600        }
601
602        // entry2 should have higher hotness score
603        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}