Skip to main content

ghostlink_core/
api_response_cache.rs

1//! API Response Caching Layer with ETag Support
2//!
3//! Reduces serialization overhead and bandwidth by caching API responses with:
4//! - TTL-based expiration
5//! - ETag support for conditional requests (304 Not Modified)
6//! - Hit ratio tracking and statistics
7//! - Optional pre-warming on startup
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::{Arc, RwLock};
12use std::time::{Duration, Instant};
13
14/// Cached API response
15#[derive(Clone, Debug)]
16struct CachedResponse {
17    /// Response body - optimized to Arc<[u8]> to allow cheap, O(1), zero-allocation clones on cache hits
18    body: Arc<[u8]>,
19    /// ETag hash
20    etag: String,
21    /// Expiration time
22    expires_at: Instant,
23}
24
25/// Cache statistics for observability
26#[derive(Clone, Debug, Default)]
27pub struct CacheStats {
28    pub total_requests: usize,
29    pub cache_hits: usize,
30    pub cache_misses: usize,
31    pub etag_hits: usize,
32}
33
34impl CacheStats {
35    pub fn hit_ratio(&self) -> f64 {
36        if self.total_requests == 0 {
37            0.0
38        } else {
39            (self.cache_hits + self.etag_hits) as f64 / self.total_requests as f64
40        }
41    }
42}
43
44/// Cache statistics for observability internally represented with lock-free atomics
45#[derive(Debug, Default)]
46struct AtomicCacheStats {
47    total_requests: AtomicUsize,
48    cache_hits: AtomicUsize,
49    cache_misses: AtomicUsize,
50    etag_hits: AtomicUsize,
51}
52
53/// Thread-safe API response cache
54#[derive(Debug)]
55pub struct ApiResponseCache {
56    cache: Arc<RwLock<HashMap<String, CachedResponse>>>,
57    stats: Arc<AtomicCacheStats>,
58}
59
60impl ApiResponseCache {
61    /// Create a new cache
62    pub fn new() -> Self {
63        Self {
64            cache: Arc::new(RwLock::new(HashMap::new())),
65            stats: Arc::new(AtomicCacheStats::default()),
66        }
67    }
68
69    /// Get cached response or compute new one.
70    ///
71    /// Optimized to return Arc<[u8]> instead of Vec<u8> to prevent cloning the entire
72    /// response body (which can be multi-megabyte JSON) on cache hits. This reduces allocation churn
73    /// and memory traffic from O(N) to O(1) time and space complexity.
74    /// Further optimized using lock-free atomics for statistics tracking, preventing
75    /// write lock contention on the hot path of cache hits/misses.
76    pub fn get_or_compute<F>(
77        &self,
78        key: &str,
79        ttl: Duration,
80        compute_fn: F,
81    ) -> Result<(Arc<[u8]>, String), String>
82    where
83        F: FnOnce() -> Result<Vec<u8>, String>,
84    {
85        self.stats.total_requests.fetch_add(1, Ordering::Relaxed);
86
87        // Check cache with a cheap read lock
88        {
89            let cache = self.cache.read().unwrap();
90            if let Some(cached) = cache.get(key) {
91                if cached.expires_at > Instant::now() {
92                    self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
93                    return Ok((Arc::clone(&cached.body), cached.etag.clone()));
94                }
95            }
96        }
97
98        // Cache miss or expired: compute outside the stats write lock
99        self.stats.cache_misses.fetch_add(1, Ordering::Relaxed);
100        let body = compute_fn()?;
101        let etag = compute_etag(&body);
102        let body_arc: Arc<[u8]> = Arc::from(body);
103
104        let mut cache = self.cache.write().unwrap();
105        cache.insert(
106            key.to_string(),
107            CachedResponse {
108                body: Arc::clone(&body_arc),
109                etag: etag.clone(),
110                expires_at: Instant::now() + ttl,
111            },
112        );
113
114        Ok((body_arc, etag))
115    }
116
117    /// Check if client's ETag matches (for 304 responses)
118    pub fn etag_matches(&self, key: &str, client_etag: &str) -> bool {
119        let cache = self.cache.read().unwrap();
120        if let Some(cached) = cache.get(key) {
121            if cached.expires_at > Instant::now() {
122                return cached.etag == client_etag;
123            }
124        }
125        false
126    }
127
128    /// Record ETag hit (304 response sent)
129    pub fn record_etag_hit(&self) {
130        self.stats.etag_hits.fetch_add(1, Ordering::Relaxed);
131    }
132
133    /// Get current cache statistics
134    pub fn stats(&self) -> CacheStats {
135        CacheStats {
136            total_requests: self.stats.total_requests.load(Ordering::Relaxed),
137            cache_hits: self.stats.cache_hits.load(Ordering::Relaxed),
138            cache_misses: self.stats.cache_misses.load(Ordering::Relaxed),
139            etag_hits: self.stats.etag_hits.load(Ordering::Relaxed),
140        }
141    }
142
143    /// Clear all cache entries
144    pub fn clear(&self) {
145        self.cache.write().unwrap().clear();
146        self.stats.total_requests.store(0, Ordering::Relaxed);
147        self.stats.cache_hits.store(0, Ordering::Relaxed);
148        self.stats.cache_misses.store(0, Ordering::Relaxed);
149        self.stats.etag_hits.store(0, Ordering::Relaxed);
150    }
151
152    /// Pre-warm cache with known endpoints
153    pub fn pre_warm<F>(&self, endpoints: Vec<(&str, Duration)>, compute_fn: F) -> Result<(), String>
154    where
155        F: Fn(&str) -> Result<Vec<u8>, String>,
156    {
157        for (key, ttl) in endpoints {
158            self.get_or_compute(key, ttl, || compute_fn(key))?;
159        }
160        Ok(())
161    }
162
163    /// Get cache size (number of entries)
164    pub fn size(&self) -> usize {
165        self.cache.read().unwrap().len()
166    }
167}
168
169impl Default for ApiResponseCache {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl Clone for ApiResponseCache {
176    fn clone(&self) -> Self {
177        Self {
178            cache: Arc::clone(&self.cache),
179            stats: Arc::clone(&self.stats),
180        }
181    }
182}
183
184/// Compute a weak ETag (RFC 7232 `W/` prefix — a content fingerprint for
185/// conditional-GET purposes, not a claim of byte-exact equivalence) from a
186/// response body. Mixes the body length into the hash state and appends it
187/// to the digest so two different-length bodies that happen to collide in
188/// the 64-bit hash are still distinguishable — plain `DefaultHasher::finish()`
189/// alone shares this any-fixed-width-hash's theoretical collision risk, but
190/// unlike a 32-bit CRC (already used elsewhere in this crate for wire-frame
191/// corruption detection, a different threat model) this doesn't halve the
192/// effective space to make it worse.
193fn compute_etag(body: &[u8]) -> String {
194    use std::collections::hash_map::DefaultHasher;
195    use std::hash::{Hash, Hasher};
196
197    let mut hasher = DefaultHasher::new();
198    body.len().hash(&mut hasher);
199    body.hash(&mut hasher);
200    format!("W/\"{:x}-{:x}\"", hasher.finish(), body.len())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn cache_miss_computes_value() {
209        let cache = ApiResponseCache::new();
210        let body = b"response data".to_vec();
211        let body_clone = body.clone();
212
213        let (result, etag) = cache
214            .get_or_compute("test", Duration::from_secs(60), || Ok(body_clone))
215            .unwrap();
216
217        assert_eq!(result.as_ref(), body.as_slice());
218        assert!(!etag.is_empty());
219    }
220
221    #[test]
222    fn cache_hit_reuses() {
223        let cache = ApiResponseCache::new();
224        let body1 = b"response1".to_vec();
225        let body1_clone = body1.clone();
226
227        let (result1, etag1) = cache
228            .get_or_compute("test", Duration::from_secs(60), || Ok(body1_clone))
229            .unwrap();
230
231        let (result2, etag2) = cache
232            .get_or_compute(
233                "test",
234                Duration::from_secs(60),
235                || Ok(b"response2".to_vec()),
236            )
237            .unwrap();
238
239        assert_eq!(result1, result2);
240        assert_eq!(etag1, etag2);
241        assert_eq!(cache.stats().cache_hits, 1);
242    }
243
244    #[test]
245    fn etag_match_detection() {
246        let cache = ApiResponseCache::new();
247        let body = b"data".to_vec();
248        let body_clone = body.clone();
249
250        let (_result, etag) = cache
251            .get_or_compute("test", Duration::from_secs(60), || Ok(body_clone))
252            .unwrap();
253
254        assert!(cache.etag_matches("test", &etag));
255        assert!(!cache.etag_matches("test", "wrong-etag"));
256    }
257
258    #[test]
259    fn expiration_and_recompute() {
260        let cache = ApiResponseCache::new();
261
262        // First request: cache miss
263        cache
264            .get_or_compute("test", Duration::from_millis(5), || Ok(b"data1".to_vec()))
265            .unwrap();
266
267        // Wait for expiration
268        std::thread::sleep(Duration::from_millis(50));
269
270        // Second request on expired key: should recompute
271        let (result2, _etag2) = cache
272            .get_or_compute("test", Duration::from_secs(60), || Ok(b"data2".to_vec()))
273            .unwrap();
274
275        assert_eq!(result2.as_ref(), b"data2");
276    }
277
278    #[test]
279    fn statistics_tracking() {
280        let cache = ApiResponseCache::new();
281
282        for i in 0..5 {
283            let body = format!("response{}", i).into_bytes();
284            cache
285                .get_or_compute(&format!("endpoint{}", i), Duration::from_secs(60), || {
286                    Ok(body)
287                })
288                .unwrap();
289        }
290
291        let stats = cache.stats();
292        assert_eq!(stats.total_requests, 5);
293        assert_eq!(stats.cache_misses, 5);
294        assert_eq!(stats.cache_hits, 0);
295    }
296
297    #[test]
298    fn hit_ratio_calculation() {
299        let cache = ApiResponseCache::new();
300
301        for _ in 0..3 {
302            cache
303                .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
304                .unwrap();
305            cache
306                .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
307                .unwrap();
308        }
309
310        let stats = cache.stats();
311        let ratio = stats.hit_ratio();
312        assert!(ratio > 0.5);
313    }
314
315    #[test]
316    fn pre_warm_populates() {
317        let cache = ApiResponseCache::new();
318
319        cache
320            .pre_warm(
321                vec![
322                    ("models", Duration::from_secs(60)),
323                    ("runtime", Duration::from_secs(300)),
324                ],
325                |_key| Ok(b"warm data".to_vec()),
326            )
327            .unwrap();
328
329        assert_eq!(cache.size(), 2);
330    }
331
332    #[test]
333    fn clone_shares_state() {
334        let cache1 = ApiResponseCache::new();
335        cache1
336            .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
337            .unwrap();
338
339        let cache2 = cache1.clone();
340        assert_eq!(cache1.size(), cache2.size());
341    }
342}