dwara_core/dataplane/response_cache.rs
1//! Local response cache engine (DW-037, feature analysis 5-Protocol
2//! "Response caching").
3//!
4//! Sits behind the [`CacheStore`] extension seam
5//! (DW-004): the engine owns HTTP semantics — keys,
6//! cacheability, freshness, revalidation, purge — while the backend is
7//! an opaque byte store (OSS ships the moka-backed [`MokaCache`];
8//! DW-068 swaps in Redis behind the same trait). Cache errors degrade
9//! to misses, never request failures.
10//!
11//! ## Where it sits in the pipeline
12//!
13//! ```text
14//! ... authn -> authz -> rate limit -> cap admission
15//! -> CACHE LOOKUP (hit/stale: replay; miss: fall through)
16//! -> proxy action -> masking (DW-029) -> body/header transforms (DW-028)
17//! -> CACHE STORE (post-mask, post-transform bytes; pre-compression)
18//! -> compression (DW-027) -> versioning stamps (DW-048) -> CORS (DW-027)
19//! -> security headers (DW-028) -> rate headers
20//! ```
21//!
22//! The two placements are load-bearing:
23//!
24//! - LOOKUP runs after authn/authz/rate limiting on purpose: a replay
25//! is still client traffic (it consumed a rate-limit token and an
26//! admission slot, and the consumer's identity is part of the key),
27//! and no policy may be bypassed by a hit. It runs before the
28//! breaker/endpoint pick because a hit contacts no upstream.
29//! - STORE writes the POST-masking, POST-transform bytes keyed per
30//! consumer: replayed bytes are exactly what the same consumer would
31//! have received (masking and transforms are route-scoped, so replay
32//! consistency holds — pinned by test: a transform change invalidates
33//! old entries). Stored bodies are IDENTITY (never compressed —
34//! DW-027's compression re-negotiates per request on replay), and
35//! the decoration tail from compression onward re-runs on every
36//! replay: security headers, masking-era bytes, CORS, rate headers
37//! can never be bypassed by a cache hit.
38//!
39//! ## Cacheability (deterministic, closed rules)
40//!
41//! A REQUEST is cacheable when: the route has a `cache` block and a
42//! PROXY action; the method is GET or HEAD (DP-04: HEAD is cacheable —
43//! a HEAD response carries the same headers as GET with no body, and
44//! the method folds into the key so the two representations are
45//! distinct entries; a fresh GET entry also serves a HEAD via the
46//! GET-fallback, RFC 9111 section 4.1); the request carries no body,
47//! no `Authorization`, no `Cookie` (credentials make per-consumer
48//! keying insufficient — two bearer tokens of one consumer would share
49//! an entry), and no `Upgrade`. Everything else is a BYPASS (stamped
50//! and counted, never stored).
51//!
52//! A RESPONSE is storable when: status is exactly 200; it carries no
53//! `Set-Cookie`; its `Cache-Control` has none of `no-store` /
54//! `private` / `no-cache` (the storage vetoes; DP-04 additionally
55//! honors `s-maxage`/`max-age` as the freshness lifetime — taking
56//! precedence over the configured `ttl_secs` — and `stale-if-error`
57//! as the per-entry stale-on-error window, RFC 5861 section 4); it is
58//! not content-encoded (dwara compresses on replay; an upstream-encoded
59//! body cannot be re-negotiated); and its `Vary` is `*`-free and a
60//! subset of the route's effective vary set (see `config::cache` for
61//! the configured + policy-derived variance model — the key must be
62//! derivable from the request alone, so an unknown variance dimension
63//! forbids storage).
64//!
65//! ## Keys
66//!
67//! `sha256("dwara-rc-v1" | route | epoch | method | consumer | path |
68//! query | vary-name=value...)` — hex-encoded. The method component
69//! (DP-04) separates HEAD and GET representations of one resource.
70//! The consumer component means masked (DW-029) and consumer-group
71//! variants can never cross consumers. Keys are never logged (paths
72//! and query strings carry tokens; the hash is opaque anyway).
73//!
74//! ## Freshness, stale-while-revalidate, ETag
75//!
76//! Fresh for the effective freshness lifetime — the upstream
77//! `Cache-Control: s-maxage` (then `max-age`) when present (DP-04,
78//! RFC 7234 section 5.2.2.9), else the configured `ttl_secs`; within
79//! `stale_while_revalidate_secs` after expiry the entry is served stale (`x-cache: stale`) while ONE
80//! background revalidation runs per key (a bounded in-flight set
81//! deduplicates; DW-038's request coalescing applies the same
82//! single-flight discipline to the foreground miss path). Past the
83//! window the next request revalidates synchronously: the stored
84//! validator rides the forwarded fetch as `If-None-Match` (only when
85//! the client sent none of its own — a client conditional always wins
86//! the forwarded request), and an upstream 304 refreshes the entry
87//! without re-sending the body (`x-cache: revalidated`). A client
88//! `If-None-Match` that matches a FRESH entry's validator is answered
89//! 304 straight from the cache. Weak comparison (W/ prefixes ignored)
90//! per RFC 9110 section 8.8.3.
91//!
92//! ## Request coalescing (DW-038)
93//!
94//! A cache MISS on a route whose cache block carries `coalescing`
95//! either becomes the LEADER (fetches upstream while holding the key's
96//! slot) or a FOLLOWER (parks, bounded by the route's
97//! `coalescing.wait_ms`). The coalescing key IS the cache key — route
98//! epoch, consumer, path, query, vary — so a follower can only ever be
99//! handed an outcome computed for a byte-identical request shape of
100//! its OWN consumer and generation; per-consumer isolation is
101//! inherited from the key, not reimplemented. The STORE is the share
102//! point: the leader's store stage completes before it publishes, and
103//! a woken follower re-reads the store and replays the entry exactly
104//! like a hit (`x-cache: hit`; the decoration tail re-runs — the same
105//! replay guarantees as a normal hit). Scope: the miss path of
106//! cache-enabled routes only. A request shape the cache bypasses
107//! (non-GET, credentialed, body-bearing, upgrade) never coalesces,
108//! and routes without a cache block never coalesce — "concurrent
109//! identical cacheable GETs" is the whole claim.
110//!
111//! Every failure mode fails OPEN (no client is ever errored because
112//! coalescing gave up), each rule pinned by test:
113//!
114//! - Leader finished with nothing storable (vetoed, non-200, upstream
115//! error, over-cap) or died outright (panic, client-cancel abort):
116//! followers fetch on their own, each running the route's FULL
117//! retry policy — a leader's failure is never inherited.
118//! - Epoch flipped mid-flight (purge or config change): followers
119//! fetch on their own rather than inherit a dead generation's
120//! answer.
121//! - Follower wait bound expired: the follower fetches on its own.
122//! - The leader map is saturated ([`MAX_COALESCING_KEYS`] distinct
123//! in-flight keys): the request never joins — it just fetches
124//! (uncounted by coalescing metrics; it was neither leader nor
125//! follower).
126//!
127//! The map holds LEADER slots only (waiters carry no per-key state);
128//! a slot leaves the map at completion by the leader's guard, and the
129//! guard's Drop is the publish — so a leader that dies unpublishes
130//! into the fail-open path too. Nothing in the coalescing path waits
131//! on the SWR revalidation in-flight set or vice versa: the two
132//! single-flight mechanisms have disjoint state and no shared locks,
133//! so they cannot deadlock or double-subscribe each other.
134//!
135//! ## Invalidations (why epochs)
136//!
137//! Entries record the route's CACHE EPOCH at store time; a lookup
138//! under a different epoch is a miss (the dead entry is dropped).
139//! Epochs bump on: an explicit purge (the admin API — an O(1)
140//! generation advance, which is why purge is <100 ms at any store
141//! size; the opaque backend is never enumerated), and any snapshot
142//! publish that CHANGES a route's definition (a `Route`-equality diff
143//! at refresh — stored bytes were shaped by the old masking/transform/
144//! cache policy, so any route change invalidates that route's entries;
145//! unrelated config edits leave the cache warm). Entries left
146//! unreachable by a bump are never re-read; the byte-weighed store
147//! reclaims them by eviction.
148//!
149//! DP-04 adds two targeted purge axes alongside the epoch-based route
150//! purge: purge-by-tag (every entry the upstream tagged with a given
151//! `Cache-Tags` value) and purge-by-URL (every entry for an exact or
152//! prefix-matched request URL). These delete specific keys through the
153//! `CacheStore` seam (O(keys-matching), not O(1)) using in-memory
154//! tag→keys and URL→keys indexes populated at store time; the indexes
155//! are runtime state (lost on restart) and self-clean lazily as purges
156//! drop their dead cross-references.
157//!
158//! ## Reload behavior
159//!
160//! The engine (store, epochs, in-flight set) lives on the
161//! [`DataPlane`], NOT in the snapshot: it is
162//! runtime state, like the priority counters, and survives config
163//! reloads. A changed `cache` block applies to NEW lookups (freshness
164//! windows are read from the CURRENT policy at lookup time); entries
165//! stored under a changed route die by the epoch rule above.
166
167use std::collections::{HashMap, HashSet};
168use std::net::IpAddr;
169use std::pin::Pin;
170use std::sync::atomic::{AtomicU64, Ordering};
171use std::sync::{Arc, RwLock};
172use std::task::{Context, Poll};
173use std::time::Duration;
174
175use bytes::Bytes;
176// #272: parking_lot::Mutex for the coalescing/inflight critical sections.
177// These are brief (HashMap ops, never across an await) but touched on
178// every cache miss; parking_lot's adaptive spinning avoids OS-level
179// thread blocking under moderate contention, keeping tokio workers
180// productive.
181use http_body_util::{BodyExt as _, Full};
182use hyper::body::Body as _;
183use hyper::header::{HeaderName, HeaderValue};
184use hyper::header::{ETAG, IF_NONE_MATCH};
185use hyper::{HeaderMap, Method, Request, Response, StatusCode};
186use parking_lot::Mutex;
187use sha2::{Digest, Sha256};
188use tokio::sync::OwnedSemaphorePermit;
189
190use crate::config::cache::CompiledRouteCache;
191use crate::config::Route;
192use crate::extensions::cache::{CacheStore, MokaCache};
193use crate::observability::Observability;
194use crate::security::authn::Identity;
195use crate::snapshot::Snapshot;
196
197use super::hardening::merge_vary;
198use super::proxy::{DataPlane, ProxyBody};
199use super::transforms;
200
201/// The gateway's cache-outcome stamp header (DW-037): `hit`, `stale`,
202/// `miss`, `bypass`, or `revalidated` — the same closed set as the
203/// `dwara_cache_lookups_total` metric (plus `revalidated`, which is a
204/// miss resolved by a 304 confirmation).
205pub const X_CACHE: HeaderName = HeaderName::from_static("x-cache");
206
207/// Envelope magic + schema version (see [`EntryEnvelope`]).
208const ENVELOPE_MAGIC: [u8; 4] = *b"DWRC";
209/// Envelope schema version 1 (DW-037): epoch, stored-at, status,
210/// headers, body. Decoded for back-compat with entries a long-lived
211/// store may still hold across an in-place upgrade.
212const ENVELOPE_VERSION_V1: u8 = 1;
213/// Envelope schema version 2 (DP-04): adds the per-entry freshness TTL
214/// and the `stale-if-error` window (both in milliseconds, 0 = "use the
215/// configured policy"). v2 is what [`EntryEnvelope::encode`] writes;
216/// v1 entries decode with both fields zeroed (the policy applies).
217const ENVELOPE_VERSION: u8 = 2;
218
219/// Upper bound on concurrently in-flight background revalidations
220/// (DW-037): the stale-while-revalidate path serves stale immediately
221/// and refreshes in the background; beyond this many DISTINCT keys the
222/// gateway skips spawning (the next request past the stale window
223/// revalidates synchronously) so a cache-wide expiry cannot stampede.
224pub const MAX_INFLIGHT_REVALIDATIONS: usize = 32;
225
226/// Upper bound on concurrently held coalescing LEADER slots (DW-038).
227/// The map bounds memory (each slot is a key string + a watch channel)
228/// and the stampede-collapse itself: beyond this many DISTINCT
229/// in-flight cache keys, new misses never join — they fetch
230/// independently (fail open, uncounted by coalescing metrics). Slots
231/// are not evicted early (an in-flight leader cannot be preempted —
232/// its followers are parked on it); the map drains as leaders
233/// complete, which IS the eviction policy: hold-while-in-flight,
234/// remove-at-completion, refuse-at-capacity. A same-process burst
235/// with more than this many distinct keys was never going to collapse
236/// anyway — the keys are distinct.
237pub const MAX_COALESCING_KEYS: usize = 256;
238
239/// Wall-clock milliseconds since the Unix epoch (the freshness clock
240/// domain; a backwards clock step reads as age 0 — never negative).
241fn now_ms() -> u64 {
242 std::time::SystemTime::now()
243 .duration_since(std::time::UNIX_EPOCH)
244 .map(|d| d.as_millis() as u64)
245 .unwrap_or(0)
246}
247
248/// The local response cache: opaque store + route epochs + the
249/// single-flight revalidation guard + the request-coalescing leader
250/// map. Owned by the [`DataPlane`]; survives reloads.
251pub struct ResponseCache {
252 /// The backing `CacheStore` (SCALE-04, #183: swappable at startup
253 /// so the OSS moka backend can be replaced with a Redis-backed
254 /// shared cache without rebuilding the whole `ResponseCache`).
255 store: RwLock<Arc<dyn CacheStore>>,
256 /// Route name -> cache epoch (DW-037 invalidations). Grows with
257 /// distinct route names ever seen (bounded by operator config
258 /// churn; entries for long-gone routes are a few dozen bytes each
259 /// and keep re-added names from inheriting stale entries).
260 epochs: RwLock<HashMap<String, u64>>,
261 /// Keys with a background revalidation in flight (bounded by
262 /// [`MAX_INFLIGHT_REVALIDATIONS`]).
263 inflight: Arc<Mutex<HashSet<String>>>,
264 /// In-flight coalescing LEADERS (DW-038): cache key -> publication
265 /// slot. A leader holds its slot from the miss decision until its
266 /// store stage completes (or its task dies — the guard drops).
267 /// Bounded by [`MAX_COALESCING_KEYS`]. Runtime state like the
268 /// revalidation set: survives reloads, and its keys embed the
269 /// route epoch, so a generation change strands waiters into the
270 /// fail-open path rather than serving them a dead generation.
271 coalescing: Arc<Mutex<HashMap<String, Arc<CoalesceSlot>>>>,
272 /// Cache-tag -> set of store keys (DP-04 purge-by-tag). Populated
273 /// at store time from the upstream `Cache-Tags` response header.
274 /// Purge-by-tag deletes every key in the set (and cleans the
275 /// entries). In-memory: lost on restart, so a purge-by-tag after a
276 /// restart is a no-op until entries are re-stored — the cache is a
277 /// local optimization, and the epoch-based route purge remains the
278 /// durable invalidation path. Bounded lazily: dead keys (evicted or
279 /// epoch-bumped) are removed when a purge touches their tag.
280 tag_index: RwLock<HashMap<String, HashSet<String>>>,
281 /// Request URL (`path[?query]`) -> set of store keys (DP-04
282 /// purge-by-URL). One URL maps to many keys because the cache key
283 /// also folds the route epoch, the consumer, and the vary values,
284 /// all invisible to a purge caller. Same lifecycle as
285 /// [`ResponseCache::tag_index`].
286 url_index: RwLock<HashMap<String, HashSet<String>>>,
287 /// Purges and epoch bumps performed (for /stats; the metrics
288 /// counter lives in observability).
289 purges: AtomicU64,
290}
291
292impl Default for ResponseCache {
293 fn default() -> Self {
294 ResponseCache::new(Arc::new(MokaCache::default()))
295 }
296}
297
298impl ResponseCache {
299 /// Build over a specific backend (tests inject the plain
300 /// in-memory store; the OSS gateway uses the moka backend).
301 pub fn new(store: Arc<dyn CacheStore>) -> Self {
302 ResponseCache {
303 store: RwLock::new(store),
304 epochs: RwLock::new(HashMap::new()),
305 inflight: Arc::new(Mutex::new(HashSet::new())),
306 coalescing: Arc::new(Mutex::new(HashMap::new())),
307 tag_index: RwLock::new(HashMap::new()),
308 url_index: RwLock::new(HashMap::new()),
309 purges: AtomicU64::new(0),
310 }
311 }
312
313 /// Swap the backing store (SCALE-04, #183). Called at startup when
314 /// a Redis cache config is present and the license grants the
315 /// `redis_cache` feature claim. The old store's entries are NOT
316 /// migrated (the new store starts cold; a two-tier `CoordinatedCache`
317 /// fronts Redis with the local moka cache so hot keys survive).
318 pub fn set_store(&self, store: Arc<dyn CacheStore>) {
319 *self.store.write().expect("cache store lock poisoned") = store;
320 }
321
322 /// Clone of the backing store Arc (admin/metrics introspection seam).
323 pub fn store_handle(&self) -> Arc<dyn CacheStore> {
324 Arc::clone(&self.store.read().expect("cache store lock poisoned"))
325 }
326
327 /// Approximate live entries (the `dwara_cache_entries` gauge walk;
328 /// 0 when the backend cannot report).
329 pub fn live_entries(&self) -> i64 {
330 self.store
331 .read()
332 .expect("cache store lock poisoned")
333 .entry_count()
334 .unwrap_or(0) as i64
335 }
336
337 /// Purges + epoch bumps performed since process start.
338 pub fn purge_count(&self) -> u64 {
339 self.purges.load(Ordering::Relaxed)
340 }
341
342 /// The route's current cache epoch (0 for never-bumped routes).
343 pub fn epoch(&self, route: &str) -> u64 {
344 self.epochs
345 .read()
346 .expect("cache epoch lock poisoned")
347 .get(route)
348 .copied()
349 .unwrap_or(0)
350 }
351
352 /// Advance one route's epoch (admin purge, DW-037). Every entry
353 /// stored under an earlier epoch is unreachable from the next
354 /// lookup — O(1), no store enumeration, which is the whole point.
355 pub fn bump_route(&self, route: &str) -> u64 {
356 let mut epochs = self.epochs.write().expect("cache epoch lock poisoned");
357 let next = epochs.get(route).copied().unwrap_or(0) + 1;
358 epochs.insert(route.to_string(), next);
359 self.purges.fetch_add(1, Ordering::Relaxed);
360 next
361 }
362
363 /// Purge every named route at once (admin purge-all): bumps each
364 /// CURRENT route's epoch (routes no longer in the config have no
365 /// reachable entries). Returns how many routes were invalidated.
366 pub fn purge_all(&self, route_names: impl IntoIterator<Item = String>) -> usize {
367 let mut epochs = self.epochs.write().expect("cache epoch lock poisoned");
368 let mut bumped = 0;
369 for name in route_names {
370 let next = epochs.get(&name).copied().unwrap_or(0) + 1;
371 epochs.insert(name, next);
372 bumped += 1;
373 }
374 self.purges.fetch_add(1, Ordering::Relaxed);
375 bumped
376 }
377
378 /// Purge every entry tagged with `tag` (DP-04). The tag's keys are
379 /// deleted from the store and the tag's index entry is cleared.
380 /// Returns the number of keys the tag mapped to (a tag with no
381 /// entries, or one the gateway never saw, returns 0). Unlike the
382 /// epoch-based route purge this is an O(keys-with-tag) store
383 /// deletion — the tag index is the only place the opaque
384 /// `CacheStore` seam exposes which keys carry a tag.
385 pub async fn purge_by_tag(&self, tag: &str) -> usize {
386 let keys = {
387 let mut index = self.tag_index.write().expect("cache tag index poisoned");
388 index.remove(tag).unwrap_or_default()
389 };
390 let n = keys.len();
391 for key in &keys {
392 let _ = {
393 let s = self.store_handle();
394 s.delete(key).await
395 };
396 }
397 // Drop the purged keys from the URL index too (lazy cleanup of
398 // any dead cross-references the purge just made).
399 if !keys.is_empty() {
400 let mut url_index = self.url_index.write().expect("cache url index poisoned");
401 for set in url_index.values_mut() {
402 set.retain(|k| !keys.contains(k));
403 }
404 url_index.retain(|_, set| !set.is_empty());
405 }
406 if n > 0 {
407 self.purges.fetch_add(1, Ordering::Relaxed);
408 }
409 n
410 }
411
412 /// Purge every entry whose request URL matches (DP-04). With
413 /// `prefix` false the URL must match exactly (`path[?query]`); with
414 /// `prefix` true every URL whose path starts with `prefix` as a path
415 /// segment is purged (e.g. `/api/users/` evicts every user entry;
416 /// `/api/users` evicts `/api/users`, `/api/users/123`, and
417 /// `/api/users?x=1` but NOT `/api/users2`). Returns the number of
418 /// keys deleted. The URL index maps one URL to many keys (the cache
419 /// key also folds the route epoch, the consumer, and the vary
420 /// values), so a single URL purge can delete several entries.
421 pub async fn purge_by_url(&self, url: &str, prefix: bool) -> usize {
422 let mut keys: HashSet<String> = HashSet::new();
423 {
424 let mut index = self.url_index.write().expect("cache url index poisoned");
425 if prefix {
426 let matching: Vec<String> = index
427 .keys()
428 .filter(|u| url_prefix_match(u, url))
429 .cloned()
430 .collect();
431 for u in matching {
432 if let Some(set) = index.remove(&u) {
433 keys.extend(set);
434 }
435 }
436 } else if let Some(set) = index.remove(url) {
437 keys.extend(set);
438 }
439 }
440 let n = keys.len();
441 for key in &keys {
442 let _ = {
443 let s = self.store_handle();
444 s.delete(key).await
445 };
446 }
447 // Lazy cleanup of the tag index's now-dead cross-references.
448 if !keys.is_empty() {
449 let mut tag_index = self.tag_index.write().expect("cache tag index poisoned");
450 for set in tag_index.values_mut() {
451 set.retain(|k| !keys.contains(k));
452 }
453 tag_index.retain(|_, set| !set.is_empty());
454 }
455 if n > 0 {
456 self.purges.fetch_add(1, Ordering::Relaxed);
457 }
458 n
459 }
460
461 /// Record a freshly stored entry's tags and request URL in the
462 /// purge indexes (DP-04). Called from the store stage after a
463 /// successful write. Tags come from the upstream `Cache-Tags`
464 /// response header (comma-separated, whitespace-trimmed); the URL
465 /// is the request's `path[?query]`. A store that overwrites an
466 /// existing key with DIFFERENT tags/URL first removes the key from
467 /// its old tag/URL sets (reverse cleanup) so a later purge_by_tag
468 /// cannot over-evict via stale tag associations.
469 fn record_indexes(&self, key: &str, tags: &[String], url: &str) {
470 // Reverse cleanup: remove the key from any old tag/URL sets
471 // before recording the new associations. This prevents a
472 // re-store with different tags from leaving the key reachable
473 // by its old tags (which would cause over-eviction on purge).
474 let new_tag_set: HashSet<&str> = tags.iter().map(String::as_str).collect();
475 {
476 let mut tag_index = self.tag_index.write().expect("cache tag index poisoned");
477 for (tag, keys) in tag_index.iter_mut() {
478 if !new_tag_set.contains(tag.as_str()) {
479 keys.remove(key);
480 }
481 }
482 tag_index.retain(|_, set| !set.is_empty());
483 }
484 {
485 let mut url_index = self.url_index.write().expect("cache url index poisoned");
486 for (old_url, keys) in url_index.iter_mut() {
487 if old_url != url {
488 keys.remove(key);
489 }
490 }
491 url_index.retain(|_, set| !set.is_empty());
492 }
493 // Record the new associations.
494 if !tags.is_empty() {
495 let mut index = self.tag_index.write().expect("cache tag index poisoned");
496 for tag in tags {
497 index
498 .entry(tag.clone())
499 .or_default()
500 .insert(key.to_string());
501 }
502 }
503 if !url.is_empty() {
504 let mut index = self.url_index.write().expect("cache url index poisoned");
505 index
506 .entry(url.to_string())
507 .or_default()
508 .insert(key.to_string());
509 }
510 }
511
512 /// Generation-change invalidation (called from
513 /// [`DataPlane::refresh`] before the swap): a route whose
514 /// definition CHANGED (Route equality — masking, transforms, the
515 /// cache block itself, anything) has its epoch bumped, because its
516 /// stored bytes were shaped by the old definition. Unchanged
517 /// routes keep their entries warm; removed routes bump once so a
518 /// later same-name re-add cannot inherit them.
519 pub fn note_generation(&self, old: Option<&Snapshot>, fresh: &Snapshot) {
520 let old_routes: HashMap<&str, &Route> = old
521 .map(|s| {
522 s.gateway()
523 .routes
524 .iter()
525 .map(|r| (r.name.as_str(), r))
526 .collect()
527 })
528 .unwrap_or_default();
529 let new_names: HashSet<&str> = fresh
530 .gateway()
531 .routes
532 .iter()
533 .map(|r| r.name.as_str())
534 .collect();
535 let mut epochs = self.epochs.write().expect("cache epoch lock poisoned");
536 let bump = |name: &str, epochs: &mut HashMap<String, u64>| {
537 let next = epochs.get(name).copied().unwrap_or(0) + 1;
538 epochs.insert(name.to_string(), next);
539 };
540 for r in &fresh.gateway().routes {
541 match old_routes.get(r.name.as_str()) {
542 Some(old_route) if **old_route == *r => {}
543 _ => bump(&r.name, &mut epochs),
544 }
545 }
546 for name in old_routes.keys() {
547 if !new_names.contains(name) {
548 bump(name, &mut epochs);
549 }
550 }
551 }
552
553 /// The lookup half of the cache (DW-037), running after authn/
554 /// authz/rate limiting/admission and before the proxy action.
555 /// See the module docs for the cacheability rules and the key
556 /// derivation; this function only classifies and either replays or
557 /// hands a [`MissFlow`] to the store stage.
558 #[allow(clippy::too_many_arguments)] // the per-request explicit-inputs rule (see proxy_request)
559 pub async fn lookup(
560 self: &Arc<Self>,
561 dp: &Arc<DataPlane>,
562 policy: &Arc<CompiledRouteCache>,
563 route: &Route,
564 identity: Option<&Identity>,
565 peer: IpAddr,
566 path: &str,
567 query: Option<&str>,
568 req_headers: &HeaderMap,
569 method: &Method,
570 declared_body_bytes: Option<u64>,
571 obs: &Observability,
572 ) -> LookupOutcome {
573 // Request-side gates (each miss of a gate is a BYPASS: stamped,
574 // counted, never stored — the deterministic closed set).
575 let bypass = |obs: &Observability| {
576 obs.record_cache_lookup("bypass");
577 LookupOutcome::Bypass
578 };
579 // DP-04: HEAD is cacheable alongside GET. A HEAD response carries
580 // the same headers as GET with no body; the method folds into the
581 // key so a HEAD representation (no body) and a GET representation
582 // (full body) of one resource are distinct entries (RFC 9111
583 // section 4.1). Other methods remain a bypass.
584 let is_head = *method == Method::HEAD;
585 if !is_head && *method != Method::GET {
586 return bypass(obs);
587 }
588 if req_headers.contains_key(hyper::header::AUTHORIZATION)
589 || req_headers.contains_key(hyper::header::COOKIE)
590 || req_headers.contains_key(hyper::header::UPGRADE)
591 {
592 return bypass(obs);
593 }
594 if declared_body_bytes.is_some_and(|n| n > 0) {
595 return bypass(obs);
596 }
597
598 let epoch = self.epoch(&route.name);
599 let vary_values = capture_vary_values(&policy.vary, req_headers);
600 let key = derive_key(
601 &route.name,
602 epoch,
603 identity,
604 method,
605 path,
606 query,
607 &vary_values,
608 );
609
610 let store = self.store_handle();
611 let stored = match store.get(&key).await {
612 Ok(Some(bytes)) => match EntryEnvelope::decode(&bytes) {
613 Some(entry) if entry.epoch == epoch => Some(entry),
614 Some(_) => {
615 // Dead generation (config change or purge raced the
616 // lookup): drop it so it costs no one else a read.
617 let _ = {
618 let s = self.store_handle();
619 s.delete(&key).await
620 };
621 None
622 }
623 None => {
624 // Undecodable envelope (backend corruption or a
625 // foreign writer): drop, degrade to miss.
626 let _ = {
627 let s = self.store_handle();
628 s.delete(&key).await
629 };
630 None
631 }
632 },
633 Ok(None) => None,
634 Err(_) => None, // store failure degrades to a miss, by contract
635 };
636
637 // DP-04: HEAD GET-fallback (RFC 9111 section 4.1 allows serving a
638 // cached GET representation to a HEAD request — same headers, no
639 // body). Only a FRESH GET entry is reused this way: a stale one
640 // falls through to the normal miss path so the HEAD response is
641 // fetched and stored under its own (HEAD) key rather than
642 // overwriting the GET entry with an empty body.
643 if is_head && stored.is_none() {
644 let get_key = derive_key(
645 &route.name,
646 epoch,
647 identity,
648 &Method::GET,
649 path,
650 query,
651 &vary_values,
652 );
653 if let Ok(Some(bytes)) = {
654 let s = self.store_handle();
655 s.get(&get_key).await
656 } {
657 if let Some(entry) = EntryEnvelope::decode(&bytes) {
658 if entry.epoch == epoch {
659 let age_ms = now_ms().saturating_sub(entry.stored_at_ms);
660 let ttl_ms = effective_freshness_ttl_ms(&entry, policy);
661 if age_ms < ttl_ms {
662 if let Some(resp) = serve_head_from_entry(policy, &entry, age_ms, "hit")
663 {
664 obs.record_cache_lookup("hit");
665 return LookupOutcome::Serve(Box::new(resp));
666 }
667 }
668 }
669 }
670 }
671 }
672
673 let mut injected_inm = false;
674 if let Some(entry) = &stored {
675 let age_ms = now_ms().saturating_sub(entry.stored_at_ms);
676 let ttl_ms = effective_freshness_ttl_ms(entry, policy);
677 let fresh = age_ms < ttl_ms;
678 let stale_ok = policy.stale_while_revalidate.as_millis() as u64 > 0
679 && age_ms < ttl_ms + policy.stale_while_revalidate.as_millis() as u64;
680 if fresh {
681 let resp = if is_head {
682 serve_head_from_entry(policy, entry, age_ms, "hit")
683 } else {
684 serve_from_entry(
685 policy,
686 entry,
687 age_ms,
688 req_headers.get(&IF_NONE_MATCH),
689 "hit",
690 )
691 };
692 if let Some(resp) = resp {
693 obs.record_cache_lookup("hit");
694 return LookupOutcome::Serve(Box::new(resp));
695 }
696 // An entry that cannot be rebuilt (a header that no
697 // longer parses) is a miss, not a failure: drop it.
698 let _ = {
699 let s = self.store_handle();
700 s.delete(&key).await
701 };
702 } else if stale_ok && !is_head {
703 if let Some(resp) = serve_from_entry(policy, entry, age_ms, None, "stale") {
704 obs.record_cache_lookup("stale");
705 // Serve stale NOW; refresh in the background (one
706 // revalidation per key — the in-flight guard).
707 self.spawn_revalidate(
708 Arc::clone(dp),
709 &route.name,
710 &key,
711 epoch,
712 policy,
713 path,
714 query,
715 identity,
716 &vary_values,
717 peer,
718 entry,
719 );
720 return LookupOutcome::Serve(Box::new(resp));
721 }
722 let _ = {
723 let s = self.store_handle();
724 s.delete(&key).await
725 };
726 } else {
727 // Expired past the stale window: fall through as a
728 // miss, but keep the entry — its validator makes the
729 // forwarded fetch a conditional revalidation the store
730 // stage can resolve with a 304, and (DP-04) it backs
731 // stale-if-error serving when the upstream errors.
732 // Only when WE inject it (the client sent none of its
733 // own, and the validator is a header-safe value — an
734 // uninjectable one simply never marks the flag, so the
735 // client can never be answered a 304 the gateway itself
736 // caused).
737 injected_inm = !req_headers.contains_key(&IF_NONE_MATCH)
738 && entry
739 .header("etag")
740 .and_then(|b| std::str::from_utf8(b).ok())
741 .is_some_and(|e| HeaderValue::from_str(e).is_ok());
742 }
743 }
744
745 // Miss: fall through to the proxy action. The caller injects
746 // `If-None-Match` from the stored entry when `injected_inm` is
747 // set (see `MissFlow::stored_etag`).
748 obs.record_cache_lookup("miss");
749 LookupOutcome::Miss(Box::new(MissFlow {
750 key,
751 route_name: route.name.clone(),
752 epoch,
753 policy: Arc::clone(policy),
754 path: path.to_string(),
755 query: query.map(str::to_string),
756 peer,
757 identity: identity.cloned(),
758 vary_values,
759 stored,
760 injected_inm,
761 is_head,
762 }))
763 }
764
765 /// Spawn the background revalidation of a stale-served entry
766 /// (stale-while-revalidate, DW-037). One per key at a time, at
767 /// most [`MAX_INFLIGHT_REVALIDATIONS`] distinct keys — beyond the
768 /// bound the entry stays stale until a request past the window
769 /// revalidates synchronously (bounding the refresh burst after a
770 /// mass expiry is the point of the cap).
771 #[allow(clippy::too_many_arguments)] // the per-request explicit-inputs rule (see proxy_request)
772 fn spawn_revalidate(
773 self: &Arc<Self>,
774 dp: Arc<DataPlane>,
775 route_name: &str,
776 key: &str,
777 epoch: u64,
778 policy: &Arc<CompiledRouteCache>,
779 path: &str,
780 query: Option<&str>,
781 identity: Option<&Identity>,
782 vary_values: &[(String, String)],
783 peer: IpAddr,
784 stored: &EntryEnvelope,
785 ) {
786 {
787 let mut inflight = self.inflight.lock();
788 if inflight.len() >= MAX_INFLIGHT_REVALIDATIONS || !inflight.insert(key.to_string()) {
789 return;
790 }
791 }
792 let flow = MissFlow {
793 key: key.to_string(),
794 route_name: route_name.to_string(),
795 epoch,
796 policy: Arc::clone(policy),
797 path: path.to_string(),
798 query: query.map(str::to_string),
799 peer,
800 identity: identity.cloned(),
801 vary_values: vary_values.to_vec(),
802 stored: Some(stored.clone()),
803 // The synthetic refresh always carries the stored validator
804 // (there is no client to honor conditionals for).
805 injected_inm: true,
806 // Revalidation preserves the original method (a HEAD entry
807 // revalidates as HEAD; a GET as GET).
808 is_head: false,
809 };
810 let inflight = Arc::clone(&self.inflight);
811 let key = key.to_string();
812 let cache = Arc::clone(self);
813 tokio::spawn(async move {
814 let _guard = InflightGuard {
815 set: inflight,
816 key: key.clone(),
817 };
818 cache.run_revalidation(dp, flow).await;
819 });
820 }
821
822 /// The background refresh: a minimal synthetic GET (vary-relevant
823 /// headers + the stored validator) through the full proxy path,
824 /// then the SAME masking/transform/store stages a foreground miss
825 /// runs. Deliberately NOT rate-limited (the request that triggered
826 /// it already paid) and always bounded by the in-flight guard.
827 /// Body-bearing, upgrade, and credentialed original requests are
828 /// never cacheable, so the shapes that could not be reconstructed
829 /// are exactly the shapes that never reach this path.
830 async fn run_revalidation(self: Arc<Self>, dp: Arc<DataPlane>, flow: MissFlow) {
831 let gen = dp.current();
832 let gateway = gen.snapshot.gateway();
833 let Some((idx, _params)) = gen.snapshot.route_table().find_full(&flow.path) else {
834 return;
835 };
836 let Some(route) = gateway.routes.get(idx) else {
837 return;
838 };
839 // Config moved under us (route redefined or purged): the epoch
840 // check in the store stage would drop the write anyway; skip
841 // the upstream call too. Guarded against the OWNING route's
842 // epoch (review fix) — the path may now resolve to a different
843 // route entirely, and that route's epoch says nothing about
844 // this entry's validity.
845 if self.epoch(&flow.route_name) != flow.epoch || route.cache.is_none() {
846 return;
847 }
848 let etag = flow
849 .stored
850 .as_ref()
851 .and_then(|e| e.header("etag"))
852 .map(|b| String::from_utf8_lossy(b).to_string());
853 let mut builder = Request::builder().method(Method::GET).uri(&flow.path);
854 if let Some(q) = &flow.query {
855 builder = builder.uri(format!("{}?{}", flow.path, q));
856 }
857 for (name, value) in &flow.vary_values {
858 builder = builder.header(name.as_str(), value.as_str());
859 }
860 if let Some(etag) = etag {
861 builder = builder.header(&IF_NONE_MATCH, etag);
862 }
863 let req = match builder.body(http_body_util::Empty::<Bytes>::new()) {
864 Ok(r) => r,
865 Err(_) => return,
866 };
867 let rid = format!("cache-revalidate-{:016x}", now_ms());
868 let mut rec = crate::observability::AccessRecord::new(
869 rid.clone(),
870 "GET".to_string(),
871 flow.path.clone(),
872 "cache".to_string(),
873 );
874 let mut no_permit: Option<OwnedSemaphorePermit> = None;
875 // PERF: resolve the service once here; `proxy_request` takes the
876 // pre-resolved reference (the request path must not re-scan).
877 let service = gateway.services.iter().find(|s| s.name == route.service);
878 let mut resp = super::proxy::proxy_request(
879 &gen,
880 flow.peer,
881 req,
882 route,
883 idx,
884 service,
885 &_params,
886 &mut no_permit,
887 flow.identity.as_ref(),
888 &rid,
889 &mut rec,
890 &dp.observability_arc(),
891 None,
892 dp.oauth2_token_cache(),
893 &[],
894 )
895 .await;
896 // The foreground store stage expects post-masking/
897 // post-transform bytes; apply the same stages here. The plugin
898 // response phases (DW-157) run in the SAME positions the
899 // foreground runs them (headers before masking, body between
900 // masking and the transforms) so the stored bytes are
901 // uniformly post-plugin regardless of which path refreshed the
902 // entry. A plugin failure fails closed: the 500 replaces the
903 // upstream response and flows to the store stage, which vetoes
904 // a non-storable outcome (the stale entry stands until the next
905 // refresh).
906 let mut plugin_failed = false;
907 let mut plugins = match dp.build_request_plugins(route, &rid) {
908 Ok(plugins) => plugins,
909 Err(short) => {
910 rec.plugin_short_circuit = true;
911 resp = *short;
912 plugin_failed = true;
913 None
914 }
915 };
916 if let Some(p) = plugins.as_mut() {
917 let status = resp.status();
918 if let Err(short) = p
919 .response_headers_phase(
920 status,
921 resp.headers_mut(),
922 dp.observability(),
923 &rid,
924 &route.name,
925 )
926 .await
927 {
928 rec.plugin_short_circuit = true;
929 resp = *short;
930 plugin_failed = true;
931 }
932 }
933 if !plugin_failed {
934 if let Some(masking) = gen.snapshot.route_table().masking(idx) {
935 resp = transforms::mask_response_body(
936 resp,
937 masking,
938 flow.identity
939 .as_ref()
940 .map(|i| i.groups.as_slice())
941 .unwrap_or(&[]),
942 &route.name,
943 flow.identity.as_ref().map(|i| i.consumer_name.as_str()),
944 &rid,
945 )
946 .await;
947 }
948 if plugins.as_ref().is_some_and(|p| p.needs_response_body()) {
949 let p = plugins.as_mut().expect("plugin chain checked above");
950 match p
951 .response_body_phase(
952 resp,
953 super::plugin_dispatch::plugin_body_cap(route),
954 dp.observability(),
955 &rid,
956 &route.name,
957 )
958 .await
959 {
960 Ok(passed) => resp = passed,
961 Err(short) => {
962 rec.plugin_short_circuit = true;
963 resp = *short;
964 }
965 }
966 }
967 if let Some(compiled) = gen.snapshot.route_table().response_body_ops(idx) {
968 resp = transforms::transform_response_body(resp, compiled, &rid).await;
969 }
970 if let Some(ops) = route
971 .transforms
972 .as_ref()
973 .and_then(|t| t.response.as_ref())
974 .and_then(|r| r.headers.as_ref())
975 {
976 transforms::apply_header_ops(resp.headers_mut(), ops);
977 }
978 }
979 // The response is discarded: only the store (and its metrics)
980 // matter. X-Cache stamping on it is harmless and consistent.
981 let _ = self
982 .store_stage(
983 CacheFlow::Miss(Box::new(flow)),
984 resp,
985 &rid,
986 dp.observability(),
987 )
988 .await;
989 }
990
991 /// The store half of the cache (DW-037), running after masking and
992 /// the DW-028 transforms and before compression. Stamps the
993 /// `x-cache` outcome on the response in every arm. See the module
994 /// docs for the response-side storable rules.
995 pub async fn store_stage(
996 &self,
997 flow: CacheFlow,
998 mut resp: Response<ProxyBody>,
999 rid: &str,
1000 obs: &Observability,
1001 ) -> Response<ProxyBody> {
1002 match flow {
1003 CacheFlow::Bypass => {
1004 stamp(&mut resp, "bypass");
1005 resp
1006 }
1007 CacheFlow::Miss(flow) => self.finish_miss(*flow, resp, rid, obs).await,
1008 }
1009 }
1010
1011 /// Resolve a cache miss against the fetched response: the 304
1012 /// revalidation arms first (they consume the stored entry), then
1013 /// the storable-rule vetoes and the size-capped store.
1014 async fn finish_miss(
1015 &self,
1016 flow: MissFlow,
1017 mut resp: Response<ProxyBody>,
1018 rid: &str,
1019 obs: &Observability,
1020 ) -> Response<ProxyBody> {
1021 // 304 revalidation arms (an upstream answering a conditional
1022 // with Not Modified): the stored body is still current.
1023 if resp.status() == StatusCode::NOT_MODIFIED {
1024 if let Some(entry) = flow.stored.as_ref() {
1025 let upstream_etag = resp
1026 .headers()
1027 .get(&ETAG)
1028 .and_then(|v| v.to_str().ok())
1029 .map(str::to_string);
1030 let stored_etag = entry
1031 .header("etag")
1032 .map(|b| String::from_utf8_lossy(b).to_string());
1033 if validators_match(upstream_etag.as_deref(), stored_etag.as_deref()) {
1034 // Refresh (same bytes, new clock) under the epoch
1035 // we fetched with; a purge/config change since then
1036 // silently drops the write.
1037 let refreshed = EntryEnvelope {
1038 stored_at_ms: now_ms(),
1039 ..entry.clone()
1040 };
1041 if self.epoch(&flow.route_name) == flow.epoch {
1042 let ttl = flow.policy.ttl + flow.policy.stale_while_revalidate;
1043 let store = self.store_handle();
1044 let outcome = match store
1045 .set_with_ttl(flow.key.clone(), refreshed.encode(), ttl)
1046 .await
1047 {
1048 Ok(()) => "stored",
1049 Err(_) => "error",
1050 };
1051 obs.record_cache_store(outcome);
1052 }
1053 obs.record_cache_revalidated();
1054 if flow.injected_inm {
1055 // WE made the request conditional; the client
1056 // sent no validator and MUST NOT receive a bare
1057 // 304 (RFC 9111 section 4.3.4) — serve the
1058 // stored representation as 200.
1059 return match response_from_entry(&refreshed, &flow.policy) {
1060 Some(mut out) => {
1061 stamp_age(&mut out, 0);
1062 stamp(&mut out, "revalidated");
1063 out
1064 }
1065 None => resp,
1066 };
1067 }
1068 // The CLIENT made it conditional: the 304 is its
1069 // answer; the cache refreshed underneath.
1070 stamp(&mut resp, "revalidated");
1071 return resp;
1072 }
1073 // Validator drift (the 304 names a different ETag than
1074 // the stored entry): the stored representation is no
1075 // longer current — drop it and pass the 304 through.
1076 let _ = {
1077 let s = self.store_handle();
1078 s.delete(&flow.key).await
1079 };
1080 }
1081 stamp(&mut resp, "miss");
1082 return resp;
1083 }
1084
1085 // DP-04 stale-if-error (RFC 5861 section 4): an upstream 5xx —
1086 // which includes the 502 the gateway synthesizes on connection
1087 // failure — is served from a stale entry when one is on hand
1088 // and within the entry's `stale-if-error` window. The window is
1089 // per-entry (the upstream advertised it via Cache-Control at
1090 // store time); an entry without one (0) does not serve stale on
1091 // error, and `must-revalidate` zeroes the window at store time
1092 // (the origin forbade serving stale without revalidation). The
1093 // error response itself is NEVER stored (it would poison the
1094 // cache); only a fresh 200 stores below.
1095 if resp.status().is_server_error() {
1096 if let Some(entry) = flow.stored.as_ref() {
1097 let age_ms = now_ms().saturating_sub(entry.stored_at_ms);
1098 let ttl_ms = effective_freshness_ttl_ms(entry, &flow.policy);
1099 let sie_ms = entry.stale_if_error_ms;
1100 if sie_ms > 0 && age_ms < ttl_ms + sie_ms {
1101 if let Some(mut stale) = response_from_entry(entry, &flow.policy) {
1102 stamp_age(&mut stale, age_ms);
1103 stamp(&mut stale, "stale");
1104 obs.record_cache_lookup("stale");
1105 tracing::info!(
1106 code = "cache_stale_if_error",
1107 route = %flow.route_name,
1108 status = %resp.status().as_u16(),
1109 "served stale entry on upstream error (stale-if-error)"
1110 );
1111 return stale;
1112 }
1113 }
1114 }
1115 stamp(&mut resp, "miss");
1116 return resp;
1117 }
1118
1119 // Storable rules: exactly 200, no vetoed header, within the cap.
1120 if resp.status() != StatusCode::OK {
1121 stamp(&mut resp, "miss");
1122 return resp;
1123 }
1124 if let Some(_reason) = store_veto(resp.headers(), &flow.policy) {
1125 obs.record_cache_store("vetoed");
1126 stamp(&mut resp, "miss");
1127 return resp;
1128 }
1129 let cap = flow.policy.max_body_bytes;
1130 if resp.body().size_hint().exact().is_some_and(|d| d > cap) {
1131 obs.record_cache_store("over_cap");
1132 stamp(&mut resp, "miss");
1133 return resp;
1134 }
1135
1136 // Size-capped buffering — the ONLY buffering this feature ever
1137 // does, and only on this opted-in path. Over-cap bodies stream
1138 // on exactly as if no cache existed (prefix + remainder).
1139 let (mut parts, body) = resp.into_parts();
1140 match collect_capped(body, cap).await {
1141 Ok(bytes) => {
1142 // DP-04: honor the upstream Cache-Control freshness
1143 // lifetime (s-maxage over max-age, RFC 7234 section
1144 // 5.2.2.9) and the stale-if-error window (RFC 5861
1145 // section 4). 0 means "use the configured policy" (the
1146 // v1 behavior). must-revalidate zeroes stale-if-error
1147 // (the origin forbade serving stale without
1148 // revalidation). Cache-Tags (comma-separated) feed the
1149 // purge-by-tag index.
1150 let cc = CacheControl::parse(&parts.headers);
1151 let freshness_ttl_ms = cc.effective_max_age().map(|s| s * 1000).unwrap_or(0);
1152 let stale_if_error_ms = if cc.must_revalidate {
1153 0
1154 } else {
1155 cc.stale_if_error.map(|s| s * 1000).unwrap_or(0)
1156 };
1157 let tags = parse_cache_tags(&parts.headers);
1158 let entry = EntryEnvelope {
1159 epoch: flow.epoch,
1160 stored_at_ms: now_ms(),
1161 status: parts.status.as_u16(),
1162 freshness_ttl_ms,
1163 stale_if_error_ms,
1164 headers: sanitize_headers(&parts.headers, flow.is_head),
1165 body: bytes.to_vec(),
1166 };
1167 if self.epoch(&flow.route_name) == flow.epoch {
1168 // The backend TTL hint is the full usable lifetime
1169 // (freshness + SWR + stale-if-error) so the backend
1170 // reclaims memory only after the entry is truly
1171 // unusable; the envelope's read-side expiry is the
1172 // source of truth either way.
1173 let eff_ttl_ms = if freshness_ttl_ms > 0 {
1174 freshness_ttl_ms
1175 } else {
1176 flow.policy.ttl.as_millis() as u64
1177 };
1178 let ttl = Duration::from_millis(
1179 eff_ttl_ms
1180 + flow.policy.stale_while_revalidate.as_millis() as u64
1181 + stale_if_error_ms,
1182 );
1183 let store = self.store_handle();
1184 let outcome = match store
1185 .set_with_ttl(flow.key.clone(), entry.encode(), ttl)
1186 .await
1187 {
1188 Ok(()) => "stored",
1189 Err(_) => "error",
1190 };
1191 obs.record_cache_store(outcome);
1192 if outcome == "stored" {
1193 let url = match &flow.query {
1194 Some(q) => format!("{}?{}", flow.path, q),
1195 None => flow.path.clone(),
1196 };
1197 // Note: there is a small best-effort window between
1198 // store.set_with_ttl and record_indexes where a
1199 // concurrent purge_by_tag/purge_by_url could miss
1200 // this just-stored entry (it is in the store but
1201 // not yet in the index). The entry will be caught
1202 // by the next epoch-based route purge or will
1203 // expire via the backend TTL. This is accepted as
1204 // a best-effort trade-off: inserting into the
1205 // index before the store would risk stale indexes
1206 // on store failure, which is worse.
1207 self.record_indexes(&flow.key, &tags, &url);
1208 }
1209 }
1210 if let Ok(v) = HeaderValue::from_str(&bytes.len().to_string()) {
1211 parts.headers.insert(hyper::header::CONTENT_LENGTH, v);
1212 }
1213 // DP-04: strip internal cache directives from the live
1214 // response before it reaches the client. The gateway is a
1215 // shared cache; the upstream's Cache-Control/Expires
1216 // describe the ORIGIN's freshness policy (not the
1217 // gateway's), and Cache-Tags is an operator-controlled
1218 // purge axis that must never leak to end users. These
1219 // are already stripped from the stored entry by
1220 // sanitize_headers; the live response must match.
1221 parts.headers.remove("cache-tags");
1222 parts.headers.remove(hyper::header::CACHE_CONTROL);
1223 parts.headers.remove(hyper::header::EXPIRES);
1224 let mut out = Response::from_parts(parts, ProxyBody::Full(Full::new(bytes)));
1225 stamp(&mut out, "miss");
1226 out
1227 }
1228 Err(CollectError::OverCap { prefix, rest }) => {
1229 obs.record_cache_store("over_cap");
1230 let mut out = Response::from_parts(
1231 parts,
1232 ProxyBody::Passthrough(PassthroughBody { prefix, rest }),
1233 );
1234 stamp(&mut out, "miss");
1235 out
1236 }
1237 Err(CollectError::Stream(err)) => {
1238 // Mid-body upstream death while buffering: headers have
1239 // not reached the client, so answer a clean 502 (the
1240 // DW-028 buffering precedent) instead of a torn stream.
1241 tracing::warn!(
1242 code = "cache_store_failed",
1243 request_id = %rid,
1244 error = %err,
1245 "upstream response failed while buffering for the cache"
1246 );
1247 let mut out = Response::builder()
1248 .status(StatusCode::BAD_GATEWAY)
1249 .header(hyper::header::CONTENT_TYPE, "application/json")
1250 .body(ProxyBody::Full(Full::new(
1251 crate::observability::envelope_body(
1252 "cache_store_failed",
1253 "upstream response failed",
1254 rid,
1255 ),
1256 )))
1257 .expect("static 502 response is valid");
1258 stamp(&mut out, "miss");
1259 out
1260 }
1261 }
1262 }
1263
1264 /// Request coalescing (DW-038): resolve ONE cache miss into the
1265 /// leader, a served follower, or an independent fetch. The
1266 /// coalescing key is the cache key itself (route epoch, consumer,
1267 /// path, query, vary — see [`derive_key`]), so a follower can only
1268 /// be handed an outcome computed for an identical request shape of
1269 /// its own consumer and generation. The caller runs its fetch on
1270 /// `Lead`/`Solo` and drops the guard when its store stage is done.
1271 ///
1272 /// Follower resolution, in the pinned order: (1) wait, bounded by
1273 /// the route's `coalescing.wait_ms`; (2) epoch check FIRST — a
1274 /// mid-flight purge/config change detours to an independent fetch
1275 /// even if the leader stored something; (3) store re-read — a live
1276 /// entry replays exactly like a hit; (4) otherwise an independent
1277 /// fetch (leader finished unstored, or the wait expired). Every
1278 /// fallback is the caller's NORMAL miss path: failures are never
1279 /// inherited, and each fetching follower runs the route's full
1280 /// retry policy.
1281 pub async fn attach(&self, flow: &MissFlow, obs: &Observability) -> CoalesceOutcome {
1282 let Some(wait) = flow.policy.coalesce_wait else {
1283 return CoalesceOutcome::Solo;
1284 };
1285 // Decide under the lock: follower (a leader slot exists),
1286 // leader (room in the map), or neither (saturated — fail open,
1287 // uncounted: this request is neither leader nor follower).
1288 enum Park {
1289 Lead(CoalesceLead),
1290 Follow(Arc<CoalesceSlot>),
1291 Solo,
1292 }
1293 let park = {
1294 let mut map = self.coalescing.lock();
1295 if let Some(slot) = map.get(&flow.key) {
1296 Park::Follow(Arc::clone(slot))
1297 } else if map.len() >= MAX_COALESCING_KEYS {
1298 Park::Solo
1299 } else {
1300 let slot = Arc::new(CoalesceSlot::default());
1301 map.insert(flow.key.clone(), Arc::clone(&slot));
1302 Park::Lead(CoalesceLead {
1303 map: Arc::clone(&self.coalescing),
1304 key: flow.key.clone(),
1305 slot,
1306 })
1307 }
1308 };
1309 match park {
1310 Park::Lead(lead) => {
1311 obs.record_coalescing_leader();
1312 CoalesceOutcome::Lead(lead)
1313 }
1314 Park::Solo => CoalesceOutcome::Solo,
1315 Park::Follow(slot) => {
1316 // Subscribe to the slot's watch: a leader that already
1317 // published is visible through the CURRENT value (watch
1318 // receivers start at the sender's version), so there is
1319 // no register-before-notify race to close.
1320 let mut done = slot.done.subscribe();
1321 obs.coalescing_waiter(true);
1322 let woke = tokio::time::timeout(wait, async {
1323 if !*done.borrow() {
1324 // Err = the sender dropped without publishing
1325 // (leader died mid-unpublish): treat exactly
1326 // like a publication — the store re-read below
1327 // decides what, if anything, is shareable.
1328 let _ = done.changed().await;
1329 }
1330 })
1331 .await
1332 .is_ok();
1333 obs.coalescing_waiter(false);
1334 // (1) Epoch first, pinned: a dead generation's answer
1335 // is never served to a stranded follower.
1336 if self.epoch(&flow.route_name) != flow.epoch {
1337 obs.record_coalescing_follower("fell_back_epoch");
1338 return CoalesceOutcome::Solo;
1339 }
1340 // (2) The store re-read: the leader's store stage
1341 // completed before it published (guard Drop order), so
1342 // a present entry is the leader's outcome — replay it
1343 // exactly like a hit.
1344 let store = self.store_handle();
1345 let stored = match store.get(&flow.key).await {
1346 Ok(Some(bytes)) => match EntryEnvelope::decode(&bytes) {
1347 Some(entry) if entry.epoch == flow.epoch => Some(entry),
1348 _ => None,
1349 },
1350 Ok(None) | Err(_) => None,
1351 };
1352 if let Some(entry) = stored {
1353 let age_ms = now_ms().saturating_sub(entry.stored_at_ms);
1354 if let Some(resp) = serve_from_entry(&flow.policy, &entry, age_ms, None, "hit")
1355 {
1356 obs.record_coalescing_follower("served");
1357 obs.record_coalescing_saved();
1358 return CoalesceOutcome::Served(Box::new(resp));
1359 }
1360 }
1361 // (3) Nothing shareable: independent fetch. The label
1362 // distinguishes "waited out the bound" from "the leader
1363 // finished, just not with something storable".
1364 obs.record_coalescing_follower(if woke {
1365 "fell_back_unshared"
1366 } else {
1367 "fell_back_timeout"
1368 });
1369 CoalesceOutcome::Solo
1370 }
1371 }
1372 }
1373}
1374
1375// ---------------------------------------------------------------------------
1376// Request coalescing (DW-038): leader map, publication slot, guard
1377// ---------------------------------------------------------------------------
1378
1379/// One coalescing leader's publication point: followers subscribe to
1380/// the watch; the leader's [`CoalesceLead`] Drop sends `true` (or the
1381/// sender's own drop wakes them with an error, handled identically by
1382/// the follower's store re-read).
1383struct CoalesceSlot {
1384 done: tokio::sync::watch::Sender<bool>,
1385}
1386
1387impl Default for CoalesceSlot {
1388 fn default() -> Self {
1389 CoalesceSlot {
1390 done: tokio::sync::watch::channel(false).0,
1391 }
1392 }
1393}
1394
1395/// What [`ResponseCache::attach`] decided for one cache miss (DW-038).
1396pub enum CoalesceOutcome {
1397 /// This request is the leader: run the fetch, then drop the guard
1398 /// (explicitly after the store stage, or implicitly on
1399 /// panic/cancel) to publish to followers.
1400 Lead(CoalesceLead),
1401 /// A leader's stored outcome replayed for this follower: serve it
1402 /// exactly like a lookup `Serve` (the decoration tail still runs;
1403 /// `x-cache: hit`, `Age` stamped).
1404 Served(Box<Response<ProxyBody>>),
1405 /// Coalescing made no claim (disabled, map saturated, or the
1406 /// follower fell back): run an independent fetch — the caller's
1407 /// normal miss path, retries and all.
1408 Solo,
1409}
1410
1411/// A leader's hold on one coalescing key (DW-038). Dropping IS the
1412/// publication: the slot leaves the map, then every parked follower
1413/// wakes and re-reads the store. The guard pattern (not manual
1414/// cleanup) so a leader that panics or is cancelled mid-fetch still
1415/// publishes — its followers wake, find nothing shareable, and fetch
1416/// on their own.
1417pub struct CoalesceLead {
1418 map: Arc<Mutex<HashMap<String, Arc<CoalesceSlot>>>>,
1419 key: String,
1420 slot: Arc<CoalesceSlot>,
1421}
1422
1423impl Drop for CoalesceLead {
1424 fn drop(&mut self) {
1425 self.map.lock().remove(&self.key);
1426 // Publish after the unlock: the woken follower's first acts
1427 // (store re-read, epoch check) take no coalescing lock, and
1428 // the map lock is never held across an await anywhere.
1429 let _ = self.slot.done.send(true);
1430 }
1431}
1432
1433/// What a lookup decided (DW-037): replay now, carry a miss to the
1434/// store stage, or stamp a bypass.
1435pub enum LookupOutcome {
1436 /// Fresh (or stale-within-window) entry: the replayed response,
1437 /// X-Cache/Age/Vary already stamped — the caller runs the
1438 /// decoration tail on it like any action response. Boxed: the
1439 /// other arms are pointer-sized and this variant carries a whole
1440 /// response.
1441 Serve(Box<Response<ProxyBody>>),
1442 /// No usable entry: carry to the store stage. The caller may need
1443 /// to inject the stored validator into the forwarded request (see
1444 /// `MissFlow::injected_inm`).
1445 Miss(Box<MissFlow>),
1446 /// Request shape not cacheable: stamp and count only.
1447 Bypass,
1448}
1449
1450/// The per-request cache state carried from lookup to the store stage.
1451pub enum CacheFlow {
1452 /// Route caches, this request does not.
1453 Bypass,
1454 /// Cacheable request that fetched from upstream.
1455 Miss(Box<MissFlow>),
1456}
1457
1458/// Everything the store stage and the background revalidation need
1459/// about one cacheable request (DW-037).
1460pub struct MissFlow {
1461 /// Store key (never logged — it hashes the path and query).
1462 key: String,
1463 /// The name of the route that owns this entry — the epoch guard
1464 /// compares against THIS route (review fix): a reload can shift
1465 /// same-path precedence to a different route mid-revalidation, and
1466 /// guarding on whatever the path now resolves to would let bytes
1467 /// shaped by route B land under route A's key.
1468 route_name: String,
1469 /// Route epoch at lookup (writes under a different epoch are
1470 /// dropped by the store stage's re-check).
1471 epoch: u64,
1472 /// The compiled route policy the lookup ran under.
1473 policy: Arc<CompiledRouteCache>,
1474 /// Inbound path (cache-keyed; the revalidation re-runs the path
1475 /// rewrite against the current snapshot).
1476 path: String,
1477 /// Inbound query, verbatim (part of the key).
1478 query: Option<String>,
1479 /// Direct peer (the revalidation's forwarded-header identity).
1480 peer: IpAddr,
1481 /// The authenticated identity (None = anonymous): consumer and
1482 /// groups key the entry and shape the revalidation's masking.
1483 identity: Option<Identity>,
1484 /// Captured vary-header values of THIS request (the revalidation
1485 /// reproduces them so the upstream sees the same variance).
1486 vary_values: Vec<(String, String)>,
1487 /// The entry found at lookup past its stale window (None on a cold
1488 /// miss): backs conditional revalidation and the 304-reuse arm.
1489 stored: Option<EntryEnvelope>,
1490 /// Whether the CALLER injected the stored validator into the
1491 /// forwarded request (a 304 answer must then become a 200 for the
1492 /// client — RFC 9111 section 4.3.4).
1493 pub injected_inm: bool,
1494 /// Whether this is a HEAD request (DP-04): HEAD entries preserve the
1495 /// upstream Content-Length in the stored headers (the body is empty
1496 /// but the entity length must be replayed correctly per RFC 9110
1497 /// section 9.3.2).
1498 pub is_head: bool,
1499}
1500
1501impl MissFlow {
1502 /// The stored entry's ETag, decoded (the caller injects it as
1503 /// `If-None-Match` when the client sent none).
1504 pub fn stored_etag(&self) -> Option<String> {
1505 self.stored
1506 .as_ref()
1507 .and_then(|e| e.header("etag"))
1508 .map(|b| String::from_utf8_lossy(b).to_string())
1509 }
1510
1511 /// The route's coalescing follower wait bound, if the route's
1512 /// cache block enables request coalescing (DW-038). The proxy path
1513 /// gates on this before consulting the coalescing map.
1514 pub fn coalesce_wait(&self) -> Option<std::time::Duration> {
1515 self.policy.coalesce_wait
1516 }
1517}
1518
1519/// Removes the key from the in-flight set when the revalidation task
1520/// ends (any exit path — the guard pattern, not manual cleanup).
1521struct InflightGuard {
1522 set: Arc<Mutex<HashSet<String>>>,
1523 key: String,
1524}
1525
1526impl Drop for InflightGuard {
1527 fn drop(&mut self) {
1528 self.set.lock().remove(&self.key);
1529 }
1530}
1531
1532// ---------------------------------------------------------------------------
1533// Key derivation and vary capture
1534// ---------------------------------------------------------------------------
1535
1536/// Capture the vary-set header values of one request: each configured
1537/// vary name's values (all lines, wire order) joined with ", " — the
1538/// exact bytes the key folds.
1539fn capture_vary_values(vary: &[String], headers: &HeaderMap) -> Vec<(String, String)> {
1540 vary.iter()
1541 .map(|name| {
1542 let joined = headers
1543 .get_all(name.as_str())
1544 .iter()
1545 .filter_map(|v| v.to_str().ok())
1546 .collect::<Vec<_>>()
1547 .join(", ");
1548 (name.clone(), joined)
1549 })
1550 .collect()
1551}
1552
1553/// Derive the store key: a SHA-256 over the domain-tagged components
1554/// (route, epoch, consumer, method, path, query, vary values). Hashing
1555/// keeps the key length bounded and keeps raw paths/queries out of the
1556/// store's memory-visible key space (they are never logged either way).
1557/// The method component (DP-04) separates a HEAD representation (no
1558/// body) from a GET representation (full body) of the same resource —
1559/// they are distinct cache entries per RFC 9111 section 4.1.
1560pub fn derive_key(
1561 route: &str,
1562 epoch: u64,
1563 identity: Option<&Identity>,
1564 method: &Method,
1565 path: &str,
1566 query: Option<&str>,
1567 vary_values: &[(String, String)],
1568) -> String {
1569 let mut hasher = Sha256::new();
1570 hasher.update(b"dwara-rc-v1\x00");
1571 hasher.update(route.as_bytes());
1572 hasher.update([0]);
1573 hasher.update(epoch.to_le_bytes());
1574 hasher.update(method.as_str().as_bytes());
1575 hasher.update([0]);
1576 if let Some(id) = identity {
1577 hasher.update(id.consumer_name.as_bytes());
1578 }
1579 hasher.update([0]);
1580 hasher.update(path.as_bytes());
1581 hasher.update([0]);
1582 hasher.update(query.unwrap_or("").as_bytes());
1583 hasher.update([0]);
1584 for (name, value) in vary_values {
1585 hasher.update(name.as_bytes());
1586 hasher.update([1]);
1587 hasher.update(value.as_bytes());
1588 hasher.update([0]);
1589 }
1590 format!("rc-{:016x}", hasher.finalize())
1591}
1592
1593// ---------------------------------------------------------------------------
1594// Envelope: the stored entry format
1595// ---------------------------------------------------------------------------
1596
1597/// One stored response (the value bytes of the [`CacheStore`]). The
1598/// layout is a tiny length-prefixed binary frame (no new dependency):
1599/// magic, schema version, epoch, stored-at, status, header pairs, body.
1600/// Headers are the DENY-LISTED copy of the response's headers — hop-
1601/// by-hop and framing names are already gone (stripped on the proxy
1602/// path); additionally excluded at store time: `Content-Length`
1603/// (recomputed on replay), `Vary` (re-derived from the live policy),
1604/// `Age` (recomputed), `Cache-Control`/`Expires` (the gateway owns the
1605/// entry's freshness — replaying origin directives would mislead
1606/// clients about a gateway-held entry), `Set-Cookie` (personalized;
1607/// also a storage veto), and the gateway's own stamps (`X-Cache`,
1608/// `X-Request-Id`, `X-RateLimit-*`). Everything else round-trips
1609/// verbatim (ETag, Last-Modified, Date, Content-Type, custom headers).
1610#[derive(Debug, Clone, PartialEq)]
1611pub struct EntryEnvelope {
1612 /// Route cache epoch at store time (the invalidation dimension).
1613 pub epoch: u64,
1614 /// Wall-clock ms at store time (the freshness clock origin).
1615 pub stored_at_ms: u64,
1616 /// Stored status (200 by the storable rules; carried so widening
1617 /// the status set later does not change the envelope).
1618 pub status: u16,
1619 /// Per-entry freshness lifetime in milliseconds (DP-04). 0 means
1620 /// "use the configured `ttl_secs`" (the v1 behavior and the
1621 /// fallback when the upstream sent no `s-maxage`/`max-age`).
1622 /// Non-zero is the upstream `Cache-Control` lifetime the gateway
1623 /// honors as a shared cache (RFC 7234 section 5.2.2).
1624 pub freshness_ttl_ms: u64,
1625 /// Per-entry `stale-if-error` window in milliseconds (DP-04,
1626 /// RFC 5861 section 4). 0 means "no stale-on-error serving beyond
1627 /// the configured stale-while-revalidate window".
1628 pub stale_if_error_ms: u64,
1629 /// (name, value) raw byte pairs, wire order.
1630 pub headers: Vec<(Vec<u8>, Vec<u8>)>,
1631 /// Identity (never compressed) body bytes.
1632 pub body: Vec<u8>,
1633}
1634
1635impl EntryEnvelope {
1636 /// First value of `name` (lowercase), if stored.
1637 pub fn header(&self, name: &str) -> Option<&[u8]> {
1638 self.headers
1639 .iter()
1640 .find(|(n, _)| n.eq_ignore_ascii_case(name.as_bytes()))
1641 .map(|(_, v)| v.as_slice())
1642 }
1643
1644 /// Encode into the store's value bytes.
1645 pub fn encode(&self) -> Vec<u8> {
1646 fn put_u16(out: &mut Vec<u8>, v: u16) {
1647 out.extend_from_slice(&v.to_le_bytes());
1648 }
1649 fn put_u32(out: &mut Vec<u8>, v: u32) {
1650 out.extend_from_slice(&v.to_le_bytes());
1651 }
1652 fn put_u64(out: &mut Vec<u8>, v: u64) {
1653 out.extend_from_slice(&v.to_le_bytes());
1654 }
1655 fn put_bytes(out: &mut Vec<u8>, b: &[u8]) {
1656 put_u32(out, b.len() as u32);
1657 out.extend_from_slice(b);
1658 }
1659 let mut out = Vec::with_capacity(72 + self.body.len());
1660 out.extend_from_slice(&ENVELOPE_MAGIC);
1661 out.push(ENVELOPE_VERSION);
1662 put_u64(&mut out, self.epoch);
1663 put_u64(&mut out, self.stored_at_ms);
1664 put_u16(&mut out, self.status);
1665 put_u64(&mut out, self.freshness_ttl_ms);
1666 put_u64(&mut out, self.stale_if_error_ms);
1667 put_u32(&mut out, self.headers.len() as u32);
1668 for (name, value) in &self.headers {
1669 put_bytes(&mut out, name);
1670 put_bytes(&mut out, value);
1671 }
1672 put_u32(&mut out, self.body.len() as u32);
1673 out.extend_from_slice(&self.body);
1674 out
1675 }
1676
1677 /// Decode store bytes; `None` on any framing/schema mismatch (the
1678 /// caller treats an undecodable entry as a miss and drops it).
1679 pub fn decode(bytes: &[u8]) -> Option<Self> {
1680 let mut cur = bytes;
1681 fn take<'a>(cur: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
1682 if cur.len() < n {
1683 return None;
1684 }
1685 let (head, rest) = cur.split_at(n);
1686 *cur = rest;
1687 Some(head)
1688 }
1689 fn take_u16(cur: &mut &[u8]) -> Option<u16> {
1690 take(cur, 2).map(|b| u16::from_le_bytes(b.try_into().expect("2 bytes")))
1691 }
1692 fn take_u32(cur: &mut &[u8]) -> Option<u32> {
1693 take(cur, 4).map(|b| u32::from_le_bytes(b.try_into().expect("4 bytes")))
1694 }
1695 fn take_u64(cur: &mut &[u8]) -> Option<u64> {
1696 take(cur, 8).map(|b| u64::from_le_bytes(b.try_into().expect("8 bytes")))
1697 }
1698 fn take_bytes<'a>(cur: &mut &'a [u8]) -> Option<&'a [u8]> {
1699 let len = take_u32(cur)? as usize;
1700 take(cur, len)
1701 }
1702 if take(&mut cur, ENVELOPE_MAGIC.len())? != &ENVELOPE_MAGIC[..] {
1703 return None;
1704 }
1705 let version = take(&mut cur, 1)?[0];
1706 let epoch = take_u64(&mut cur)?;
1707 let stored_at_ms = take_u64(&mut cur)?;
1708 let status = take_u16(&mut cur)?;
1709 // DP-04: v2 carries the per-entry freshness TTL and the
1710 // stale-if-error window after the status; v1 omits them and
1711 // they read as 0 (the configured policy applies). A version
1712 // the gateway does not know is a framing mismatch.
1713 let (freshness_ttl_ms, stale_if_error_ms) = match version {
1714 ENVELOPE_VERSION => (take_u64(&mut cur)?, take_u64(&mut cur)?),
1715 ENVELOPE_VERSION_V1 => (0, 0),
1716 _ => return None,
1717 };
1718 let header_count = take_u32(&mut cur)? as usize;
1719 // A corrupt header count must not arm a monstrous allocation.
1720 if header_count.saturating_mul(8) > cur.len() {
1721 return None;
1722 }
1723 let mut headers = Vec::with_capacity(header_count);
1724 for _ in 0..header_count {
1725 let name = take_bytes(&mut cur)?.to_vec();
1726 let value = take_bytes(&mut cur)?.to_vec();
1727 headers.push((name, value));
1728 }
1729 let body = take_bytes(&mut cur)?.to_vec();
1730 if !cur.is_empty() {
1731 return None; // trailing bytes = framing mismatch
1732 }
1733 Some(EntryEnvelope {
1734 epoch,
1735 stored_at_ms,
1736 status,
1737 freshness_ttl_ms,
1738 stale_if_error_ms,
1739 headers,
1740 body,
1741 })
1742 }
1743}
1744
1745/// Header names never stored (see [`EntryEnvelope`]'s docs for the
1746/// why of each group).
1747fn is_denied_storage_header(name: &str) -> bool {
1748 matches!(
1749 name,
1750 "content-length"
1751 | "transfer-encoding"
1752 | "connection"
1753 | "keep-alive"
1754 | "te"
1755 | "trailer"
1756 | "upgrade"
1757 | "proxy-connection"
1758 | "proxy-authenticate"
1759 | "proxy-authorization"
1760 | "age"
1761 | "vary"
1762 | "cache-control"
1763 | "cache-tags"
1764 | "expires"
1765 | "set-cookie"
1766 | "x-cache"
1767 | "x-request-id"
1768 | "x-ratelimit-limit"
1769 | "x-ratelimit-remaining"
1770 | "x-ratelimit-reset"
1771 )
1772}
1773
1774/// The deny-listed copy of a response's headers (store side). When
1775/// `preserve_content_length` is true (HEAD entries, DP-04), the upstream
1776/// `Content-Length` is kept — the HEAD body is empty but the entity
1777/// length must be replayed correctly per RFC 9110 section 9.3.2.
1778fn sanitize_headers(headers: &HeaderMap, preserve_content_length: bool) -> Vec<(Vec<u8>, Vec<u8>)> {
1779 let mut out = Vec::new();
1780 for (name, value) in headers.iter() {
1781 let name_str = name.as_str();
1782 if is_denied_storage_header(name_str) {
1783 // DP-04: HEAD entries keep Content-Length (the entity length
1784 // a GET would return); GET entries re-derive it from the
1785 // body at replay time.
1786 if !(preserve_content_length && name_str.eq_ignore_ascii_case("content-length")) {
1787 continue;
1788 }
1789 }
1790 out.push((name_str.to_string().into_bytes(), value.as_bytes().to_vec()));
1791 }
1792 out
1793}
1794
1795/// Parse the upstream `Cache-Tags` response header (DP-04): a comma-
1796/// separated list of opaque tags the operator can purge by. Whitespace
1797/// around each tag is trimmed and empty tags are dropped. Multiple
1798/// `Cache-Tags` headers are folded. Tags are never validated beyond
1799/// being non-empty trimmed tokens — they are an operator-controlled
1800/// purge axis, not a client-facing value (the header is stripped before
1801/// replay by [`is_denied_storage_header`]).
1802fn parse_cache_tags(headers: &HeaderMap) -> Vec<String> {
1803 let mut tags = Vec::new();
1804 for value in headers.get_all("cache-tags") {
1805 let Ok(s) = value.to_str() else { continue };
1806 for tag in s.split(',') {
1807 let tag = tag.trim();
1808 if !tag.is_empty() {
1809 tags.push(tag.to_string());
1810 }
1811 }
1812 }
1813 tags
1814}
1815
1816/// Rebuild a response from a stored entry. Also re-derives what the
1817/// replay must advertise: `Content-Length` from the body and one
1818/// `Vary` merge per effective-vary token (the decoration tail adds the
1819/// policy-derived folds — Accept/Origin/Accept-Encoding — exactly as
1820/// for a live response; `merge_vary` dedupes).
1821fn response_from_entry(
1822 entry: &EntryEnvelope,
1823 policy: &CompiledRouteCache,
1824) -> Option<Response<ProxyBody>> {
1825 let mut builder = Response::builder().status(StatusCode::from_u16(entry.status).ok()?);
1826 // DP-04: HEAD entries preserve the upstream Content-Length in the
1827 // stored headers (the entity length a GET would return). If present,
1828 // it is already in the header loop below; skip overwriting it with
1829 // body.len() (which is 0 for HEAD entries). GET entries do not store
1830 // Content-Length (sanitize_headers strips it), so body.len() is the
1831 // correct replay value for them.
1832 let has_stored_cl = entry
1833 .headers
1834 .iter()
1835 .any(|(n, _)| n.eq_ignore_ascii_case(b"content-length"));
1836 for (name, value) in &entry.headers {
1837 builder = builder.header(
1838 HeaderName::from_bytes(name).ok()?,
1839 HeaderValue::from_bytes(value).ok()?,
1840 );
1841 }
1842 let body = Bytes::from(entry.body.clone());
1843 if !has_stored_cl {
1844 builder = builder.header(
1845 hyper::header::CONTENT_LENGTH,
1846 HeaderValue::from_str(&body.len().to_string()).ok()?,
1847 );
1848 }
1849 let mut resp = builder.body(ProxyBody::Full(Full::new(body))).ok()?;
1850 for token in &policy.vary {
1851 merge_vary(resp.headers_mut(), token);
1852 }
1853 Some(resp)
1854}
1855
1856/// The effective freshness lifetime for one entry (DP-04): the
1857/// upstream `Cache-Control` lifetime stored on the entry when present
1858/// (s-maxage over max-age, RFC 7234 section 5.2.2.9), else the
1859/// configured `ttl_secs` (the operator-owned default, the v1
1860/// behavior). Returned in milliseconds for direct comparison with
1861/// `age_ms`.
1862fn effective_freshness_ttl_ms(entry: &EntryEnvelope, policy: &CompiledRouteCache) -> u64 {
1863 if entry.freshness_ttl_ms > 0 {
1864 entry.freshness_ttl_ms
1865 } else {
1866 policy.ttl.as_millis() as u64
1867 }
1868}
1869
1870/// Serve a HEAD response from a stored entry (DP-04): the entry's
1871/// headers with the entity's `Content-Length` (the bytes a GET would
1872/// return) but an EMPTY body — the HEAD framing (RFC 9110 section
1873/// 9.3.2). Works for a HEAD entry (empty body, Content-Length 0) and
1874/// for the GET-fallback (a GET entry's headers + Content-Length, no
1875/// body). Carries `Age` and the `x-cache` outcome like a GET replay.
1876fn serve_head_from_entry(
1877 policy: &CompiledRouteCache,
1878 entry: &EntryEnvelope,
1879 age_ms: u64,
1880 outcome: &str,
1881) -> Option<Response<ProxyBody>> {
1882 let mut resp = response_from_entry(entry, policy)?;
1883 // The body is empty; keep the Content-Length response_from_entry
1884 // stamped (it reflects the entity length, not the body bytes — the
1885 // HEAD framing a client uses to learn what a GET would return).
1886 *resp.body_mut() = ProxyBody::Full(Full::new(Bytes::new()));
1887 for token in &policy.vary {
1888 merge_vary(resp.headers_mut(), token);
1889 }
1890 stamp_age(&mut resp, age_ms);
1891 stamp(&mut resp, outcome);
1892 Some(resp)
1893}
1894
1895/// Serve a stored entry: a 304 when the client's `If-None-Match`
1896/// matches the stored validator (fresh entries only — the caller
1897/// passes no client conditional on the stale path), else the full
1898/// stored representation. Both carry `Age` and the `x-cache` outcome.
1899fn serve_from_entry(
1900 policy: &CompiledRouteCache,
1901 entry: &EntryEnvelope,
1902 age_ms: u64,
1903 client_inm: Option<&HeaderValue>,
1904 outcome: &str,
1905) -> Option<Response<ProxyBody>> {
1906 if let Some(inm) = client_inm.and_then(|v| v.to_str().ok()) {
1907 let stored_etag = entry
1908 .header("etag")
1909 .map(|b| String::from_utf8_lossy(b).to_string());
1910 if inm_matches(inm, stored_etag.as_deref()) {
1911 let mut builder = Response::builder().status(StatusCode::NOT_MODIFIED);
1912 if let Some(etag) = entry.header("etag") {
1913 builder = builder.header(&ETAG, String::from_utf8_lossy(etag).to_string());
1914 }
1915 if let Some(date) = entry.header("date") {
1916 if let Ok(v) = HeaderValue::from_bytes(date) {
1917 builder = builder.header(hyper::header::DATE, v);
1918 }
1919 }
1920 let mut resp = builder
1921 .body(ProxyBody::Full(Full::new(Bytes::new())))
1922 .expect("static 304 response is valid");
1923 for token in &policy.vary {
1924 merge_vary(resp.headers_mut(), token);
1925 }
1926 stamp_age(&mut resp, age_ms);
1927 stamp(&mut resp, "hit");
1928 return Some(resp);
1929 }
1930 }
1931 let mut resp = response_from_entry(entry, policy)?;
1932 stamp_age(&mut resp, age_ms);
1933 stamp(&mut resp, outcome);
1934 Some(resp)
1935}
1936
1937/// Whether a client `If-None-Match` value matches a stored validator:
1938/// `*` matches any; otherwise weak comparison (RFC 9110 section 8.8.3
1939/// — the W/ prefix is ignored on both sides) over the comma-separated
1940/// list. A stored entry without a validator never matches.
1941pub fn inm_matches(inm: &str, etag: Option<&str>) -> bool {
1942 let Some(etag) = etag else { return false };
1943 let etag = strip_weak(etag);
1944 inm.split(',').any(|token| {
1945 let token = token.trim();
1946 token == "*" || strip_weak(token) == etag
1947 })
1948}
1949
1950/// Strip the weak-validator prefix (RFC 9110 section 8.8.3: weak
1951/// comparison ignores `W/` on both sides).
1952pub fn strip_weak(v: &str) -> &str {
1953 let trimmed = v.trim();
1954 trimmed.strip_prefix("W/").unwrap_or(trimmed)
1955}
1956
1957/// Whether an upstream 304's validator agrees with the stored one
1958/// (both absent also agrees — an entity with no validator is trivially
1959/// unchanged). Weak comparison, like `inm_matches`.
1960pub fn validators_match(upstream: Option<&str>, stored: Option<&str>) -> bool {
1961 match (upstream, stored) {
1962 (None, None) => true,
1963 (Some(u), Some(s)) => strip_weak(u) == strip_weak(s),
1964 _ => false,
1965 }
1966}
1967
1968/// Parsed upstream `Cache-Control` directives (RFC 7234 + the
1969/// `stale-if-error` extension, RFC 5861 section 4). Only the directives
1970/// the gateway's shared cache acts on are captured; unknown directives
1971/// are ignored (forward-compat). The parser is hand-rolled (no new
1972/// dependency) and tolerant: a malformed `delta-seconds` argument is
1973/// treated as absent rather than failing the whole header, and directive
1974/// names are matched case-insensitively with optional whitespace.
1975#[derive(Debug, Clone, Default, PartialEq, Eq)]
1976pub struct CacheControl {
1977 /// `no-store`: the response MUST NOT be stored (a hard storage veto).
1978 pub no_store: bool,
1979 /// `no-cache`: store, but revalidate before every reuse (a storage
1980 /// veto in v1 — the gateway does not yet serve-then-revalidate, so
1981 /// storing an entry that can never be replayed fresh is dead weight).
1982 pub no_cache: bool,
1983 /// `private`: a shared cache MUST NOT store the response (the
1984 /// gateway's cache is shared — consumer keying does not make it
1985 /// private, because the operator owns the cache, not the client).
1986 pub private: bool,
1987 /// `must-revalidate`: a stale response MUST NOT be served without
1988 /// revalidation. Recorded but not yet enforced beyond blocking the
1989 /// stale-while-revalidate serve path (see `lookup`).
1990 pub must_revalidate: bool,
1991 /// `s-maxage`: the freshness lifetime for SHARED caches. Takes
1992 /// precedence over `max-age` for the gateway's shared cache (RFC
1993 /// 7234 section 5.2.2.9). Absent = fall back to `max_age` then the
1994 /// configured `ttl_secs`.
1995 pub s_maxage: Option<u64>,
1996 /// `max-age`: the freshness lifetime for any cache. Used when
1997 /// `s-maxage` is absent.
1998 pub max_age: Option<u64>,
1999 /// `stale-if-error` (RFC 5861 section 4): the number of seconds PAST
2000 /// expiry a stale entry may be served when the upstream returns an
2001 /// error (5xx) or is unreachable. Absent/0 = no stale-on-error
2002 /// serving beyond the configured stale-while-revalidate window.
2003 pub stale_if_error: Option<u64>,
2004}
2005
2006impl CacheControl {
2007 /// Parse every `Cache-Control` header value (RFC 7234 section 5.2.2).
2008 /// Multiple values are folded; a directive appearing more than once
2009 /// uses the last occurrence's argument (matches common cache
2010 /// behavior). Returns an empty `CacheControl` when the header is
2011 /// absent or entirely unparseable — the caller then falls back to
2012 /// the configured policy.
2013 pub fn parse(headers: &HeaderMap) -> Self {
2014 let mut cc = CacheControl::default();
2015 for value in headers.get_all(hyper::header::CACHE_CONTROL) {
2016 let Ok(s) = value.to_str() else { continue };
2017 for directive in s.split(',') {
2018 let directive = directive.trim();
2019 if directive.is_empty() {
2020 continue;
2021 }
2022 let (name, arg) = directive
2023 .split_once('=')
2024 .map(|(n, v)| (n.trim(), Some(v.trim())))
2025 .unwrap_or((directive, None));
2026 match name.to_ascii_lowercase().as_str() {
2027 "no-store" => cc.no_store = true,
2028 "no-cache" => cc.no_cache = true,
2029 "private" => cc.private = true,
2030 "must-revalidate" => cc.must_revalidate = true,
2031 "s-maxage" => cc.s_maxage = arg.and_then(parse_delta_seconds),
2032 "max-age" => cc.max_age = arg.and_then(parse_delta_seconds),
2033 "stale-if-error" => cc.stale_if_error = arg.and_then(parse_delta_seconds),
2034 _ => {}
2035 }
2036 }
2037 }
2038 cc
2039 }
2040
2041 /// The effective shared-cache freshness lifetime, in seconds:
2042 /// `s-maxage` wins over `max-age` (RFC 7234 section 5.2.2.9), else
2043 /// None (the caller applies the configured `ttl_secs`).
2044 pub fn effective_max_age(&self) -> Option<u64> {
2045 self.s_maxage.or(self.max_age)
2046 }
2047}
2048
2049/// Parse an RFC 7234 `delta-seconds` argument: a non-negative integer
2050/// (the spec allows values larger than 2^31-1, which a 32-bit parser
2051/// would reject; u64 accepts them and they simply read as "very far in
2052/// the future"). A non-numeric or empty argument yields None (the
2053/// directive is treated as absent, not as a parse failure for the
2054/// whole header).
2055fn parse_delta_seconds(s: &str) -> Option<u64> {
2056 s.trim().parse::<u64>().ok()
2057}
2058
2059/// Path-segment-aware prefix match for URL purge (DP-04). `url` is the
2060/// stored request URL (`path[?query]`), `prefix` is the purge prefix.
2061/// Matches when: the stored URL equals the prefix, or the stored URL
2062/// starts with the prefix AND either the prefix ends with `/` (already
2063/// a full path segment) or the character after the prefix is a path
2064/// separator (`/` or `?`) — preventing `/api/users` from matching
2065/// `/api/users2` while allowing `/api/users/` to match `/api/users/alice`.
2066fn url_prefix_match(url: &str, prefix: &str) -> bool {
2067 if url == prefix {
2068 return true;
2069 }
2070 if !url.starts_with(prefix) {
2071 return false;
2072 }
2073 // A prefix ending with `/` is already segment-complete; any
2074 // continuation matches. Otherwise the next character must be a
2075 // path/query boundary so `/api/users` does not match `/api/users2`.
2076 prefix.ends_with('/') || {
2077 let after = &url[prefix.len()..];
2078 after.starts_with('/') || after.starts_with('?')
2079 }
2080}
2081
2082/// The response-side storable rules (see the module docs). Returns the
2083/// veto reason code for logging/telemetry when storage is forbidden.
2084pub fn store_veto(headers: &HeaderMap, policy: &CompiledRouteCache) -> Option<&'static str> {
2085 if headers.contains_key(hyper::header::SET_COOKIE) {
2086 return Some("set_cookie");
2087 }
2088 let cc = CacheControl::parse(headers);
2089 if cc.no_store || cc.private || cc.no_cache {
2090 return Some("cache_control");
2091 }
2092 // DP-04: s-maxage=0 or max-age=0 means "immediately stale" — the
2093 // origin requires revalidation on every use. This engine does not
2094 // yet support revalidate-on-every-use, so a zero freshness lifetime
2095 // is a storage veto (the response would be stored but never served
2096 // fresh, which is pointless and wastes capacity).
2097 if cc.effective_max_age() == Some(0) {
2098 return Some("cache_control");
2099 }
2100 if headers.contains_key(hyper::header::CONTENT_ENCODING) {
2101 return Some("content_encoding");
2102 }
2103 for value in headers.get_all(hyper::header::VARY) {
2104 let Ok(v) = value.to_str() else {
2105 return Some("vary");
2106 };
2107 for token in v.split(',') {
2108 let token = token.trim();
2109 if token.is_empty() {
2110 continue;
2111 }
2112 if token == "*" {
2113 return Some("vary_star");
2114 }
2115 if !policy
2116 .vary
2117 .iter()
2118 .any(|name| name.eq_ignore_ascii_case(token))
2119 {
2120 return Some("vary_uncovered");
2121 }
2122 }
2123 }
2124 None
2125}
2126
2127fn stamp(resp: &mut Response<ProxyBody>, outcome: &str) {
2128 if let Ok(v) = HeaderValue::from_str(outcome) {
2129 resp.headers_mut().insert(&X_CACHE, v);
2130 }
2131}
2132
2133fn stamp_age(resp: &mut Response<ProxyBody>, age_ms: u64) {
2134 if let Ok(v) = HeaderValue::from_str(&(age_ms / 1000).to_string()) {
2135 resp.headers_mut().insert(hyper::header::AGE, v);
2136 }
2137}
2138
2139// ---------------------------------------------------------------------------
2140// Size-capped response collection (the zero-buffering edge)
2141// ---------------------------------------------------------------------------
2142
2143/// Why a capped collection stopped early.
2144enum CollectError {
2145 /// The body crossed the cap mid-stream: the buffered prefix plus
2146 /// the untouched remainder must still reach the client.
2147 OverCap {
2148 prefix: Bytes,
2149 rest: Pin<Box<ProxyBody>>,
2150 },
2151 /// The stream errored mid-body (headers not yet sent: the caller
2152 /// answers a clean 502 envelope).
2153 Stream(String),
2154}
2155
2156/// Buffer a response body up to `cap` bytes — the ONLY buffering of
2157/// response bodies the cache ever performs, on the opted-in store path
2158/// only. Trailers are dropped (they described the streamed body; the
2159/// DW-028 transform collector made the same call for the same reason).
2160async fn collect_capped(body: ProxyBody, cap: u64) -> Result<Bytes, CollectError> {
2161 let mut body = Box::pin(body);
2162 let mut buf: Vec<u8> = Vec::new();
2163 loop {
2164 match body.as_mut().frame().await {
2165 Some(Ok(frame)) => {
2166 let Ok(data) = frame.into_data() else {
2167 continue; // trailer: dropped
2168 };
2169 if buf.len() as u64 + data.len() as u64 > cap {
2170 buf.extend_from_slice(&data);
2171 return Err(CollectError::OverCap {
2172 prefix: Bytes::from(buf),
2173 rest: body,
2174 });
2175 }
2176 buf.extend_from_slice(&data);
2177 }
2178 Some(Err(err)) => return Err(CollectError::Stream(err.to_string())),
2179 None => return Ok(Bytes::from(buf)),
2180 }
2181 }
2182}
2183
2184/// A body that replays a buffered prefix and then continues the
2185/// original stream byte-for-byte (the over-cap passthrough arm — the
2186/// response reaches the client exactly as if no cache existed).
2187pub struct PassthroughBody {
2188 prefix: Bytes,
2189 rest: Pin<Box<ProxyBody>>,
2190}
2191
2192impl hyper::body::Body for PassthroughBody {
2193 type Data = Bytes;
2194 type Error = super::proxy::ProxyBodyError;
2195
2196 fn poll_frame(
2197 self: Pin<&mut Self>,
2198 cx: &mut Context<'_>,
2199 ) -> Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
2200 let this = self.get_mut();
2201 if !this.prefix.is_empty() {
2202 return Poll::Ready(Some(Ok(hyper::body::Frame::data(std::mem::take(
2203 &mut this.prefix,
2204 )))));
2205 }
2206 this.rest.as_mut().poll_frame(cx)
2207 }
2208
2209 fn is_end_stream(&self) -> bool {
2210 self.prefix.is_empty() && self.rest.is_end_stream()
2211 }
2212
2213 fn size_hint(&self) -> hyper::body::SizeHint {
2214 if self.prefix.is_empty() {
2215 self.rest.size_hint()
2216 } else {
2217 hyper::body::SizeHint::default()
2218 }
2219 }
2220}