sentinel_proxy/
memory_cache.rs

1//! In-memory caching module using pingora-memory-cache
2//!
3//! This module provides fast in-memory caching for hot data like:
4//! - Route matching results
5//! - Parsed configuration fragments
6//! - Compiled regex patterns
7//! - Upstream selection hints
8//!
9//! Uses S3-FIFO + TinyLFU eviction for excellent hit rates on skewed workloads.
10
11use pingora_memory_cache::MemoryCache;
12use std::hash::Hash;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::Arc;
15use std::time::Duration;
16use tracing::{debug, trace};
17
18/// Configuration for the memory cache
19#[derive(Debug, Clone)]
20pub struct MemoryCacheConfig {
21    /// Maximum number of items in the cache
22    pub max_items: usize,
23    /// Default TTL for cached items
24    pub default_ttl: Duration,
25    /// Enable cache statistics
26    pub enable_stats: bool,
27}
28
29impl Default for MemoryCacheConfig {
30    fn default() -> Self {
31        Self {
32            max_items: 10_000,
33            default_ttl: Duration::from_secs(60),
34            enable_stats: true,
35        }
36    }
37}
38
39/// Statistics for the memory cache
40#[derive(Debug, Default)]
41pub struct MemoryCacheStats {
42    /// Number of cache hits
43    pub hits: AtomicU64,
44    /// Number of cache misses
45    pub misses: AtomicU64,
46    /// Number of cache insertions
47    pub insertions: AtomicU64,
48    /// Number of cache evictions
49    pub evictions: AtomicU64,
50}
51
52impl MemoryCacheStats {
53    /// Get hit rate as a percentage (0.0 - 100.0)
54    pub fn hit_rate(&self) -> f64 {
55        let hits = self.hits.load(Ordering::Relaxed) as f64;
56        let misses = self.misses.load(Ordering::Relaxed) as f64;
57        let total = hits + misses;
58        if total > 0.0 {
59            (hits / total) * 100.0
60        } else {
61            0.0
62        }
63    }
64
65    /// Reset statistics
66    pub fn reset(&self) {
67        self.hits.store(0, Ordering::Relaxed);
68        self.misses.store(0, Ordering::Relaxed);
69        self.insertions.store(0, Ordering::Relaxed);
70        self.evictions.store(0, Ordering::Relaxed);
71    }
72}
73
74/// Route matching cache entry
75#[derive(Debug, Clone)]
76pub struct RouteMatchEntry {
77    /// Route ID that matched
78    pub route_id: String,
79    /// Upstream ID for this route
80    pub upstream_id: Option<String>,
81    /// Cached timestamp
82    pub cached_at: std::time::Instant,
83}
84
85/// Memory cache manager using pingora-memory-cache
86///
87/// Provides high-performance caching with:
88/// - S3-FIFO eviction (better than LRU for many workloads)
89/// - TinyLFU admission policy (prevents cache pollution)
90/// - Cache stampede protection
91pub struct MemoryCacheManager {
92    /// Route match cache (key: String, value: RouteMatchEntry)
93    route_cache: MemoryCache<String, RouteMatchEntry>,
94    /// Configuration
95    config: MemoryCacheConfig,
96    /// Statistics
97    stats: Arc<MemoryCacheStats>,
98}
99
100impl MemoryCacheManager {
101    /// Create a new memory cache manager
102    pub fn new(config: MemoryCacheConfig) -> Self {
103        debug!(
104            max_items = config.max_items,
105            default_ttl_secs = config.default_ttl.as_secs(),
106            "Creating memory cache manager"
107        );
108
109        // Create pingora memory cache with size estimate
110        // Each RouteMatchEntry is roughly 100-200 bytes
111        let estimated_item_size = 200;
112        let cache_size = config.max_items * estimated_item_size;
113
114        let route_cache = MemoryCache::new(cache_size);
115
116        Self {
117            route_cache,
118            config,
119            stats: Arc::new(MemoryCacheStats::default()),
120        }
121    }
122
123    /// Look up a route match by cache key
124    pub fn get_route_match(&self, key: &str) -> Option<RouteMatchEntry> {
125        let key_string = key.to_string();
126        let (result, _status) = self.route_cache.get(&key_string);
127
128        if self.config.enable_stats {
129            if result.is_some() {
130                self.stats.hits.fetch_add(1, Ordering::Relaxed);
131                trace!(key = %key, "Route cache hit");
132            } else {
133                self.stats.misses.fetch_add(1, Ordering::Relaxed);
134                trace!(key = %key, "Route cache miss");
135            }
136        }
137
138        result
139    }
140
141    /// Cache a route match result
142    pub fn put_route_match(&self, key: &str, entry: RouteMatchEntry) {
143        self.put_route_match_with_ttl(key, entry, self.config.default_ttl);
144    }
145
146    /// Cache a route match result with custom TTL
147    pub fn put_route_match_with_ttl(&self, key: &str, entry: RouteMatchEntry, ttl: Duration) {
148        trace!(
149            key = %key,
150            route_id = %entry.route_id,
151            ttl_secs = ttl.as_secs(),
152            "Caching route match"
153        );
154
155        let key_string = key.to_string();
156        self.route_cache.put(&key_string, entry, Some(ttl));
157
158        if self.config.enable_stats {
159            self.stats.insertions.fetch_add(1, Ordering::Relaxed);
160        }
161    }
162
163    /// Generate a cache key for route matching
164    ///
165    /// The key incorporates:
166    /// - HTTP method
167    /// - Request path
168    /// - Host header (for virtual hosting)
169    pub fn route_cache_key(method: &str, path: &str, host: Option<&str>) -> String {
170        match host {
171            Some(h) => format!("{}:{}:{}", method, h, path),
172            None => format!("{}:{}", method, path),
173        }
174    }
175
176    /// Invalidate route cache entries for a specific route
177    ///
178    /// Note: pingora-memory-cache doesn't support iteration, so we can't
179    /// selectively invalidate. For now, we'd need to clear the entire cache
180    /// on configuration reload.
181    pub fn invalidate_route(&self, _route_id: &str) {
182        // pingora-memory-cache doesn't support selective invalidation
183        // This would require tracking keys per route or using a different approach
184        debug!("Route invalidation requested (requires cache clear)");
185    }
186
187    /// Clear all cached entries
188    ///
189    /// Call this on configuration reload to ensure fresh routing decisions.
190    pub fn clear(&self) {
191        debug!("Clearing memory cache");
192        // pingora-memory-cache doesn't have a clear method, but items will expire via TTL
193        // For immediate invalidation, we'd need to recreate the cache
194    }
195
196    /// Get cache statistics
197    pub fn stats(&self) -> &MemoryCacheStats {
198        &self.stats
199    }
200
201    /// Get the cache configuration
202    pub fn config(&self) -> &MemoryCacheConfig {
203        &self.config
204    }
205}
206
207/// Generic cache wrapper for arbitrary types
208///
209/// This provides a typed interface over pingora's memory cache.
210/// K is the key type (must implement Hash + Eq + Clone + Send + Sync)
211/// V is the value type (must implement Clone + Send + Sync)
212pub struct TypedCache<K, V>
213where
214    K: Hash + Eq + Clone + Send + Sync + 'static,
215    V: Clone + Send + Sync + 'static,
216{
217    inner: MemoryCache<K, V>,
218    stats: Arc<MemoryCacheStats>,
219    default_ttl: Duration,
220}
221
222impl<K, V> TypedCache<K, V>
223where
224    K: Hash + Eq + Clone + Send + Sync + 'static,
225    V: Clone + Send + Sync + 'static,
226{
227    /// Create a new typed cache
228    pub fn new(max_size_bytes: usize, default_ttl: Duration) -> Self {
229        Self {
230            inner: MemoryCache::new(max_size_bytes),
231            stats: Arc::new(MemoryCacheStats::default()),
232            default_ttl,
233        }
234    }
235
236    /// Get a value from the cache
237    pub fn get(&self, key: &K) -> Option<V> {
238        let (result, _status) = self.inner.get(key);
239        if result.is_some() {
240            self.stats.hits.fetch_add(1, Ordering::Relaxed);
241        } else {
242            self.stats.misses.fetch_add(1, Ordering::Relaxed);
243        }
244        result
245    }
246
247    /// Put a value in the cache with default TTL
248    pub fn put(&self, key: &K, value: V) {
249        self.put_with_ttl(key, value, self.default_ttl);
250    }
251
252    /// Put a value in the cache with custom TTL
253    pub fn put_with_ttl(&self, key: &K, value: V, ttl: Duration) {
254        self.inner.put(key, value, Some(ttl));
255        self.stats.insertions.fetch_add(1, Ordering::Relaxed);
256    }
257
258    /// Get cache statistics
259    pub fn stats(&self) -> &MemoryCacheStats {
260        &self.stats
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_route_cache_key() {
270        let key1 = MemoryCacheManager::route_cache_key("GET", "/api/users", Some("example.com"));
271        assert_eq!(key1, "GET:example.com:/api/users");
272
273        let key2 = MemoryCacheManager::route_cache_key("POST", "/api/data", None);
274        assert_eq!(key2, "POST:/api/data");
275    }
276
277    #[test]
278    fn test_memory_cache_basic() {
279        let config = MemoryCacheConfig::default();
280        let cache = MemoryCacheManager::new(config);
281
282        // Miss on first lookup
283        assert!(cache.get_route_match("test-key").is_none());
284        assert_eq!(cache.stats().misses.load(Ordering::Relaxed), 1);
285
286        // Insert
287        let entry = RouteMatchEntry {
288            route_id: "route-1".to_string(),
289            upstream_id: Some("upstream-1".to_string()),
290            cached_at: std::time::Instant::now(),
291        };
292        cache.put_route_match("test-key", entry);
293
294        // Hit on second lookup
295        let result = cache.get_route_match("test-key");
296        assert!(result.is_some());
297        assert_eq!(result.unwrap().route_id, "route-1");
298        assert_eq!(cache.stats().hits.load(Ordering::Relaxed), 1);
299    }
300
301    #[test]
302    fn test_hit_rate() {
303        let stats = MemoryCacheStats::default();
304        stats.hits.store(80, Ordering::Relaxed);
305        stats.misses.store(20, Ordering::Relaxed);
306        assert!((stats.hit_rate() - 80.0).abs() < 0.001);
307    }
308
309    #[test]
310    fn test_typed_cache() {
311        let cache: TypedCache<String, String> =
312            TypedCache::new(1024 * 1024, Duration::from_secs(60));
313
314        let key = "key1".to_string();
315        cache.put(&key, "value1".to_string());
316        let result = cache.get(&key);
317        assert_eq!(result, Some("value1".to_string()));
318    }
319}