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}