dwara_core/config/
cache.rs

1//! Route-scoped response caching grammar (DW-037, feature analysis
2//! 5-Protocol "Response caching"): the config shape of a route's cache
3//! policy and the compiled form the dataplane's response cache consumes.
4//!
5//! This is config-contract grammar in the same sense as `transforms.rs`
6//! (DW-028) and `versioning.rs` (DW-048): validation
7//! (`snapshot::validate`) and the runtime (`dataplane::response_cache`)
8//! must agree on ONE parsing of these values, so the grammar lives in
9//! `config`, the lowest consuming domain.
10//!
11//! ## Posture: explicit opt-in buffering, bounded
12//!
13//! The dataplane buffers nothing by default; a cache entry is a
14//! buffered response body. Presence of the `cache` block is therefore
15//! the opt-in (the same presence-means-enabled rule as `transforms`,
16//! `compression`, and `masking`), and the buffering is capped by
17//! `max_body_bytes` per entry: a response that would cross the cap is
18//! passed through UNSTORED and streams exactly as if no cache existed
19//! (see `dataplane::response_cache` for the full cacheability matrix).
20//!
21//! ## Variance model: configured + policy-derived, not response-driven
22//!
23//! RFC 9111 lets a response's own `Vary` drive what a shared cache
24//! keys on. dwara's cache key must be derivable from the REQUEST alone
25//! (the backing `CacheStore` extension trait is an opaque key-value
26//! seam — no entry enumeration, so no
27//! two-level "hash, then vary" lookup is possible). The variance
28//! dimension is therefore DECLARED, not discovered:
29//!
30//! - the operator lists extra request headers in `vary`;
31//! - the snapshot compile folds in the headers the gateway's own
32//!   policies already promise to vary by: `Accept` when the route
33//!   selects on `match.accept` (DW-048), `Origin` when the route
34//!   carries a CORS policy (DW-027), and `Accept-Encoding` never
35//!   (cached bodies are identity; compression re-negotiates per
36//!   request on replay).
37//!
38//! A response whose `Vary` names anything OUTSIDE the effective set is
39//! not stored (the cache cannot prove it would key correctly), and
40//! `Vary: *` never stores. The union of everything the key folds in is
41//! stamped on cached responses through the decoration tail's existing
42//! `Vary` merges, so replays advertise exactly what they varied by.
43
44use std::time::Duration;
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48
49/// Default `cache.max_body_bytes` (DW-037): 1 MiB per entry — large
50/// enough for typical JSON payloads, small enough that a full store of
51/// them stays cheap. Validation bounds the configurable range around it.
52pub const DEFAULT_CACHE_MAX_BODY_BYTES: u64 = 1024 * 1024;
53
54/// Upper bound for `cache.max_body_bytes` (DW-037): 16 MiB. A cache
55/// entry is a buffered body; the ceiling keeps one route's policy from
56/// opting the gateway into buffering near-streaming payloads.
57pub const MAX_CACHE_MAX_BODY_BYTES: u64 = 16 * 1024 * 1024;
58
59/// Upper bound for `cache.ttl_secs` (DW-037): 24 hours. Freshness
60/// beyond a day is an origin's job (CDN), not a gateway's local cache.
61pub const MAX_CACHE_TTL_SECS: u64 = 86400;
62
63/// Upper bound for `cache.stale_while_revalidate_secs` (DW-037): 24
64/// hours, symmetric with the TTL bound.
65pub const MAX_CACHE_STALE_SECS: u64 = 86400;
66
67/// Maximum number of `vary` entries (DW-037): the vary set multiplies
68/// the key space per route; eight extra dimensions is already generous.
69pub const MAX_CACHE_VARY_HEADERS: usize = 8;
70
71/// Default `cache.coalescing.wait_ms` (DW-038): 5 s — comfortably above
72/// a typical upstream fetch so a follower normally out-waits the leader
73/// instead of piling its own call on top (the wait bound exists for the
74/// pathological leader, not the common one).
75pub const DEFAULT_COALESCE_WAIT_MS: u64 = 5000;
76
77/// Upper bound for `cache.coalescing.wait_ms` (DW-038): 60 s. A follower
78/// parked longer than a minute is a stuck request from the client's
79/// point of view; the route's own timeouts bound the leader, so a wait
80/// beyond them only delays the inevitable fail-open.
81pub const MAX_COALESCE_WAIT_MS: u64 = 60_000;
82
83/// Route-scoped response caching policy (DW-037). Absent (the
84/// default): the route's responses are never cached, buffered, or
85/// stamped with cache headers — bytes flow exactly as before. Presence
86/// opts the route's GET traffic into the local response cache behind
87/// the `CacheStore` extension seam (extensions domain).
88///
89/// Semantics (frozen; enforcement lives in `dataplane::response_cache`):
90///
91/// - Only PROXY-action routes cache. The key folds in the route name,
92///   the consumer, the inbound path + query, and the effective vary
93///   set's request header values — two consumers (or two vary
94///   dimensions) never share an entry.
95/// - Freshness is the configured `ttl_secs`, full stop: upstream
96///   freshness directives other than the storage vetoes
97///   (`no-store`/`private`/`no-cache`) are deliberately ignored — the
98///   OPERATOR, not the origin, owns how long the gateway holds an
99///   entry. Within `stale_while_revalidate_secs` after expiry the
100///   entry is served stale while a single background revalidation
101///   refreshes it; past that window the next request revalidates
102///   synchronously (conditional on the stored validator).
103/// - `ETag` round-trips: a stored entry's ETag backs client
104///   `If-None-Match` 304s from the cache and upstream revalidation
105///   (`If-None-Match` on the forwarded fetch; a 304 refreshes the
106///   entry without re-sending the body).
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct RouteCache {
110    /// Freshness lifetime, in seconds. Required: a cache entry without
111    /// a lifetime is an unbounded staleness bug, so there is no
112    /// default. Validation rejects 0 and values above 24 hours.
113    pub ttl_secs: u64,
114    /// How long past expiry the entry may be served stale while a
115    /// background revalidation runs (DW-037
116    /// stale-while-revalidate). Absent/0: no stale serving — an expired
117    /// entry revalidates synchronously. Validation bounds the value to
118    /// 24 hours.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub stale_while_revalidate_secs: Option<u64>,
121    /// Per-entry body cap in bytes: responses at or under the cap are
122    /// buffered and stored; larger responses pass through unstored and
123    /// unbuffered (the zero-buffering principle — the cap is the
124    /// buffering opt-in's hard edge). Absent: 1 MiB. Validation bounds
125    /// the range [1, 16 MiB].
126    #[serde(
127        default = "default_cache_max_body_bytes",
128        skip_serializing_if = "is_default_max_body_bytes"
129    )]
130    pub max_body_bytes: u64,
131    /// Extra request header names folded into the cache key (the
132    /// configured half of the variance model — see the module docs).
133    /// The policy-derived halves (`Accept` for `match.accept` routes,
134    /// `Origin` for CORS routes) are added at snapshot compile and
135    /// need not (but may) be listed. Validation rejects duplicates,
136    /// hop-by-hop/framing names, `Authorization`/`Cookie` (requests
137    /// carrying them are never cacheable, so varying on them is dead
138    /// configuration), and `X-Consumer-*` (consumer identity is already
139    /// a key component).
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub vary: Vec<String>,
142    /// Request coalescing (DW-038): collapse concurrent identical
143    /// cacheable GETs on this route into ONE upstream call — the first
144    /// miss (the leader) fetches while the rest (followers) wait,
145    /// bounded, and replay the leader's stored outcome. Absent (the
146    /// default): every miss fetches independently, exactly as before.
147    /// Presence enables; see [`RouteCacheCoalescing`].
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub coalescing: Option<RouteCacheCoalescing>,
150}
151
152/// Request-coalescing knobs (DW-038). Presence of the block on a
153/// route's `cache` is the opt-in (the same presence-means-enabled rule
154/// as every other block); this struct carries the one bound the
155/// behavior needs.
156///
157/// Scope (deliberate, spec-faithful): coalescing applies ONLY to the
158/// cache-miss path of cache-enabled routes — "concurrent identical
159/// CACHEABLE GETs". Requests on routes without a `cache` block (or
160/// shapes the cache bypasses: non-GET, credentialed, body-bearing,
161/// upgrade) are never coalesced; they were never cacheable, so there is
162/// no shared outcome to hand a follower.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct RouteCacheCoalescing {
166    /// How long a follower waits for the leader's outcome before doing
167    /// its own upstream call (fail open — a client is never errored
168    /// because coalescing gave up). Absent: 5 s. Validation bounds the
169    /// value to (0, 60 s].
170    #[serde(
171        default = "default_coalesce_wait_ms",
172        skip_serializing_if = "is_default_coalesce_wait_ms"
173    )]
174    pub wait_ms: u64,
175}
176
177fn default_coalesce_wait_ms() -> u64 {
178    DEFAULT_COALESCE_WAIT_MS
179}
180
181fn is_default_coalesce_wait_ms(v: &u64) -> bool {
182    *v == DEFAULT_COALESCE_WAIT_MS
183}
184
185fn default_cache_max_body_bytes() -> u64 {
186    DEFAULT_CACHE_MAX_BODY_BYTES
187}
188
189fn is_default_max_body_bytes(v: &u64) -> bool {
190    *v == DEFAULT_CACHE_MAX_BODY_BYTES
191}
192
193/// Header names that may never appear in `cache.vary`, with the reason
194/// validation reports. Checked case-insensitively against the
195/// lowercased name. Grammar helper in the house style of `net.rs` /
196/// `transforms.rs`: validation (snapshot) and compile agree on this
197/// one parsing.
198pub fn forbidden_vary_reason(name: &str) -> Option<&'static str> {
199    match name {
200        "authorization" | "cookie" => {
201            Some("requests carrying it are never cacheable, so varying on it is dead configuration")
202        }
203        "host" => Some("the route's host match already selects the response"),
204        "connection"
205        | "keep-alive"
206        | "proxy-authenticate"
207        | "proxy-authorization"
208        | "proxy-connection"
209        | "te"
210        | "trailer"
211        | "transfer-encoding"
212        | "upgrade" => Some("hop-by-hop headers never reach the cache decision"),
213        "content-length" => Some("framing header; not a variance dimension"),
214        "cache-control" | "pragma" => {
215            Some("request cache directives do not vary the response body")
216        }
217        _ => {
218            if name.starts_with("x-consumer-") {
219                Some("consumer identity is already a cache key component")
220            } else {
221                None
222            }
223        }
224    }
225}
226
227/// The compiled per-route cache policy (DW-037): validated values
228/// resolved into the exact durations, bounds, and the EFFECTIVE vary
229/// set (configured entries plus the policy-derived `Accept`/`Origin`
230/// folds), lowercased and in deterministic order. Built once at
231/// snapshot compile (`RouteTable`), never per request.
232#[derive(Debug, Clone, PartialEq)]
233pub struct CompiledRouteCache {
234    /// Freshness lifetime.
235    pub ttl: Duration,
236    /// Stale-while-revalidate window past expiry (0 = disabled).
237    pub stale_while_revalidate: Duration,
238    /// Per-entry body cap.
239    pub max_body_bytes: u64,
240    /// The effective vary set: lowercased header names, deduplicated,
241    /// in deterministic order (configured order, then the derived
242    /// folds). This is exactly the set of request headers the key folds
243    /// in, and exactly what a stored response's `Vary` must be a
244    /// subset of.
245    pub vary: Vec<String>,
246    /// Follower wait bound for request coalescing (DW-038). None:
247    /// coalescing disabled on this route (the default).
248    pub coalesce_wait: Option<Duration>,
249}
250
251impl CompiledRouteCache {
252    /// Compile the route's cache block, folding in the policy-derived
253    /// vary dimensions: `accept` (Some when the route matches on
254    /// `match.accept`, DW-048) and `origin` (the route carries a CORS
255    /// policy, DW-027). Validation has already run, so the configured
256    /// vary list is well-formed; the folds deduplicate against it.
257    pub fn compile(cache: &RouteCache, accept: bool, cors: bool) -> Self {
258        let mut vary: Vec<String> = cache
259            .vary
260            .iter()
261            .map(|v| v.trim().to_ascii_lowercase())
262            .collect();
263        if accept && !vary.iter().any(|v| v == "accept") {
264            vary.push("accept".to_string());
265        }
266        if cors && !vary.iter().any(|v| v == "origin") {
267            vary.push("origin".to_string());
268        }
269        CompiledRouteCache {
270            ttl: Duration::from_secs(cache.ttl_secs),
271            stale_while_revalidate: Duration::from_secs(
272                cache.stale_while_revalidate_secs.unwrap_or(0),
273            ),
274            max_body_bytes: cache.max_body_bytes,
275            vary,
276            coalesce_wait: cache
277                .coalescing
278                .as_ref()
279                .map(|c| Duration::from_millis(c.wait_ms)),
280        }
281    }
282}