1use 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
31const DEFAULT_CACHE_SIZE_BYTES: usize = 100 * 1024 * 1024;
37
38const DEFAULT_EVICTION_LIMIT_BYTES: usize = 100 * 1024 * 1024;
40
41static 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
55static 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
64static HTTP_CACHE_LOCK: Lazy<CacheLock> = Lazy::new(|| {
66 info!("Initializing HTTP cache lock");
67 CacheLock::new(Duration::from_secs(10))
68});
69
70pub fn get_cache_storage() -> &'static (dyn Storage + Sync) {
74 &*HTTP_CACHE_STORAGE
75}
76
77pub fn get_cache_eviction() -> &'static LruEvictionManager {
79 &HTTP_CACHE_EVICTION
80}
81
82pub fn get_cache_lock() -> &'static CacheLock {
84 &HTTP_CACHE_LOCK
85}
86
87#[derive(Debug, Clone)]
89pub struct CacheConfig {
90 pub enabled: bool,
92 pub default_ttl_secs: u64,
94 pub max_size_bytes: usize,
96 pub cache_private: bool,
98 pub stale_while_revalidate_secs: u64,
100 pub stale_if_error_secs: u64,
102 pub cacheable_methods: Vec<String>,
104 pub cacheable_status_codes: Vec<u16>,
106}
107
108impl Default for CacheConfig {
109 fn default() -> Self {
110 Self {
111 enabled: false, default_ttl_secs: 3600,
113 max_size_bytes: 10 * 1024 * 1024, 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#[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 pub fn record_hit(&self) {
135 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
136 }
137
138 pub fn record_miss(&self) {
140 self.misses
141 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
142 }
143
144 pub fn record_store(&self) {
146 self.stores
147 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
148 }
149
150 pub fn record_eviction(&self) {
152 self.evictions
153 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
154 }
155
156 pub fn hits(&self) -> u64 {
158 self.hits.load(std::sync::atomic::Ordering::Relaxed)
159 }
160
161 pub fn misses(&self) -> u64 {
163 self.misses.load(std::sync::atomic::Ordering::Relaxed)
164 }
165
166 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 pub fn stores(&self) -> u64 {
179 self.stores.load(std::sync::atomic::Ordering::Relaxed)
180 }
181
182 pub fn evictions(&self) -> u64 {
184 self.evictions.load(std::sync::atomic::Ordering::Relaxed)
185 }
186}
187
188#[derive(Debug, Clone)]
190struct PurgeEntry {
191 created_at: Instant,
193 pattern: Option<String>,
195}
196
197const PURGE_ENTRY_LIFETIME: Duration = Duration::from_secs(60);
199
200pub struct CacheManager {
205 route_configs: RwLock<HashMap<String, CacheConfig>>,
207 stats: Arc<HttpCacheStats>,
209 purged_keys: RwLock<HashMap<String, Instant>>,
211 purge_patterns: RwLock<Vec<PurgeEntry>>,
213 compiled_patterns: RwLock<Vec<(Regex, Instant)>>,
215}
216
217impl CacheManager {
218 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 pub fn stats(&self) -> Arc<HttpCacheStats> {
231 self.stats.clone()
232 }
233
234 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 pub fn get_route_config(&self, route_id: &str) -> Option<CacheConfig> {
249 self.route_configs.read().get(route_id).cloned()
250 }
251
252 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 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 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 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 pub fn parse_max_age(header_value: &str) -> Option<u64> {
293 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 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 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 if Self::is_no_cache(cc) && !config.cache_private {
323 return Duration::ZERO;
324 }
325
326 if let Some(max_age) = Self::parse_max_age(cc) {
328 return Duration::from_secs(max_age);
329 }
330 }
331
332 Duration::from_secs(config.default_ttl_secs)
334 }
335
336 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 pub fn route_count(&self) -> usize {
354 self.route_configs.read().len()
355 }
356
357 pub fn purge(&self, path: &str) -> usize {
367 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 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 pub fn purge_wildcard(&self, pattern: &str) -> usize {
401 let regex_pattern = glob_to_regex(pattern);
403
404 match Regex::new(®ex_pattern) {
405 Ok(regex) => {
406 let now = Instant::now();
407
408 self.compiled_patterns.write().push((regex, now));
410
411 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 pub fn should_invalidate(&self, cache_key: &str) -> bool {
442 self.cleanup_expired_purges();
444
445 {
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 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 {
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 fn cleanup_expired_purges(&self) {
486 let now = Instant::now();
487
488 {
490 let mut purged = self.purged_keys.write();
491 purged.retain(|_, created_at| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
492 }
493
494 {
496 let mut patterns = self.purge_patterns.write();
497 patterns.retain(|entry| now.duration_since(entry.created_at) < PURGE_ENTRY_LIFETIME);
498 }
499
500 {
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 pub fn active_purge_count(&self) -> usize {
510 self.purged_keys.read().len() + self.purge_patterns.read().len()
511 }
512
513 #[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
522fn 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 if i + 1 < chars.len() && chars[i + 1] == '*' {
541 regex.push_str(".*");
542 i += 2;
543 } else {
544 regex.push_str("[^/]*");
546 i += 1;
547 }
548 }
549 '?' => {
550 regex.push('.');
551 i += 1;
552 }
553 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
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
570fn extract_path_from_cache_key(cache_key: &str) -> Option<&str> {
574 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 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 let ttl = manager.calculate_ttl("api", Some("max-age=3600"));
706 assert_eq!(ttl.as_secs(), 3600);
707
708 let ttl = manager.calculate_ttl("api", None);
710 assert_eq!(ttl.as_secs(), 600);
711
712 let ttl = manager.calculate_ttl("api", Some("no-store"));
714 assert_eq!(ttl.as_secs(), 0);
715 }
716
717 #[test]
722 fn test_purge_single_entry() {
723 let manager = CacheManager::new();
724
725 let count = manager.purge("/api/users/123");
727 assert_eq!(count, 1);
728
729 assert!(manager.active_purge_count() > 0);
731
732 let cache_key =
734 CacheManager::generate_cache_key("GET", "example.com", "/api/users/123", None);
735 assert!(manager.should_invalidate(&cache_key));
736
737 let other_key =
739 CacheManager::generate_cache_key("GET", "example.com", "/api/users/456", None);
740 assert!(!manager.should_invalidate(&other_key));
741
742 manager.clear_purges();
744 }
745
746 #[test]
747 fn test_purge_wildcard_pattern() {
748 let manager = CacheManager::new();
749
750 let count = manager.purge_wildcard("/api/users/*");
752 assert_eq!(count, 1);
753
754 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 assert!(!manager.should_invalidate("/api/posts/123"));
761 assert!(!manager.should_invalidate("/api/users")); manager.clear_purges();
764 }
765
766 #[test]
767 fn test_purge_double_wildcard() {
768 let manager = CacheManager::new();
769
770 let count = manager.purge_wildcard("/api/**");
772 assert_eq!(count, 1);
773
774 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 assert!(!manager.should_invalidate("/other/path"));
781
782 manager.clear_purges();
783 }
784
785 #[test]
786 fn test_glob_to_regex() {
787 let regex = glob_to_regex("/api/users/*");
789 assert_eq!(regex, "^/api/users/[^/]*$");
790
791 let regex = glob_to_regex("/api/**");
793 assert_eq!(regex, "^/api/.*$");
794
795 let regex = glob_to_regex("/api/user?");
797 assert_eq!(regex, "^/api/user.$");
798
799 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 let path = extract_path_from_cache_key("GET:example.com:/api/users");
808 assert_eq!(path, Some("/api/users"));
809
810 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 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 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}