1use 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
33const DEFAULT_CACHE_SIZE_BYTES: usize = 100 * 1024 * 1024;
39
40const DEFAULT_EVICTION_LIMIT_BYTES: usize = 100 * 1024 * 1024;
42
43const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 10;
45
46static CACHE_CONFIG: OnceCell<CacheStorageConfig> = OnceCell::new();
51
52pub 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
82fn get_cache_config() -> &'static CacheStorageConfig {
84 CACHE_CONFIG.get_or_init(CacheStorageConfig::default)
85}
86
87pub fn is_cache_enabled() -> bool {
89 get_cache_config().enabled
90}
91
92static 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
112static 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
123static 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
133pub fn get_cache_storage() -> &'static (dyn Storage + Sync) {
137 &*HTTP_CACHE_STORAGE
138}
139
140pub fn get_cache_eviction() -> &'static LruEvictionManager {
142 &HTTP_CACHE_EVICTION
143}
144
145pub fn get_cache_lock() -> &'static CacheLock {
147 &HTTP_CACHE_LOCK
148}
149
150#[derive(Debug, Clone)]
152pub struct CacheConfig {
153 pub enabled: bool,
155 pub default_ttl_secs: u64,
157 pub max_size_bytes: usize,
159 pub cache_private: bool,
161 pub stale_while_revalidate_secs: u64,
163 pub stale_if_error_secs: u64,
165 pub cacheable_methods: Vec<String>,
167 pub cacheable_status_codes: Vec<u16>,
169}
170
171impl Default for CacheConfig {
172 fn default() -> Self {
173 Self {
174 enabled: false, default_ttl_secs: 3600,
176 max_size_bytes: 10 * 1024 * 1024, 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#[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 pub fn record_hit(&self) {
198 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
199 }
200
201 pub fn record_miss(&self) {
203 self.misses
204 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
205 }
206
207 pub fn record_store(&self) {
209 self.stores
210 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
211 }
212
213 pub fn record_eviction(&self) {
215 self.evictions
216 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
217 }
218
219 pub fn hits(&self) -> u64 {
221 self.hits.load(std::sync::atomic::Ordering::Relaxed)
222 }
223
224 pub fn misses(&self) -> u64 {
226 self.misses.load(std::sync::atomic::Ordering::Relaxed)
227 }
228
229 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 pub fn stores(&self) -> u64 {
242 self.stores.load(std::sync::atomic::Ordering::Relaxed)
243 }
244
245 pub fn evictions(&self) -> u64 {
247 self.evictions.load(std::sync::atomic::Ordering::Relaxed)
248 }
249}
250
251#[derive(Debug, Clone)]
253struct PurgeEntry {
254 created_at: Instant,
256 pattern: Option<String>,
258}
259
260const PURGE_ENTRY_LIFETIME: Duration = Duration::from_secs(60);
262
263pub struct CacheManager {
268 route_configs: RwLock<HashMap<String, CacheConfig>>,
270 stats: Arc<HttpCacheStats>,
272 purged_keys: RwLock<HashMap<String, Instant>>,
274 purge_patterns: RwLock<Vec<PurgeEntry>>,
276 compiled_patterns: RwLock<Vec<(Regex, Instant)>>,
278}
279
280impl CacheManager {
281 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 pub fn stats(&self) -> Arc<HttpCacheStats> {
294 self.stats.clone()
295 }
296
297 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 pub fn get_route_config(&self, route_id: &str) -> Option<CacheConfig> {
312 self.route_configs.read().get(route_id).cloned()
313 }
314
315 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 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 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 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 pub fn parse_max_age(header_value: &str) -> Option<u64> {
356 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 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 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 if Self::is_no_cache(cc) && !config.cache_private {
386 return Duration::ZERO;
387 }
388
389 if let Some(max_age) = Self::parse_max_age(cc) {
391 return Duration::from_secs(max_age);
392 }
393 }
394
395 Duration::from_secs(config.default_ttl_secs)
397 }
398
399 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 pub fn route_count(&self) -> usize {
417 self.route_configs.read().len()
418 }
419
420 pub fn purge(&self, path: &str) -> usize {
430 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 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 pub fn purge_wildcard(&self, pattern: &str) -> usize {
464 let regex_pattern = glob_to_regex(pattern);
466
467 match Regex::new(®ex_pattern) {
468 Ok(regex) => {
469 let now = Instant::now();
470
471 self.compiled_patterns.write().push((regex, now));
473
474 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 pub fn should_invalidate(&self, cache_key: &str) -> bool {
505 self.cleanup_expired_purges();
507
508 {
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 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 {
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 fn cleanup_expired_purges(&self) {
549 let now = Instant::now();
550
551 {
553 let mut purged = self.purged_keys.write();
554 purged.retain(|_, created_at| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
555 }
556
557 {
559 let mut patterns = self.purge_patterns.write();
560 patterns.retain(|entry| now.duration_since(entry.created_at) < PURGE_ENTRY_LIFETIME);
561 }
562
563 {
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 pub fn active_purge_count(&self) -> usize {
573 self.purged_keys.read().len() + self.purge_patterns.read().len()
574 }
575
576 #[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
585fn 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 if i + 1 < chars.len() && chars[i + 1] == '*' {
604 regex.push_str(".*");
605 i += 2;
606 } else {
607 regex.push_str("[^/]*");
609 i += 1;
610 }
611 }
612 '?' => {
613 regex.push('.');
614 i += 1;
615 }
616 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
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
633fn extract_path_from_cache_key(cache_key: &str) -> Option<&str> {
637 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 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 let ttl = manager.calculate_ttl("api", Some("max-age=3600"));
769 assert_eq!(ttl.as_secs(), 3600);
770
771 let ttl = manager.calculate_ttl("api", None);
773 assert_eq!(ttl.as_secs(), 600);
774
775 let ttl = manager.calculate_ttl("api", Some("no-store"));
777 assert_eq!(ttl.as_secs(), 0);
778 }
779
780 #[test]
785 fn test_purge_single_entry() {
786 let manager = CacheManager::new();
787
788 let count = manager.purge("/api/users/123");
790 assert_eq!(count, 1);
791
792 assert!(manager.active_purge_count() > 0);
794
795 let cache_key =
797 CacheManager::generate_cache_key("GET", "example.com", "/api/users/123", None);
798 assert!(manager.should_invalidate(&cache_key));
799
800 let other_key =
802 CacheManager::generate_cache_key("GET", "example.com", "/api/users/456", None);
803 assert!(!manager.should_invalidate(&other_key));
804
805 manager.clear_purges();
807 }
808
809 #[test]
810 fn test_purge_wildcard_pattern() {
811 let manager = CacheManager::new();
812
813 let count = manager.purge_wildcard("/api/users/*");
815 assert_eq!(count, 1);
816
817 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 assert!(!manager.should_invalidate("/api/posts/123"));
824 assert!(!manager.should_invalidate("/api/users")); manager.clear_purges();
827 }
828
829 #[test]
830 fn test_purge_double_wildcard() {
831 let manager = CacheManager::new();
832
833 let count = manager.purge_wildcard("/api/**");
835 assert_eq!(count, 1);
836
837 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 assert!(!manager.should_invalidate("/other/path"));
844
845 manager.clear_purges();
846 }
847
848 #[test]
849 fn test_glob_to_regex() {
850 let regex = glob_to_regex("/api/users/*");
852 assert_eq!(regex, "^/api/users/[^/]*$");
853
854 let regex = glob_to_regex("/api/**");
856 assert_eq!(regex, "^/api/.*$");
857
858 let regex = glob_to_regex("/api/user?");
860 assert_eq!(regex, "^/api/user.$");
861
862 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 let path = extract_path_from_cache_key("GET:example.com:/api/users");
871 assert_eq!(path, Some("/api/users"));
872
873 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 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 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}