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