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}