Skip to main content

featherbit/traffic/
cache.rs

1//! Response-cache backends for the `proxy-cache` node.
2//!
3//! One trait, two implementations: a process-local map and (from the redis
4//! backend onwards) a shared store. `proxy-cache` holds whichever it was
5//! configured with and never learns which.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use async_trait::async_trait;
12use bytes::Bytes;
13use dashmap::DashMap;
14
15/// A cached upstream response, without any notion of when it expires.
16///
17/// Lifetime is the backend's business: the local map compares `Instant`s,
18/// while a redis entry lives on the key's own TTL. Carrying an `Instant` here
19/// would be meaningless to a second instance, which does not share the first
20/// one's monotonic clock.
21#[derive(Debug, Clone)]
22pub struct CachedResponse {
23    pub status: u16,
24    pub headers: HashMap<String, Vec<String>>,
25    pub body: Bytes,
26}
27
28/// A backend could not answer. Distinct from a miss; see [`ResponseCache`].
29#[derive(Debug)]
30pub struct CacheError(pub String);
31
32/// The key prefix shared by every entry a `proxy-cache` pair writes.
33///
34/// `proxy-cache` derives keys as `{id}\u{1}{component}\u{1}…`, so this prefix
35/// selects a pair exactly: `products\u{1}` matches nothing of `products-v2`.
36pub(crate) fn pair_prefix(id: &str) -> String {
37    format!("{id}\u{1}")
38}
39
40impl std::fmt::Display for CacheError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "response cache error: {}", self.0)
43    }
44}
45
46/// Storage for `proxy-cache`.
47///
48/// `Ok(None)` means the backend answered and had nothing; `Err` means it could
49/// not answer. Keeping those apart is deliberate: the caller decides that an
50/// outage should read as a miss, and that decision belongs somewhere a
51/// reviewer can find it rather than inside a backend that swallows its own
52/// errors.
53#[async_trait]
54pub trait ResponseCache: Send + Sync {
55    async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheError>;
56    async fn put(&self, key: &str, entry: &CachedResponse, ttl: Duration)
57        -> Result<(), CacheError>;
58
59    /// Removes every entry belonging to the pair `id` and returns how many.
60    ///
61    /// "Belonging to" means the key begins with [`pair_prefix`]. `Ok(0)` is a
62    /// normal answer: the pair had nothing cached.
63    async fn purge(&self, id: &str) -> Result<u64, CacheError>;
64}
65
66/// Entries the local cache keeps before it starts evicting.
67const DEFAULT_MAX_ENTRIES: usize = 10_000;
68
69/// Fraction of capacity a sweep reclaims in one pass.
70///
71/// Evicting down to a low-water mark is what keeps the O(n) scan off the hot
72/// path: a saturated cache would otherwise pay a full scan on every write,
73/// forever. Reclaiming a tenth means the next ~capacity/10 inserts find room
74/// already waiting and return immediately.
75const RECLAIM_FRACTION: usize = 10;
76
77/// Process-local cache. Shared by every `policy: local` node in the gateway.
78pub struct LocalResponseCache {
79    entries: DashMap<String, (CachedResponse, Instant)>,
80    /// Read on every insert; set once at startup from `cache.max_entries`.
81    capacity: std::sync::atomic::AtomicUsize,
82    /// Set once at construction (`PluginResources::new`); `None` disables
83    /// recording (unit tests). Used only to count evictions — hits, misses
84    /// and errors are metered by the `proxy-cache` plugin, which knows the
85    /// `store` label this backend does not have.
86    metrics: Option<Arc<crate::metrics::GatewayMetrics>>,
87}
88
89impl Default for LocalResponseCache {
90    fn default() -> Self {
91        Self::new(None)
92    }
93}
94
95impl LocalResponseCache {
96    /// Creates the process-local cache, optionally wired to the metrics
97    /// registry so `make_room` can count evictions.
98    pub fn new(metrics: Option<Arc<crate::metrics::GatewayMetrics>>) -> Self {
99        Self {
100            entries: DashMap::new(),
101            capacity: std::sync::atomic::AtomicUsize::new(DEFAULT_MAX_ENTRIES),
102            metrics,
103        }
104    }
105
106    /// Sets the entry bound. Called once at startup, before traffic.
107    pub fn set_capacity(&self, max_entries: usize) {
108        self.capacity
109            .store(max_entries.max(1), std::sync::atomic::Ordering::Relaxed);
110    }
111
112    /// Entries currently held, live or not yet swept.
113    #[cfg(test)]
114    pub fn len(&self) -> usize {
115        self.entries.len()
116    }
117
118    /// Makes room for one more entry.
119    ///
120    /// Expired entries go first, since they are already worthless. If that is
121    /// not enough, the entries expiring soonest go next — deliberately not an
122    /// LRU (no LRU crate is in the dependency tree, and adding one for this is
123    /// not worth the supply-chain surface). For a cache whose entries all
124    /// carry TTLs this discards what was about to become useless anyway; the
125    /// cost is that a hot short-TTL entry loses to a cold long-TTL one.
126    ///
127    /// The live-entry eviction reclaims down to a low-water mark
128    /// (`capacity - capacity / RECLAIM_FRACTION`) in one sorted pass, rather
129    /// than removing exactly one entry via a fresh per-entry `min_by_key`
130    /// scan. Once the cache is saturated, a per-entry scan would make every
131    /// `put` pay an O(n) cost for the life of the process; amortising the
132    /// sort over a batch of evictions means most inserts, most of the time,
133    /// find room already waiting and skip straight to the insert.
134    fn make_room(&self, capacity: usize) {
135        if self.entries.len() < capacity {
136            return;
137        }
138
139        // Expired entries first: they are already worthless, and clearing them in
140        // bulk is the one thing a per-entry eviction cannot do cheaply.
141        let now = Instant::now();
142        self.entries.retain(|_, (_, expires_at)| now < *expires_at);
143        if self.entries.len() < capacity {
144            return;
145        }
146
147        // Still full, so live entries have to go. Sort once and remove a batch,
148        // rather than rescanning for the minimum per entry.
149        let target = capacity.saturating_sub((capacity / RECLAIM_FRACTION).max(1));
150        let excess = self.entries.len().saturating_sub(target);
151        if excess == 0 {
152            return;
153        }
154
155        let mut by_expiry: Vec<(String, Instant)> = self
156            .entries
157            .iter()
158            .map(|e| (e.key().clone(), e.value().1))
159            .collect();
160        by_expiry.sort_unstable_by_key(|(_, expires_at)| *expires_at);
161        // Counted separately from the expired sweep above: a live entry
162        // being evicted here — not merely swept for already being dead — is
163        // the signal that `max_entries` is too small for the working set.
164        let mut evicted: u64 = 0;
165        for (key, _) in by_expiry.into_iter().take(excess) {
166            if self.entries.remove(&key).is_some() {
167                evicted += 1;
168            }
169        }
170        if evicted > 0 {
171            if let Some(metrics) = &self.metrics {
172                metrics
173                    .cache_events
174                    .with_label_values(&["local", "", "eviction"])
175                    .inc_by(evicted);
176            }
177        }
178    }
179}
180
181#[async_trait]
182impl ResponseCache for LocalResponseCache {
183    async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheError> {
184        let Some(found) = self.entries.get(key) else {
185            return Ok(None);
186        };
187        if Instant::now() < found.1 {
188            let hit = found.0.clone();
189            Ok(Some(hit))
190        } else {
191            drop(found);
192            self.entries.remove(key);
193            Ok(None)
194        }
195    }
196
197    async fn put(
198        &self,
199        key: &str,
200        entry: &CachedResponse,
201        ttl: Duration,
202    ) -> Result<(), CacheError> {
203        self.make_room(self.capacity.load(std::sync::atomic::Ordering::Relaxed));
204        self.entries
205            .insert(key.to_string(), (entry.clone(), Instant::now() + ttl));
206        Ok(())
207    }
208
209    async fn purge(&self, id: &str) -> Result<u64, CacheError> {
210        let prefix = pair_prefix(id);
211        let mut removed = 0u64;
212        self.entries.retain(|key, _| {
213            if key.starts_with(&prefix) {
214                removed += 1;
215                false
216            } else {
217                true
218            }
219        });
220        Ok(removed)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn response(body: &str) -> CachedResponse {
229        let mut headers = HashMap::new();
230        headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
231        CachedResponse {
232            status: 200,
233            headers,
234            body: Bytes::from(body.to_string()),
235        }
236    }
237
238    #[tokio::test]
239    async fn test_local_round_trips_a_response() {
240        let cache = LocalResponseCache::default();
241        cache
242            .put("k", &response("hello"), Duration::from_secs(60))
243            .await
244            .unwrap();
245
246        let got = cache.get("k").await.unwrap().expect("a stored entry");
247        assert_eq!(got.status, 200);
248        assert_eq!(got.body, Bytes::from("hello"));
249        assert_eq!(
250            got.headers.get("content-type").unwrap(),
251            &vec!["text/plain".to_string()]
252        );
253    }
254
255    #[tokio::test]
256    async fn test_local_miss_is_ok_none_not_an_error() {
257        // The distinction the whole design rests on: "nothing stored" is a
258        // successful answer, not a failure. Only a backend that could not
259        // answer returns Err.
260        let cache = LocalResponseCache::default();
261        assert!(cache.get("absent").await.unwrap().is_none());
262    }
263
264    #[tokio::test]
265    async fn test_local_entry_expires() {
266        let cache = LocalResponseCache::default();
267        cache
268            .put("k", &response("x"), Duration::from_millis(1))
269            .await
270            .unwrap();
271        tokio::time::sleep(Duration::from_millis(20)).await;
272        assert!(
273            cache.get("k").await.unwrap().is_none(),
274            "an expired entry must not be served"
275        );
276    }
277
278    #[tokio::test]
279    async fn test_local_evicts_expired_entries_before_live_ones() {
280        // An already-expired entry always has the earliest `Instant` of any
281        // entry in the map, so a single expired entry can't tell a two-phase
282        // sweep (expire all, then batch-evict live ones) apart from a
283        // one-phase "always evict the minimum" implementation: either would
284        // remove it. Several expired entries do distinguish the two: a
285        // per-entry min-eviction would stop after removing just one (as soon
286        // as it's back under capacity), while the sweep clears all of them
287        // in one pass.
288        let cache = LocalResponseCache::default();
289        cache.set_capacity(5);
290
291        for i in 0..4 {
292            cache
293                .put(
294                    &format!("dead{i}"),
295                    &response("d"),
296                    Duration::from_millis(1),
297                )
298                .await
299                .unwrap();
300        }
301        cache
302            .put("live", &response("l"), Duration::from_secs(60))
303            .await
304            .unwrap();
305        tokio::time::sleep(Duration::from_millis(20)).await;
306
307        // At capacity: a single put must trigger the sweep and clear every
308        // expired entry, not just one of them.
309        cache
310            .put("new", &response("n"), Duration::from_secs(60))
311            .await
312            .unwrap();
313
314        for i in 0..4 {
315            assert!(
316                cache.get(&format!("dead{i}")).await.unwrap().is_none(),
317                "all expired entries must be swept, not just one"
318            );
319        }
320        assert!(
321            cache.get("live").await.unwrap().is_some(),
322            "a live entry must survive a sweep"
323        );
324        assert!(cache.get("new").await.unwrap().is_some());
325    }
326
327    #[tokio::test]
328    async fn test_local_at_capacity_evicts_the_soonest_to_expire() {
329        let cache = LocalResponseCache::default();
330        cache.set_capacity(2);
331
332        // `long` is inserted first and `short` second, so insertion order and
333        // expiry order disagree: an "evict oldest-inserted" implementation
334        // would evict `long`, not `short`. Only a policy that actually looks
335        // at expiry time evicts `short` here.
336        cache
337            .put("long", &response("l"), Duration::from_secs(600))
338            .await
339            .unwrap();
340        cache
341            .put("short", &response("s"), Duration::from_secs(1))
342            .await
343            .unwrap();
344        cache
345            .put("new", &response("n"), Duration::from_secs(600))
346            .await
347            .unwrap();
348
349        // Nothing has expired, so the bound falls back to discarding what was
350        // about to become worthless anyway. This is NOT an LRU and the test
351        // says so: a hot short-TTL entry loses to a cold long-TTL one.
352        assert!(cache.get("short").await.unwrap().is_none());
353        assert!(cache.get("long").await.unwrap().is_some());
354        assert!(cache.get("new").await.unwrap().is_some());
355    }
356
357    /// Spec §8: an eviction driven by a full cache (not a merely-expired
358    /// sweep) must be visible, since it is the only signal that `max_entries`
359    /// is too small for the working set.
360    #[tokio::test]
361    async fn test_an_eviction_at_capacity_increments_the_eviction_counter() {
362        let metrics = Arc::new(crate::metrics::GatewayMetrics::new());
363        let cache = LocalResponseCache::new(Some(metrics.clone()));
364        cache.set_capacity(2);
365
366        // None of these expire during the test, so every entry beyond
367        // capacity is evicted live, not swept as already-dead.
368        cache
369            .put("a", &response("a"), Duration::from_secs(600))
370            .await
371            .unwrap();
372        cache
373            .put("b", &response("b"), Duration::from_secs(600))
374            .await
375            .unwrap();
376        cache
377            .put("c", &response("c"), Duration::from_secs(600))
378            .await
379            .unwrap();
380
381        assert!(
382            metrics
383                .cache_events
384                .with_label_values(&["local", "", "eviction"])
385                .get()
386                >= 1,
387            "a live-entry eviction at capacity must be counted"
388        );
389    }
390
391    /// The prefix boundary is the whole safety argument: purging `products`
392    /// must not touch `products-v2`, whose keys share every byte up to the
393    /// separator.
394    #[tokio::test]
395    async fn test_local_purge_removes_only_the_named_pair() {
396        let cache = LocalResponseCache::default();
397        let ttl = Duration::from_secs(60);
398        cache
399            .put("products\u{1}/a", &response("a"), ttl)
400            .await
401            .unwrap();
402        cache
403            .put("products\u{1}/b", &response("b"), ttl)
404            .await
405            .unwrap();
406        cache
407            .put("products-v2\u{1}/a", &response("v2"), ttl)
408            .await
409            .unwrap();
410
411        let removed = cache.purge("products").await.unwrap();
412
413        assert_eq!(removed, 2);
414        assert!(cache.get("products\u{1}/a").await.unwrap().is_none());
415        assert!(cache.get("products\u{1}/b").await.unwrap().is_none());
416        assert!(
417            cache.get("products-v2\u{1}/a").await.unwrap().is_some(),
418            "a sibling pair sharing a textual prefix must survive"
419        );
420    }
421
422    /// The count must reflect exactly the entries removed, not a
423    /// `before - after` delta taken around the retain: a concurrent `put`
424    /// landing in a shard the shard-by-shard retain has not yet visited grows
425    /// `len()` mid-purge, so a before/after diff undercounts (or, given
426    /// enough concurrent insertions, underflows the `usize` subtraction
427    /// outright, panicking in debug and wrapping to a huge garbage count in
428    /// release). Counting inside the retain closure itself is immune: it only
429    /// ever sees the entries it actually drops.
430    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
431    async fn test_local_purge_counts_removed_entries_not_a_before_after_delta() {
432        let cache = std::sync::Arc::new(LocalResponseCache::default());
433        let ttl = Duration::from_secs(60);
434
435        // A large filler dataset -- inserted directly, bypassing `put`'s
436        // capacity bookkeeping, which is irrelevant here -- makes the
437        // retain's shard-by-shard scan below take long enough in real wall
438        // time for a concurrent writer to land insertions in shards it has
439        // not yet visited: the exact window the old before/after diff got
440        // wrong.
441        for i in 0..300_000u64 {
442            cache.entries.insert(
443                format!("filler\u{1}/{i}"),
444                (response("f"), Instant::now() + ttl),
445            );
446        }
447        for i in 0..3 {
448            cache.entries.insert(
449                format!("products\u{1}/{i}"),
450                (response("p"), Instant::now() + ttl),
451            );
452        }
453        for i in 0..2 {
454            cache.entries.insert(
455                format!("other\u{1}/{i}"),
456                (response("o"), Instant::now() + ttl),
457            );
458        }
459
460        // A real OS thread, writing straight into the map (bypassing the
461        // async `put` wrapper, which adds only overhead here), released at
462        // the same instant as the purge below via a barrier so it genuinely
463        // races the retain rather than merely interleaving at `.await`
464        // points on a cooperative scheduler.
465        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
466        let writer = {
467            let cache = cache.clone();
468            let barrier = barrier.clone();
469            std::thread::spawn(move || {
470                barrier.wait();
471                let entry = (response("c"), Instant::now() + Duration::from_secs(600));
472                for i in 0..100_000u64 {
473                    cache
474                        .entries
475                        .insert(format!("concurrent\u{1}/{i}"), entry.clone());
476                }
477            })
478        };
479
480        barrier.wait();
481        let removed = cache.purge("products").await.unwrap();
482        writer.join().unwrap();
483
484        assert_eq!(
485            removed, 3,
486            "must count exactly the 3 pair entries removed, regardless of \
487             concurrent unrelated writes racing the purge"
488        );
489        assert!(cache.get("other\u{1}/0").await.unwrap().is_some());
490        assert!(cache.get("other\u{1}/1").await.unwrap().is_some());
491    }
492
493    /// Purging a pair that cached nothing is a normal answer, not a failure.
494    #[tokio::test]
495    async fn test_local_purge_of_an_empty_pair_is_zero_not_an_error() {
496        let cache = LocalResponseCache::default();
497        assert_eq!(cache.purge("nothing-here").await.unwrap(), 0);
498    }
499
500    #[tokio::test]
501    async fn test_local_never_exceeds_its_capacity() {
502        // Sequential writes are close to structurally guaranteed to respect
503        // the bound, since `make_room` runs before every insert. The
504        // interesting case — and the one the assertion message below has
505        // always claimed to cover — is concurrent writers racing `make_room`
506        // and `insert` against each other.
507        let cache = std::sync::Arc::new(LocalResponseCache::default());
508        cache.set_capacity(4);
509
510        let mut tasks = Vec::new();
511        for task in 0..8 {
512            let cache = cache.clone();
513            tasks.push(tokio::spawn(async move {
514                for i in 0..50 {
515                    cache
516                        .put(
517                            &format!("k{task}-{i}"),
518                            &response("x"),
519                            Duration::from_secs(600),
520                        )
521                        .await
522                        .unwrap();
523                }
524            }));
525        }
526        for task in tasks {
527            task.await.unwrap();
528        }
529
530        assert!(
531            cache.len() <= 4,
532            "the bound must hold under sustained writes: {}",
533            cache.len()
534        );
535    }
536}