Skip to main content

barbacane_wasm/
cache.rs

1//! Response cache with TTL support.
2//!
3//! This module provides an in-memory response cache for the cache middleware.
4//! Entries are keyed by (path, method, vary_headers) and include TTL expiration.
5
6use parking_lot::RwLock;
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11/// A cached response entry.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct CacheEntry {
14    /// The cached response status code.
15    pub status: u16,
16    /// The cached response headers.
17    pub headers: HashMap<String, String>,
18    /// The cached response body (binary-safe via base64 in JSON).
19    #[serde(with = "barbacane_plugin_sdk::types::base64_body")]
20    pub body: Option<Vec<u8>>,
21    /// Cache metadata for debugging.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub metadata: Option<CacheMetadata>,
24}
25
26/// Cache metadata for debugging and headers.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct CacheMetadata {
29    /// When the entry was created (Unix timestamp).
30    pub created_at: u64,
31    /// When the entry expires (Unix timestamp).
32    pub expires_at: u64,
33    /// Remaining TTL in seconds.
34    pub ttl_remaining: u64,
35}
36
37/// Internal cache entry with expiration.
38struct InternalEntry {
39    entry: CacheEntry,
40    expires_at: Instant,
41    created_at: Instant,
42    _ttl_secs: u64,
43}
44
45/// Result of a cache lookup.
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct CacheResult {
48    /// Whether there was a cache hit.
49    pub hit: bool,
50    /// The cached entry (only set on hit).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub entry: Option<CacheEntry>,
53}
54
55/// Thread-safe response cache.
56#[derive(Clone)]
57pub struct ResponseCache {
58    /// Cached entries by key.
59    entries: Arc<RwLock<HashMap<String, InternalEntry>>>,
60    /// Cleanup interval.
61    cleanup_interval: Duration,
62    /// Last cleanup time.
63    last_cleanup: Arc<RwLock<Instant>>,
64}
65
66impl Default for ResponseCache {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl ResponseCache {
73    /// Create a new response cache.
74    pub fn new() -> Self {
75        Self {
76            entries: Arc::new(RwLock::new(HashMap::new())),
77            cleanup_interval: Duration::from_secs(60),
78            last_cleanup: Arc::new(RwLock::new(Instant::now())),
79        }
80    }
81
82    /// Get a cache entry by key.
83    pub fn get(&self, key: &str) -> CacheResult {
84        self.maybe_cleanup();
85
86        let entries = self.entries.read();
87        let now = Instant::now();
88
89        if let Some(internal) = entries.get(key) {
90            if internal.expires_at > now {
91                // Cache hit
92                let ttl_remaining = internal.expires_at.saturating_duration_since(now).as_secs();
93                let now_unix = std::time::SystemTime::now()
94                    .duration_since(std::time::UNIX_EPOCH)
95                    .map(|d| d.as_secs())
96                    .unwrap_or(0);
97
98                let mut entry = internal.entry.clone();
99                entry.metadata = Some(CacheMetadata {
100                    created_at: now_unix - internal.created_at.elapsed().as_secs(),
101                    expires_at: now_unix + ttl_remaining,
102                    ttl_remaining,
103                });
104
105                return CacheResult {
106                    hit: true,
107                    entry: Some(entry),
108                };
109            }
110        }
111
112        // Cache miss
113        CacheResult {
114            hit: false,
115            entry: None,
116        }
117    }
118
119    /// Set a cache entry with TTL.
120    pub fn set(&self, key: &str, entry: CacheEntry, ttl_secs: u64) {
121        let now = Instant::now();
122        let expires_at = now + Duration::from_secs(ttl_secs);
123
124        let internal = InternalEntry {
125            entry,
126            expires_at,
127            created_at: now,
128            _ttl_secs: ttl_secs,
129        };
130
131        let mut entries = self.entries.write();
132        entries.insert(key.to_string(), internal);
133    }
134
135    /// Invalidate a cache entry.
136    pub fn invalidate(&self, key: &str) {
137        let mut entries = self.entries.write();
138        entries.remove(key);
139    }
140
141    /// Clear all cache entries.
142    pub fn clear(&self) {
143        let mut entries = self.entries.write();
144        entries.clear();
145    }
146
147    /// Periodically clean up expired entries.
148    fn maybe_cleanup(&self) {
149        let now = Instant::now();
150
151        {
152            let last = self.last_cleanup.read();
153            if now.duration_since(*last) < self.cleanup_interval {
154                return;
155            }
156        }
157
158        if let Some(mut last) = self.last_cleanup.try_write() {
159            if now.duration_since(*last) >= self.cleanup_interval {
160                *last = now;
161
162                if let Some(mut entries) = self.entries.try_write() {
163                    entries.retain(|_, v| v.expires_at > now);
164                }
165            }
166        }
167    }
168
169    /// Get cache statistics.
170    pub fn stats(&self) -> CacheStats {
171        let entries = self.entries.read();
172        let now = Instant::now();
173        let valid_count = entries.values().filter(|e| e.expires_at > now).count();
174
175        CacheStats {
176            total_entries: entries.len(),
177            valid_entries: valid_count,
178        }
179    }
180}
181
182/// Cache statistics.
183#[derive(Debug, Clone)]
184pub struct CacheStats {
185    /// Total number of entries (including expired).
186    pub total_entries: usize,
187    /// Number of non-expired entries.
188    pub valid_entries: usize,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_cache_miss() {
197        let cache = ResponseCache::new();
198        let result = cache.get("test-key");
199        assert!(!result.hit);
200        assert!(result.entry.is_none());
201    }
202
203    #[test]
204    fn test_cache_hit() {
205        let cache = ResponseCache::new();
206
207        let entry = CacheEntry {
208            status: 200,
209            headers: HashMap::new(),
210            body: Some(b"test body".to_vec()),
211            metadata: None,
212        };
213
214        cache.set("test-key", entry, 60);
215
216        let result = cache.get("test-key");
217        assert!(result.hit);
218        assert!(result.entry.is_some());
219        let cached = result.entry.unwrap();
220        assert_eq!(cached.status, 200);
221        assert_eq!(cached.body, Some(b"test body".to_vec()));
222    }
223
224    #[test]
225    fn test_cache_invalidate() {
226        let cache = ResponseCache::new();
227
228        let entry = CacheEntry {
229            status: 200,
230            headers: HashMap::new(),
231            body: None,
232            metadata: None,
233        };
234
235        cache.set("test-key", entry, 60);
236        assert!(cache.get("test-key").hit);
237
238        cache.invalidate("test-key");
239        assert!(!cache.get("test-key").hit);
240    }
241
242    #[test]
243    fn test_cache_stats() {
244        let cache = ResponseCache::new();
245
246        let entry = CacheEntry {
247            status: 200,
248            headers: HashMap::new(),
249            body: None,
250            metadata: None,
251        };
252
253        cache.set("key1", entry.clone(), 60);
254        cache.set("key2", entry.clone(), 60);
255        cache.set("key3", entry, 60);
256
257        let stats = cache.stats();
258        assert_eq!(stats.total_entries, 3);
259        assert_eq!(stats.valid_entries, 3);
260    }
261
262    #[test]
263    fn test_cache_binary_body_roundtrip() {
264        let cache = ResponseCache::new();
265
266        let binary_body = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; // PNG header
267        let entry = CacheEntry {
268            status: 200,
269            headers: {
270                let mut h = HashMap::new();
271                h.insert("content-type".to_string(), "image/png".to_string());
272                h
273            },
274            body: Some(binary_body.clone()),
275            metadata: None,
276        };
277
278        cache.set("binary-key", entry, 60);
279        let cached = cache.get("binary-key").entry.unwrap();
280        assert_eq!(cached.body, Some(binary_body));
281    }
282
283    #[test]
284    fn test_cache_entry_json_roundtrip() {
285        // CacheEntry uses base64_body for JSON — verify roundtrip
286        let entry = CacheEntry {
287            status: 200,
288            headers: HashMap::new(),
289            body: Some(vec![0x00, 0xFF, 0x80]),
290            metadata: None,
291        };
292        let json = serde_json::to_string(&entry).unwrap();
293        let decoded: CacheEntry = serde_json::from_str(&json).unwrap();
294        assert_eq!(decoded.body, entry.body);
295    }
296
297    #[test]
298    fn test_cache_entry_json_none_body() {
299        let entry = CacheEntry {
300            status: 204,
301            headers: HashMap::new(),
302            body: None,
303            metadata: None,
304        };
305        let json = serde_json::to_string(&entry).unwrap();
306        let decoded: CacheEntry = serde_json::from_str(&json).unwrap();
307        assert!(decoded.body.is_none());
308    }
309}