sentinel_proxy/
cache.rs

1//! HTTP caching infrastructure for Sentinel
2//!
3//! This module provides the foundation for HTTP response caching using
4//! Pingora's cache infrastructure.
5//!
6//! Current features:
7//! - Cache configuration per route
8//! - Cache statistics tracking
9//! - Cache key generation
10//! - TTL calculation from Cache-Control headers
11//! - In-memory cache storage backend (for development/testing)
12//!
13//! # Storage Backends
14//!
15//! The default storage is an in-memory cache suitable for development and
16//! single-instance deployments. For production with large cache sizes or
17//! persistence needs, consider implementing a disk-based storage backend.
18
19use once_cell::sync::{Lazy, OnceCell};
20use parking_lot::RwLock;
21use pingora_cache::eviction::simple_lru::Manager as LruEvictionManager;
22use pingora_cache::lock::CacheLock;
23use pingora_cache::storage::Storage;
24use pingora_cache::MemCache;
25use regex::Regex;
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29use tracing::{debug, info, trace, warn};
30
31use sentinel_config::CacheStorageConfig;
32
33// ============================================================================
34// Cache Configuration
35// ============================================================================
36
37/// Default cache size: 100MB
38const DEFAULT_CACHE_SIZE_BYTES: usize = 100 * 1024 * 1024;
39
40/// Default eviction limit: 100MB
41const DEFAULT_EVICTION_LIMIT_BYTES: usize = 100 * 1024 * 1024;
42
43/// Default lock timeout: 10 seconds
44const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 10;
45
46/// Global cache configuration holder
47///
48/// This should be set during proxy startup, before the cache is accessed.
49/// If not set, default values will be used.
50static CACHE_CONFIG: OnceCell<CacheStorageConfig> = OnceCell::new();
51
52/// Configure the global cache storage settings.
53///
54/// This must be called before the first cache access to take effect.
55/// If called after cache initialization, returns false and logs a warning.
56///
57/// # Example
58/// ```ignore
59/// use sentinel_config::CacheStorageConfig;
60/// use sentinel_proxy::cache::configure_cache;
61///
62/// let config = CacheStorageConfig {
63///     max_size_bytes: 200 * 1024 * 1024, // 200MB
64///     lock_timeout_secs: 15,
65///     ..Default::default()
66/// };
67/// configure_cache(config);
68/// ```
69pub fn configure_cache(config: CacheStorageConfig) -> bool {
70    match CACHE_CONFIG.set(config) {
71        Ok(()) => {
72            info!("Cache storage configured");
73            true
74        }
75        Err(_) => {
76            warn!("Cache already initialized, configuration ignored");
77            false
78        }
79    }
80}
81
82/// Get the current cache configuration
83fn get_cache_config() -> &'static CacheStorageConfig {
84    CACHE_CONFIG.get_or_init(CacheStorageConfig::default)
85}
86
87/// Check if caching is globally enabled
88pub fn is_cache_enabled() -> bool {
89    get_cache_config().enabled
90}
91
92// ============================================================================
93// Static Cache Storage
94// ============================================================================
95
96/// Static in-memory cache storage instance
97///
98/// This provides a `&'static` reference required by Pingora's cache API.
99/// Note: MemCache is marked "for testing only" in pingora-cache. For production
100/// deployments with large cache requirements, consider implementing a disk-based
101/// storage backend.
102static HTTP_CACHE_STORAGE: Lazy<MemCache> = Lazy::new(|| {
103    let config = get_cache_config();
104    info!(
105        cache_size_mb = config.max_size_bytes / 1024 / 1024,
106        backend = ?config.backend,
107        "Initializing HTTP cache storage"
108    );
109    MemCache::new()
110});
111
112/// Static LRU eviction manager for cache entries
113static HTTP_CACHE_EVICTION: Lazy<LruEvictionManager> = Lazy::new(|| {
114    let config = get_cache_config();
115    let limit = config.eviction_limit_bytes.unwrap_or(config.max_size_bytes);
116    info!(
117        eviction_limit_mb = limit / 1024 / 1024,
118        "Initializing HTTP cache eviction manager"
119    );
120    LruEvictionManager::new(limit)
121});
122
123/// Static cache lock for preventing thundering herd
124static HTTP_CACHE_LOCK: Lazy<CacheLock> = Lazy::new(|| {
125    let config = get_cache_config();
126    info!(
127        lock_timeout_secs = config.lock_timeout_secs,
128        "Initializing HTTP cache lock"
129    );
130    CacheLock::new(Duration::from_secs(config.lock_timeout_secs))
131});
132
133/// Get a static reference to the HTTP cache storage
134///
135/// This is used by the ProxyHttp implementation to enable caching.
136pub fn get_cache_storage() -> &'static (dyn Storage + Sync) {
137    &*HTTP_CACHE_STORAGE
138}
139
140/// Get a static reference to the cache eviction manager
141pub fn get_cache_eviction() -> &'static LruEvictionManager {
142    &HTTP_CACHE_EVICTION
143}
144
145/// Get a static reference to the cache lock
146pub fn get_cache_lock() -> &'static CacheLock {
147    &HTTP_CACHE_LOCK
148}
149
150/// Cache configuration for a route
151#[derive(Debug, Clone)]
152pub struct CacheConfig {
153    /// Whether caching is enabled for this route
154    pub enabled: bool,
155    /// Default TTL in seconds if no Cache-Control header
156    pub default_ttl_secs: u64,
157    /// Maximum cacheable response size in bytes
158    pub max_size_bytes: usize,
159    /// Whether to cache private responses
160    pub cache_private: bool,
161    /// Stale-while-revalidate grace period in seconds
162    pub stale_while_revalidate_secs: u64,
163    /// Stale-if-error grace period in seconds
164    pub stale_if_error_secs: u64,
165    /// Methods that are cacheable (GET, HEAD)
166    pub cacheable_methods: Vec<String>,
167    /// Status codes that are cacheable
168    pub cacheable_status_codes: Vec<u16>,
169}
170
171impl Default for CacheConfig {
172    fn default() -> Self {
173        Self {
174            enabled: false, // Disabled by default for safety
175            default_ttl_secs: 3600,
176            max_size_bytes: 10 * 1024 * 1024, // 10MB
177            cache_private: false,
178            stale_while_revalidate_secs: 60,
179            stale_if_error_secs: 300,
180            cacheable_methods: vec!["GET".to_string(), "HEAD".to_string()],
181            cacheable_status_codes: vec![200, 203, 204, 206, 300, 301, 308, 404, 410],
182        }
183    }
184}
185
186/// HTTP cache statistics
187#[derive(Debug, Default)]
188pub struct HttpCacheStats {
189    hits: std::sync::atomic::AtomicU64,
190    misses: std::sync::atomic::AtomicU64,
191    stores: std::sync::atomic::AtomicU64,
192    evictions: std::sync::atomic::AtomicU64,
193}
194
195impl HttpCacheStats {
196    /// Record a cache hit
197    pub fn record_hit(&self) {
198        self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
199    }
200
201    /// Record a cache miss
202    pub fn record_miss(&self) {
203        self.misses
204            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
205    }
206
207    /// Record a cache store
208    pub fn record_store(&self) {
209        self.stores
210            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
211    }
212
213    /// Record an eviction
214    pub fn record_eviction(&self) {
215        self.evictions
216            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
217    }
218
219    /// Get current hit count
220    pub fn hits(&self) -> u64 {
221        self.hits.load(std::sync::atomic::Ordering::Relaxed)
222    }
223
224    /// Get current miss count
225    pub fn misses(&self) -> u64 {
226        self.misses.load(std::sync::atomic::Ordering::Relaxed)
227    }
228
229    /// Get hit ratio (0.0 to 1.0)
230    pub fn hit_ratio(&self) -> f64 {
231        let hits = self.hits() as f64;
232        let total = hits + self.misses() as f64;
233        if total == 0.0 {
234            0.0
235        } else {
236            hits / total
237        }
238    }
239
240    /// Get current store count
241    pub fn stores(&self) -> u64 {
242        self.stores.load(std::sync::atomic::Ordering::Relaxed)
243    }
244
245    /// Get current eviction count
246    pub fn evictions(&self) -> u64 {
247        self.evictions.load(std::sync::atomic::Ordering::Relaxed)
248    }
249}
250
251/// Purge entry with expiration tracking
252#[derive(Debug, Clone)]
253struct PurgeEntry {
254    /// When this purge was registered
255    created_at: Instant,
256    /// Pattern for wildcard matching (None for exact key purges)
257    pattern: Option<String>,
258}
259
260/// Default purge entry lifetime (how long a purge entry stays active)
261const PURGE_ENTRY_LIFETIME: Duration = Duration::from_secs(60);
262
263/// Cache manager for HTTP responses
264///
265/// This provides a foundation for HTTP caching that can be extended
266/// to use pingora-cache's full capabilities when they stabilize.
267pub struct CacheManager {
268    /// Per-route cache configurations
269    route_configs: RwLock<HashMap<String, CacheConfig>>,
270    /// Global cache statistics
271    stats: Arc<HttpCacheStats>,
272    /// Exact keys that have been purged (with timestamp for cleanup)
273    purged_keys: RwLock<HashMap<String, Instant>>,
274    /// Wildcard patterns for purging (with timestamp for cleanup)
275    purge_patterns: RwLock<Vec<PurgeEntry>>,
276    /// Compiled regex patterns for efficient matching
277    compiled_patterns: RwLock<Vec<(Regex, Instant)>>,
278}
279
280impl CacheManager {
281    /// Create a new cache manager
282    pub fn new() -> Self {
283        Self {
284            route_configs: RwLock::new(HashMap::new()),
285            stats: Arc::new(HttpCacheStats::default()),
286            purged_keys: RwLock::new(HashMap::new()),
287            purge_patterns: RwLock::new(Vec::new()),
288            compiled_patterns: RwLock::new(Vec::new()),
289        }
290    }
291
292    /// Get cache statistics
293    pub fn stats(&self) -> Arc<HttpCacheStats> {
294        self.stats.clone()
295    }
296
297    /// Register cache configuration for a route
298    pub fn register_route(&self, route_id: &str, config: CacheConfig) {
299        trace!(
300            route_id = route_id,
301            enabled = config.enabled,
302            default_ttl = config.default_ttl_secs,
303            "Registering cache configuration for route"
304        );
305        self.route_configs
306            .write()
307            .insert(route_id.to_string(), config);
308    }
309
310    /// Get cache configuration for a route
311    pub fn get_route_config(&self, route_id: &str) -> Option<CacheConfig> {
312        self.route_configs.read().get(route_id).cloned()
313    }
314
315    /// Check if caching is enabled for a route
316    pub fn is_enabled(&self, route_id: &str) -> bool {
317        self.route_configs
318            .read()
319            .get(route_id)
320            .map(|c| c.enabled)
321            .unwrap_or(false)
322    }
323
324    /// Generate a cache key string from request info
325    pub fn generate_cache_key(method: &str, host: &str, path: &str, query: Option<&str>) -> String {
326        match query {
327            Some(q) => format!("{}:{}:{}?{}", method, host, path, q),
328            None => format!("{}:{}:{}", method, host, path),
329        }
330    }
331
332    /// Check if a method is cacheable for a route
333    pub fn is_method_cacheable(&self, route_id: &str, method: &str) -> bool {
334        self.route_configs
335            .read()
336            .get(route_id)
337            .map(|c| {
338                c.cacheable_methods
339                    .iter()
340                    .any(|m| m.eq_ignore_ascii_case(method))
341            })
342            .unwrap_or(false)
343    }
344
345    /// Check if a status code is cacheable for a route
346    pub fn is_status_cacheable(&self, route_id: &str, status: u16) -> bool {
347        self.route_configs
348            .read()
349            .get(route_id)
350            .map(|c| c.cacheable_status_codes.contains(&status))
351            .unwrap_or(false)
352    }
353
354    /// Parse max-age from Cache-Control header value
355    pub fn parse_max_age(header_value: &str) -> Option<u64> {
356        // Simple parsing of max-age directive
357        for directive in header_value.split(',') {
358            let directive = directive.trim();
359            if let Some(value) = directive.strip_prefix("max-age=") {
360                if let Ok(secs) = value.trim().parse::<u64>() {
361                    return Some(secs);
362                }
363            }
364            if let Some(value) = directive.strip_prefix("s-maxage=") {
365                if let Ok(secs) = value.trim().parse::<u64>() {
366                    return Some(secs);
367                }
368            }
369        }
370        None
371    }
372
373    /// Check if Cache-Control indicates no caching
374    pub fn is_no_cache(header_value: &str) -> bool {
375        let lower = header_value.to_lowercase();
376        lower.contains("no-store") || lower.contains("no-cache") || lower.contains("private")
377    }
378
379    /// Calculate TTL from Cache-Control or use default
380    pub fn calculate_ttl(&self, route_id: &str, cache_control: Option<&str>) -> Duration {
381        let config = self.get_route_config(route_id).unwrap_or_default();
382
383        if let Some(cc) = cache_control {
384            // Check for no-store or no-cache
385            if Self::is_no_cache(cc) && !config.cache_private {
386                return Duration::ZERO;
387            }
388
389            // Use max-age if present
390            if let Some(max_age) = Self::parse_max_age(cc) {
391                return Duration::from_secs(max_age);
392            }
393        }
394
395        // Fall back to default TTL
396        Duration::from_secs(config.default_ttl_secs)
397    }
398
399    /// Determine if response should be served stale
400    pub fn should_serve_stale(
401        &self,
402        route_id: &str,
403        stale_duration: Duration,
404        is_error: bool,
405    ) -> bool {
406        let config = self.get_route_config(route_id).unwrap_or_default();
407
408        if is_error {
409            stale_duration.as_secs() <= config.stale_if_error_secs
410        } else {
411            stale_duration.as_secs() <= config.stale_while_revalidate_secs
412        }
413    }
414
415    /// Get count of registered routes with caching
416    pub fn route_count(&self) -> usize {
417        self.route_configs.read().len()
418    }
419
420    // ========================================================================
421    // Cache Purge API
422    // ========================================================================
423
424    /// Purge a single cache entry by exact key (path).
425    ///
426    /// Returns the number of entries purged (0 or 1).
427    /// The purge is tracked so that subsequent cache hits for this key
428    /// will be invalidated via `ForcedInvalidationKind`.
429    pub fn purge(&self, path: &str) -> usize {
430        // Generate cache key for common methods
431        // Since we don't know the exact method/host, we purge all variants
432        let keys_to_purge: Vec<String> =
433            vec![format!("GET:*:{}", path), format!("HEAD:*:{}", path)];
434
435        let now = Instant::now();
436        let mut purged = self.purged_keys.write();
437
438        for key in &keys_to_purge {
439            purged.insert(key.clone(), now);
440        }
441
442        // Also add the raw path for flexible matching
443        purged.insert(path.to_string(), now);
444
445        debug!(
446            path = %path,
447            purged_keys = keys_to_purge.len() + 1,
448            "Purged cache entry"
449        );
450
451        self.stats.record_eviction();
452        1
453    }
454
455    /// Purge cache entries matching a wildcard pattern.
456    ///
457    /// Supports glob-style patterns:
458    /// - `*` matches any sequence of characters except `/`
459    /// - `**` matches any sequence of characters including `/`
460    /// - `?` matches any single character
461    ///
462    /// Returns the number of pattern registrations (actual purges happen on cache hit).
463    pub fn purge_wildcard(&self, pattern: &str) -> usize {
464        // Convert glob pattern to regex
465        let regex_pattern = glob_to_regex(pattern);
466
467        match Regex::new(&regex_pattern) {
468            Ok(regex) => {
469                let now = Instant::now();
470
471                // Store the compiled pattern
472                self.compiled_patterns.write().push((regex, now));
473
474                // Store the original pattern for debugging
475                self.purge_patterns.write().push(PurgeEntry {
476                    created_at: now,
477                    pattern: Some(pattern.to_string()),
478                });
479
480                debug!(
481                    pattern = %pattern,
482                    regex = %regex_pattern,
483                    "Registered wildcard cache purge"
484                );
485
486                self.stats.record_eviction();
487                1
488            }
489            Err(e) => {
490                warn!(
491                    pattern = %pattern,
492                    error = %e,
493                    "Failed to compile purge pattern as regex"
494                );
495                0
496            }
497        }
498    }
499
500    /// Check if a cache key should be invalidated due to a purge request.
501    ///
502    /// This is called from `cache_hit_filter` to determine if a cached
503    /// response should be re-fetched from upstream.
504    pub fn should_invalidate(&self, cache_key: &str) -> bool {
505        // First, cleanup expired entries
506        self.cleanup_expired_purges();
507
508        // Check exact key matches
509        {
510            let purged = self.purged_keys.read();
511            if purged.contains_key(cache_key) {
512                trace!(cache_key = %cache_key, "Cache key matches purged key");
513                return true;
514            }
515
516            // Also check if the path portion matches
517            // Cache key format: "METHOD:HOST:PATH" or "METHOD:HOST:PATH?QUERY"
518            if let Some(path) = extract_path_from_cache_key(cache_key) {
519                if purged.contains_key(path) {
520                    trace!(cache_key = %cache_key, path = %path, "Cache path matches purged path");
521                    return true;
522                }
523            }
524        }
525
526        // Check wildcard patterns
527        {
528            let patterns = self.compiled_patterns.read();
529            let path = extract_path_from_cache_key(cache_key).unwrap_or(cache_key);
530
531            for (regex, _) in patterns.iter() {
532                if regex.is_match(path) {
533                    trace!(
534                        cache_key = %cache_key,
535                        path = %path,
536                        pattern = %regex.as_str(),
537                        "Cache key matches purge pattern"
538                    );
539                    return true;
540                }
541            }
542        }
543
544        false
545    }
546
547    /// Remove expired purge entries to prevent memory growth.
548    fn cleanup_expired_purges(&self) {
549        let now = Instant::now();
550
551        // Cleanup exact keys
552        {
553            let mut purged = self.purged_keys.write();
554            purged.retain(|_, created_at| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
555        }
556
557        // Cleanup patterns
558        {
559            let mut patterns = self.purge_patterns.write();
560            patterns.retain(|entry| now.duration_since(entry.created_at) < PURGE_ENTRY_LIFETIME);
561        }
562
563        // Cleanup compiled patterns
564        {
565            let mut compiled = self.compiled_patterns.write();
566            compiled
567                .retain(|(_, created_at)| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
568        }
569    }
570
571    /// Get count of active purge entries (for stats/debugging)
572    pub fn active_purge_count(&self) -> usize {
573        self.purged_keys.read().len() + self.purge_patterns.read().len()
574    }
575
576    /// Clear all purge entries (for testing)
577    #[cfg(test)]
578    pub fn clear_purges(&self) {
579        self.purged_keys.write().clear();
580        self.purge_patterns.write().clear();
581        self.compiled_patterns.write().clear();
582    }
583}
584
585/// Convert a glob-style pattern to a regex pattern.
586///
587/// - `*` becomes `[^/]*` (match any except /)
588/// - `**` becomes `.*` (match anything)
589/// - `?` becomes `.` (match single char)
590/// - Other regex special chars are escaped
591fn glob_to_regex(pattern: &str) -> String {
592    let mut regex = String::with_capacity(pattern.len() * 2);
593    regex.push('^');
594
595    let chars: Vec<char> = pattern.chars().collect();
596    let mut i = 0;
597
598    while i < chars.len() {
599        let c = chars[i];
600        match c {
601            '*' => {
602                // Check for ** (match anything including /)
603                if i + 1 < chars.len() && chars[i + 1] == '*' {
604                    regex.push_str(".*");
605                    i += 2;
606                } else {
607                    // Single * matches anything except /
608                    regex.push_str("[^/]*");
609                    i += 1;
610                }
611            }
612            '?' => {
613                regex.push('.');
614                i += 1;
615            }
616            // Escape regex special characters
617            '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
618                regex.push('\\');
619                regex.push(c);
620                i += 1;
621            }
622            _ => {
623                regex.push(c);
624                i += 1;
625            }
626        }
627    }
628
629    regex.push('$');
630    regex
631}
632
633/// Extract the path portion from a cache key.
634///
635/// Cache key format: "METHOD:HOST:PATH" or "METHOD:HOST:PATH?QUERY"
636fn extract_path_from_cache_key(cache_key: &str) -> Option<&str> {
637    // Find the second colon (after METHOD:HOST:)
638    let mut colon_count = 0;
639    for (i, c) in cache_key.char_indices() {
640        if c == ':' {
641            colon_count += 1;
642            if colon_count == 2 {
643                // Return everything after this colon
644                return Some(&cache_key[i + 1..]);
645            }
646        }
647    }
648    None
649}
650
651impl Default for CacheManager {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn test_cache_key_generation() {
663        let key = CacheManager::generate_cache_key("GET", "example.com", "/api/users", None);
664        assert_eq!(key, "GET:example.com:/api/users");
665
666        let key_with_query = CacheManager::generate_cache_key(
667            "GET",
668            "example.com",
669            "/api/users",
670            Some("page=1&limit=10"),
671        );
672        assert_eq!(key_with_query, "GET:example.com:/api/users?page=1&limit=10");
673    }
674
675    #[test]
676    fn test_cache_config_defaults() {
677        let config = CacheConfig::default();
678        assert!(!config.enabled);
679        assert_eq!(config.default_ttl_secs, 3600);
680        assert!(config.cacheable_methods.contains(&"GET".to_string()));
681        assert!(config.cacheable_status_codes.contains(&200));
682    }
683
684    #[test]
685    fn test_route_config_registration() {
686        let manager = CacheManager::new();
687
688        manager.register_route(
689            "api",
690            CacheConfig {
691                enabled: true,
692                default_ttl_secs: 300,
693                ..Default::default()
694            },
695        );
696
697        assert!(manager.is_enabled("api"));
698        assert!(!manager.is_enabled("unknown"));
699    }
700
701    #[test]
702    fn test_method_cacheability() {
703        let manager = CacheManager::new();
704
705        manager.register_route(
706            "api",
707            CacheConfig {
708                enabled: true,
709                cacheable_methods: vec!["GET".to_string(), "HEAD".to_string()],
710                ..Default::default()
711            },
712        );
713
714        assert!(manager.is_method_cacheable("api", "GET"));
715        assert!(manager.is_method_cacheable("api", "get"));
716        assert!(!manager.is_method_cacheable("api", "POST"));
717    }
718
719    #[test]
720    fn test_parse_max_age() {
721        assert_eq!(CacheManager::parse_max_age("max-age=3600"), Some(3600));
722        assert_eq!(
723            CacheManager::parse_max_age("public, max-age=300"),
724            Some(300)
725        );
726        assert_eq!(
727            CacheManager::parse_max_age("s-maxage=600, max-age=300"),
728            Some(600)
729        );
730        assert_eq!(CacheManager::parse_max_age("no-store"), None);
731    }
732
733    #[test]
734    fn test_is_no_cache() {
735        assert!(CacheManager::is_no_cache("no-store"));
736        assert!(CacheManager::is_no_cache("no-cache"));
737        assert!(CacheManager::is_no_cache("private"));
738        assert!(CacheManager::is_no_cache("private, max-age=300"));
739        assert!(!CacheManager::is_no_cache("public, max-age=3600"));
740    }
741
742    #[test]
743    fn test_cache_stats() {
744        let stats = HttpCacheStats::default();
745
746        stats.record_hit();
747        stats.record_hit();
748        stats.record_miss();
749
750        assert_eq!(stats.hits(), 2);
751        assert_eq!(stats.misses(), 1);
752        assert!((stats.hit_ratio() - 0.666).abs() < 0.01);
753    }
754
755    #[test]
756    fn test_calculate_ttl() {
757        let manager = CacheManager::new();
758        manager.register_route(
759            "api",
760            CacheConfig {
761                enabled: true,
762                default_ttl_secs: 600,
763                ..Default::default()
764            },
765        );
766
767        // Uses max-age from header
768        let ttl = manager.calculate_ttl("api", Some("max-age=3600"));
769        assert_eq!(ttl.as_secs(), 3600);
770
771        // Falls back to default
772        let ttl = manager.calculate_ttl("api", None);
773        assert_eq!(ttl.as_secs(), 600);
774
775        // No-store returns zero
776        let ttl = manager.calculate_ttl("api", Some("no-store"));
777        assert_eq!(ttl.as_secs(), 0);
778    }
779
780    // ========================================================================
781    // Cache Purge Tests
782    // ========================================================================
783
784    #[test]
785    fn test_purge_single_entry() {
786        let manager = CacheManager::new();
787
788        // Purge a single path
789        let count = manager.purge("/api/users/123");
790        assert_eq!(count, 1);
791
792        // Should have active purge entries
793        assert!(manager.active_purge_count() > 0);
794
795        // Should invalidate matching cache key
796        let cache_key =
797            CacheManager::generate_cache_key("GET", "example.com", "/api/users/123", None);
798        assert!(manager.should_invalidate(&cache_key));
799
800        // Should not invalidate non-matching cache key
801        let other_key =
802            CacheManager::generate_cache_key("GET", "example.com", "/api/users/456", None);
803        assert!(!manager.should_invalidate(&other_key));
804
805        // Clean up for next test
806        manager.clear_purges();
807    }
808
809    #[test]
810    fn test_purge_wildcard_pattern() {
811        let manager = CacheManager::new();
812
813        // Purge with wildcard pattern
814        let count = manager.purge_wildcard("/api/users/*");
815        assert_eq!(count, 1);
816
817        // Should invalidate matching paths
818        assert!(manager.should_invalidate("/api/users/123"));
819        assert!(manager.should_invalidate("/api/users/456"));
820        assert!(manager.should_invalidate("/api/users/abc"));
821
822        // Should not invalidate non-matching paths
823        assert!(!manager.should_invalidate("/api/posts/123"));
824        assert!(!manager.should_invalidate("/api/users")); // No trailing /
825
826        manager.clear_purges();
827    }
828
829    #[test]
830    fn test_purge_double_wildcard() {
831        let manager = CacheManager::new();
832
833        // Purge with ** pattern (matches anything including /)
834        let count = manager.purge_wildcard("/api/**");
835        assert_eq!(count, 1);
836
837        // Should match any path under /api/
838        assert!(manager.should_invalidate("/api/users/123"));
839        assert!(manager.should_invalidate("/api/posts/456/comments"));
840        assert!(manager.should_invalidate("/api/deep/nested/path"));
841
842        // Should not match other paths
843        assert!(!manager.should_invalidate("/other/path"));
844
845        manager.clear_purges();
846    }
847
848    #[test]
849    fn test_glob_to_regex() {
850        // Test single * pattern
851        let regex = glob_to_regex("/api/users/*");
852        assert_eq!(regex, "^/api/users/[^/]*$");
853
854        // Test ** pattern
855        let regex = glob_to_regex("/api/**");
856        assert_eq!(regex, "^/api/.*$");
857
858        // Test ? pattern
859        let regex = glob_to_regex("/api/user?");
860        assert_eq!(regex, "^/api/user.$");
861
862        // Test escaping special chars
863        let regex = glob_to_regex("/api/v1.0/users");
864        assert_eq!(regex, "^/api/v1\\.0/users$");
865    }
866
867    #[test]
868    fn test_extract_path_from_cache_key() {
869        // Test standard cache key
870        let path = extract_path_from_cache_key("GET:example.com:/api/users");
871        assert_eq!(path, Some("/api/users"));
872
873        // Test cache key with query
874        let path = extract_path_from_cache_key("GET:example.com:/api/users?page=1");
875        assert_eq!(path, Some("/api/users?page=1"));
876
877        // Test invalid cache key (no second colon)
878        let path = extract_path_from_cache_key("invalid");
879        assert_eq!(path, None);
880    }
881
882    #[test]
883    fn test_purge_eviction_stats() {
884        let manager = CacheManager::new();
885
886        let initial_evictions = manager.stats().evictions();
887
888        // Each purge should record an eviction
889        manager.purge("/path1");
890        manager.purge("/path2");
891        manager.purge_wildcard("/pattern/*");
892
893        assert_eq!(manager.stats().evictions(), initial_evictions + 3);
894
895        manager.clear_purges();
896    }
897}