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}