Skip to main content

grapsus_proxy/
cache.rs

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