Expand description
Local response cache engine (DW-037, feature analysis 5-Protocol “Response caching”).
Sits behind the CacheStore extension seam
(DW-004): the engine owns HTTP semantics — keys,
cacheability, freshness, revalidation, purge — while the backend is
an opaque byte store (OSS ships the moka-backed MokaCache;
DW-068 swaps in Redis behind the same trait). Cache errors degrade
to misses, never request failures.
§Where it sits in the pipeline
... authn -> authz -> rate limit -> cap admission
-> CACHE LOOKUP (hit/stale: replay; miss: fall through)
-> proxy action -> masking (DW-029) -> body/header transforms (DW-028)
-> CACHE STORE (post-mask, post-transform bytes; pre-compression)
-> compression (DW-027) -> versioning stamps (DW-048) -> CORS (DW-027)
-> security headers (DW-028) -> rate headersThe two placements are load-bearing:
- LOOKUP runs after authn/authz/rate limiting on purpose: a replay is still client traffic (it consumed a rate-limit token and an admission slot, and the consumer’s identity is part of the key), and no policy may be bypassed by a hit. It runs before the breaker/endpoint pick because a hit contacts no upstream.
- STORE writes the POST-masking, POST-transform bytes keyed per consumer: replayed bytes are exactly what the same consumer would have received (masking and transforms are route-scoped, so replay consistency holds — pinned by test: a transform change invalidates old entries). Stored bodies are IDENTITY (never compressed — DW-027’s compression re-negotiates per request on replay), and the decoration tail from compression onward re-runs on every replay: security headers, masking-era bytes, CORS, rate headers can never be bypassed by a cache hit.
§Cacheability (deterministic, closed rules)
A REQUEST is cacheable when: the route has a cache block and a
PROXY action; the method is GET or HEAD (DP-04: HEAD is cacheable —
a HEAD response carries the same headers as GET with no body, and
the method folds into the key so the two representations are
distinct entries; a fresh GET entry also serves a HEAD via the
GET-fallback, RFC 9111 section 4.1); the request carries no body,
no Authorization, no Cookie (credentials make per-consumer
keying insufficient — two bearer tokens of one consumer would share
an entry), and no Upgrade. Everything else is a BYPASS (stamped
and counted, never stored).
A RESPONSE is storable when: status is exactly 200; it carries no
Set-Cookie; its Cache-Control has none of no-store /
private / no-cache (the storage vetoes; DP-04 additionally
honors s-maxage/max-age as the freshness lifetime — taking
precedence over the configured ttl_secs — and stale-if-error
as the per-entry stale-on-error window, RFC 5861 section 4); it is
not content-encoded (dwara compresses on replay; an upstream-encoded
body cannot be re-negotiated); and its Vary is *-free and a
subset of the route’s effective vary set (see config::cache for
the configured + policy-derived variance model — the key must be
derivable from the request alone, so an unknown variance dimension
forbids storage).
§Keys
sha256("dwara-rc-v1" | route | epoch | method | consumer | path | query | vary-name=value...) — hex-encoded. The method component
(DP-04) separates HEAD and GET representations of one resource.
The consumer component means masked (DW-029) and consumer-group
variants can never cross consumers. Keys are never logged (paths
and query strings carry tokens; the hash is opaque anyway).
§Freshness, stale-while-revalidate, ETag
Fresh for the effective freshness lifetime — the upstream
Cache-Control: s-maxage (then max-age) when present (DP-04,
RFC 7234 section 5.2.2.9), else the configured ttl_secs; within
stale_while_revalidate_secs after expiry the entry is served stale (x-cache: stale) while ONE
background revalidation runs per key (a bounded in-flight set
deduplicates; DW-038’s request coalescing applies the same
single-flight discipline to the foreground miss path). Past the
window the next request revalidates synchronously: the stored
validator rides the forwarded fetch as If-None-Match (only when
the client sent none of its own — a client conditional always wins
the forwarded request), and an upstream 304 refreshes the entry
without re-sending the body (x-cache: revalidated). A client
If-None-Match that matches a FRESH entry’s validator is answered
304 straight from the cache. Weak comparison (W/ prefixes ignored)
per RFC 9110 section 8.8.3.
§Request coalescing (DW-038)
A cache MISS on a route whose cache block carries coalescing
either becomes the LEADER (fetches upstream while holding the key’s
slot) or a FOLLOWER (parks, bounded by the route’s
coalescing.wait_ms). The coalescing key IS the cache key — route
epoch, consumer, path, query, vary — so a follower can only ever be
handed an outcome computed for a byte-identical request shape of
its OWN consumer and generation; per-consumer isolation is
inherited from the key, not reimplemented. The STORE is the share
point: the leader’s store stage completes before it publishes, and
a woken follower re-reads the store and replays the entry exactly
like a hit (x-cache: hit; the decoration tail re-runs — the same
replay guarantees as a normal hit). Scope: the miss path of
cache-enabled routes only. A request shape the cache bypasses
(non-GET, credentialed, body-bearing, upgrade) never coalesces,
and routes without a cache block never coalesce — “concurrent
identical cacheable GETs” is the whole claim.
Every failure mode fails OPEN (no client is ever errored because coalescing gave up), each rule pinned by test:
- Leader finished with nothing storable (vetoed, non-200, upstream error, over-cap) or died outright (panic, client-cancel abort): followers fetch on their own, each running the route’s FULL retry policy — a leader’s failure is never inherited.
- Epoch flipped mid-flight (purge or config change): followers fetch on their own rather than inherit a dead generation’s answer.
- Follower wait bound expired: the follower fetches on its own.
- The leader map is saturated (
MAX_COALESCING_KEYSdistinct in-flight keys): the request never joins — it just fetches (uncounted by coalescing metrics; it was neither leader nor follower).
The map holds LEADER slots only (waiters carry no per-key state); a slot leaves the map at completion by the leader’s guard, and the guard’s Drop is the publish — so a leader that dies unpublishes into the fail-open path too. Nothing in the coalescing path waits on the SWR revalidation in-flight set or vice versa: the two single-flight mechanisms have disjoint state and no shared locks, so they cannot deadlock or double-subscribe each other.
§Invalidations (why epochs)
Entries record the route’s CACHE EPOCH at store time; a lookup
under a different epoch is a miss (the dead entry is dropped).
Epochs bump on: an explicit purge (the admin API — an O(1)
generation advance, which is why purge is <100 ms at any store
size; the opaque backend is never enumerated), and any snapshot
publish that CHANGES a route’s definition (a Route-equality diff
at refresh — stored bytes were shaped by the old masking/transform/
cache policy, so any route change invalidates that route’s entries;
unrelated config edits leave the cache warm). Entries left
unreachable by a bump are never re-read; the byte-weighed store
reclaims them by eviction.
DP-04 adds two targeted purge axes alongside the epoch-based route
purge: purge-by-tag (every entry the upstream tagged with a given
Cache-Tags value) and purge-by-URL (every entry for an exact or
prefix-matched request URL). These delete specific keys through the
CacheStore seam (O(keys-matching), not O(1)) using in-memory
tag→keys and URL→keys indexes populated at store time; the indexes
are runtime state (lost on restart) and self-clean lazily as purges
drop their dead cross-references.
§Reload behavior
The engine (store, epochs, in-flight set) lives on the
DataPlane, NOT in the snapshot: it is
runtime state, like the priority counters, and survives config
reloads. A changed cache block applies to NEW lookups (freshness
windows are read from the CURRENT policy at lookup time); entries
stored under a changed route die by the epoch rule above.
Structs§
- Cache
Control - Parsed upstream
Cache-Controldirectives (RFC 7234 + thestale-if-errorextension, RFC 5861 section 4). Only the directives the gateway’s shared cache acts on are captured; unknown directives are ignored (forward-compat). The parser is hand-rolled (no new dependency) and tolerant: a malformeddelta-secondsargument is treated as absent rather than failing the whole header, and directive names are matched case-insensitively with optional whitespace. - Coalesce
Lead - A leader’s hold on one coalescing key (DW-038). Dropping IS the publication: the slot leaves the map, then every parked follower wakes and re-reads the store. The guard pattern (not manual cleanup) so a leader that panics or is cancelled mid-fetch still publishes — its followers wake, find nothing shareable, and fetch on their own.
- Entry
Envelope - One stored response (the value bytes of the
CacheStore). The layout is a tiny length-prefixed binary frame (no new dependency): magic, schema version, epoch, stored-at, status, header pairs, body. Headers are the DENY-LISTED copy of the response’s headers — hop- by-hop and framing names are already gone (stripped on the proxy path); additionally excluded at store time:Content-Length(recomputed on replay),Vary(re-derived from the live policy),Age(recomputed),Cache-Control/Expires(the gateway owns the entry’s freshness — replaying origin directives would mislead clients about a gateway-held entry),Set-Cookie(personalized; also a storage veto), and the gateway’s own stamps (X-Cache,X-Request-Id,X-RateLimit-*). Everything else round-trips verbatim (ETag, Last-Modified, Date, Content-Type, custom headers). - Miss
Flow - Everything the store stage and the background revalidation need about one cacheable request (DW-037).
- Passthrough
Body - A body that replays a buffered prefix and then continues the original stream byte-for-byte (the over-cap passthrough arm — the response reaches the client exactly as if no cache existed).
- Response
Cache - The local response cache: opaque store + route epochs + the
single-flight revalidation guard + the request-coalescing leader
map. Owned by the
DataPlane; survives reloads.
Enums§
- Cache
Flow - The per-request cache state carried from lookup to the store stage.
- Coalesce
Outcome - What
ResponseCache::attachdecided for one cache miss (DW-038). - Lookup
Outcome - What a lookup decided (DW-037): replay now, carry a miss to the store stage, or stamp a bypass.
Constants§
- MAX_
COALESCING_ KEYS - Upper bound on concurrently held coalescing LEADER slots (DW-038). The map bounds memory (each slot is a key string + a watch channel) and the stampede-collapse itself: beyond this many DISTINCT in-flight cache keys, new misses never join — they fetch independently (fail open, uncounted by coalescing metrics). Slots are not evicted early (an in-flight leader cannot be preempted — its followers are parked on it); the map drains as leaders complete, which IS the eviction policy: hold-while-in-flight, remove-at-completion, refuse-at-capacity. A same-process burst with more than this many distinct keys was never going to collapse anyway — the keys are distinct.
- MAX_
INFLIGHT_ REVALIDATIONS - Upper bound on concurrently in-flight background revalidations (DW-037): the stale-while-revalidate path serves stale immediately and refreshes in the background; beyond this many DISTINCT keys the gateway skips spawning (the next request past the stale window revalidates synchronously) so a cache-wide expiry cannot stampede.
- X_CACHE
- The gateway’s cache-outcome stamp header (DW-037):
hit,stale,miss,bypass, orrevalidated— the same closed set as thedwara_cache_lookups_totalmetric (plusrevalidated, which is a miss resolved by a 304 confirmation).
Functions§
- derive_
key - Derive the store key: a SHA-256 over the domain-tagged components (route, epoch, consumer, method, path, query, vary values). Hashing keeps the key length bounded and keeps raw paths/queries out of the store’s memory-visible key space (they are never logged either way). The method component (DP-04) separates a HEAD representation (no body) from a GET representation (full body) of the same resource — they are distinct cache entries per RFC 9111 section 4.1.
- inm_
matches - Whether a client
If-None-Matchvalue matches a stored validator:*matches any; otherwise weak comparison (RFC 9110 section 8.8.3 — the W/ prefix is ignored on both sides) over the comma-separated list. A stored entry without a validator never matches. - store_
veto - The response-side storable rules (see the module docs). Returns the veto reason code for logging/telemetry when storage is forbidden.
- strip_
weak - Strip the weak-validator prefix (RFC 9110 section 8.8.3: weak
comparison ignores
W/on both sides). - validators_
match - Whether an upstream 304’s validator agrees with the stored one
(both absent also agrees — an entity with no validator is trivially
unchanged). Weak comparison, like
inm_matches.