Module response_cache

Module response_cache 

Source
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 headers

The 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_KEYS distinct 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§

CacheControl
Parsed upstream Cache-Control directives (RFC 7234 + the stale-if-error extension, 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 malformed delta-seconds argument is treated as absent rather than failing the whole header, and directive names are matched case-insensitively with optional whitespace.
CoalesceLead
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.
EntryEnvelope
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).
MissFlow
Everything the store stage and the background revalidation need about one cacheable request (DW-037).
PassthroughBody
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).
ResponseCache
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§

CacheFlow
The per-request cache state carried from lookup to the store stage.
CoalesceOutcome
What ResponseCache::attach decided for one cache miss (DW-038).
LookupOutcome
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, or revalidated — the same closed set as the dwara_cache_lookups_total metric (plus revalidated, 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-Match value 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.