feat: chunk-aligned edge caching of object bytes via the Cache API#189
feat: chunk-aligned edge caching of object bytes via the Cache API#189alukach wants to merge 6 commits into
Conversation
) Serve object GETs on public products through a per-PoP chunk cache: normalize the client range to 4 MiB aligned blocks, cache.match each block, fetch misses from the backend as ranged GETs against the same presigned URL (Range is not part of the SigV4 query signature), and assemble the exact requested range into a 206. - ChunkCachingBackend wraps WorkerBackend, so the gateway pipeline — authorization, error mapping, analytics — runs unchanged on every request; the cache only changes how the backend fetch is satisfied. - Eligibility is decided in lib.rs: object GET, no query, and the *anonymous* product view is_public() (fails closed to bypass). - Chunk keys carry content identity only (client path + ETag + chunk size + index) — overwrites and future chunk-size changes are self-invalidating; auth material never enters a key. - Per-object meta (ETag + length, 60s TTL) is learned via a 1-byte probe; chunk fetches carry If-Match, so a mid-read overwrite yields a bypass, never mixed-generation bytes. Spans > 32 MiB bypass. - x-cache: HIT/MISS/BYPASS (+ x-cache-chunks m/n) on responses; analytics gains append-only blob10 = cache_status. - CHUNK_CACHE_ENABLED gates rollout: staging on, prod off. Verified end-to-end under wrangler dev: MISS→HIT transitions, overlapping-range chunk reuse, suffix ranges via cached meta, straddling-range assembly byte-identical to origin, >32 MiB bypass, and local 416 for past-EOF ranges. Closes #188 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @alukach's task in 4m 30s —— View job Review complete
✅ No blocking issues — safe to merge. Walked through the range/chunk math, the cache-key construction, the ETag/ Two minor, non-blocking observations:
Nothing here blocks merge — both are edge cases already implicitly acknowledged by the PR's own "fails closed" / conservative-floor design philosophy. |
|
🚀 Latest commit deployed to https://source-data-proxy-pr-189.source-coop.workers.dev
|
Correctness: - Preserve origin entity headers on cache-served responses. build_response rebuilt headers from a 4-field ObjectMeta, dropping content-encoding, content-disposition, cache-control, x-amz-meta-*, etc. that the direct path forwards — a Content-Encoding: gzip object served without the header corrupts. Meta now stores the full origin header set (minus regenerated range framing) and replays it. - Bypass instead of synthesizing 416 from stale meta. A range unsatisfiable against cached meta now forwards to origin, which adjudicates against the live object — a grown object no longer 416s valid ranges for up to 60s, and S3's parseable InvalidRange body is preserved. - parse_range now rejects a leading '+' (u64::from_str accepted it), so a non-RFC-9110 bytes=+0-+9 bypasses like S3 instead of diverging into a 206. - Validate chunk length before caching, and evict a wrong-length cached chunk before bypassing — a single short backend body no longer poisons an immutable chunk key for its whole generation. - Normalize x-cache to the known HIT/MISS/BYPASS tokens before it reaches analytics blob10, so a CDN-fronted backend's x-cache can't pollute the taxonomy on plan=None paths. Cleanup / cost notes: - Reuse multistore's collect_js_body instead of a private arrayBuffer copy (drops the wasm-bindgen-futures direct dep). - Drop the 4 MiB per-miss bytes.clone(): move the validated Vec into the background put after slicing into the assembly buffer. - Fold ServeError into a bypass-reason string; unify the two BYPASS paths; extract one background_put; flatten the lib.rs plan gate. - Remove the dead If-Range eligibility guard (multistore strips it upstream) and correct the ≤8-chunks doc to 9 for unaligned 32 MiB spans. Verified: native suite (73 tests) + integration (22) green; end-to-end under wrangler dev — header replay (content-type/last-modified/etag), past-EOF now BYPASS→origin 416, MISS→HIT, suffix + straddling ranges byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Existing chunk-cache integration tests only checked that x-cache carried a valid disposition, tolerating a permanent MISS — so a cache that never populated would still pass. Add an effectiveness smoke test that repeats a range and polls until it's served from the edge (x-cache: HIT), since the write lands via ctx.wait_until after the MISS returns. Skips (not passes) when CHUNK_CACHE_ENABLED is off, so a disabled cache can't read as working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preview is staging-equivalent and staging runs CHUNK_CACHE_ENABLED=true, but the flag was absent from wrangler.preview.toml so it defaulted off — a ranged GET on the preview went straight to origin with no x-cache header, leaving the feature unverifiable on preview URLs. Turn it on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ulti-chunk assembly (#188) Three changes that keep the origin-offload gain while removing the measured losses (read amplification, large-range TTFB): - Cache ONLY ranged reads. Full-object GETs (no Range) and `bytes=0-` open-ended transfers now bypass to the direct stream — they're bulk, single-touch, and the worst case for assemble-then-respond. Cloud-native geospatial reads (COG tiles, GeoParquet footers, PMTiles dirs, Zarr chunks) are all ranged and carry the cross-client reuse the cache exists for. - Fix #1: range-slice the Cache API on a hit. A warm mid-chunk read asks the Cache API for just the requested bytes (Cloudflare honors Range on a cached response -> server-side-sliced 206) instead of pulling the whole 4 MiB block into wasm and slicing. Collapses the warm read-out amplification that dominated the parquet-window regression. Falls back to wasm slicing if a runtime returns the full 200. Chunk size stays 4 MiB so the cold over-fetch still prefetches adjacent tiles/blocks. - Fix #2: stream multi-chunk assembly. A range spanning >1 chunk flushes bytes after the first chunk resolves (bounded-concurrency ordered stream via Response::from_stream) instead of buffering the whole span, fixing large-range TTFB/throughput. Single-chunk reads keep the buffered path (exact HIT/MISS, no stream overhead). Trade: a post-flush chunk failure truncates (client retry) rather than bypassing; correctness holds via per-chunk length+ETag validation. Footer reuse preserved: bytes=-8 and bytes=-100 resolve from the same cached tail chunk. Tests: +1 native (bytes=0- -> From(0)), +4 integration (full-object bypass, bytes=0- bypass, multi-chunk streamed bytes, footer chunk-sharing HIT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design note: making edge caching conditional per productFollow-up question that came up: could edge caching be opt-in per product rather than "all public products"? Short answer — it's cheap on the proxy, because the decision point already has the product object in hand. The get_or_fetch_product(...).await.ok()
.filter(|p| p.is_public())
.map(|_| CachePlan::new(&host, &parts.path, ctx.clone()))Making it product-conditional = adding one predicate right here. No new fetch, no extra round-trip. The real question is where the opt-in bit comes from, not the plumbing. Three ways to source the decisionA. Env-var allow/deny list — proxy-only, shippable now.
B. A product flag from the Source API — the "real" opt-in.
C. Per-product policy (not just on/off). Level of control achievable
The one real dependencyToday the product's Why it's low-risk either wayThe gate already fetches the product, already branches, and Bottom line: ops-managed named lists are a few hours entirely within this repo (Option A). Self-service per-product opt-in is also easy on the proxy but gated on a small Source API schema change (Option B) — and we can ship A now and migrate to B later without reworking the gate, since both are just predicates in the same spot. |
Capture the #188/#189 design as adrs/009-edge-caching.md, following the ADR convention introduced in #115 (adrs/NNN-title.md): context + constraints, the ranged-only/range-sliced/streamed decision, the eviction & overwrite-consistency model (ETag-in-key rotation, If-Match, 60s meta-TTL staleness bound), rollout, consequences, alternatives, and future options (per-product opt-in). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements #188.
What
Ranged object GETs on public products are served through a worker-controlled, per-PoP chunk cache built on the Cache API (the same mechanism as the Source API metadata cache, extended to data):
206— buffered for a single-chunk read, streamed for a multi-chunk read.bytes=0-bulk transfers bypass to the direct stream. Spans > 32 MiB also bypass, bounding subrequests and memory.How
ChunkCachingBackendwraps multistore'sWorkerBackend(ProxyBackend::forward). Key enabler: GETs are presigned as query-string SigV4 withRangeunsigned and pass-through, so one presigned URL serves all chunk sub-fetches with differentRangeheaders — no re-signing, no multistore changes, and authz/analytics/error mapping stay byte-identical.eligible): object GET, carries aRangeheader, non-conditional, on a product whose anonymous view isis_public(). Cloud-native geospatial reads (COG tiles, GeoParquet footers, PMTiles directories, Zarr chunks) are all ranged and carry the cross-client reuse the cache exists for; full-object GETs are bulk downloads (aws s3 cp, rclone) — throughput-bound, single-touch, worst-case for assemble-then-respond — so they stay on the direct stream.bytes=0-(open-ended from zero) is a full-object transfer wearing a Range header and bypasses the same way. Private bytes can never enter or leave the cache.cache_get_chunk): a warm mid-chunk read asks the Cache API for just the requested bytes of the cached block (cache.getwith aRangeheader → Cloudflare returns a server-side-sliced 206) instead of pulling the whole 4 MiB into the isolate and slicing there. Falls back to slicing in-wasm if a runtime returns the full 200. The chunk stays 4 MiB, so a cold miss still over-fetches the whole block — that over-fetch is deliberate read-ahead for adjacent tiles/blocks (COG grids, vsicurl scans); only the warm read-out, which has no prefetch value, is trimmed.futures::stream…buffered(N)→Response::from_stream, the same primitive the direct S3 passthrough already streams through) so the first bytes flush after the first chunk resolves instead of after the whole span buffers. Single-chunk reads keep the buffered path (exact HIT/MISS, no stream setup). Ordered emission + per-chunk length/ETag validation mean no chunk is ever yielded before it's validated; the trade is that once bytes have flushed, a later-chunk failure truncates the response (client retry) rather than bypassing.https://{host}/.chunk-cache/v1/{account}/{product}/{key}?etag=…&cs=…&i=N. ETag in the key makes overwrites self-invalidating; chunk size in the key makes tuning self-invalidating; auth material never appears.bytes=0-0); every chunk fetch carriesIf-Match. An overwrite mid-read produces a 412 → meta invalidated → passthrough. Mixed-generation stitching is structurally impossible. Weak/missing ETags bypass. A cached chunk whose length disagrees with the object meta is poison — evicted and re-fetched (or, on a range-sliced 206, detected via theContent-Rangetotal).x-cache. A HIT is header-identical to the direct path.cache.puts ridectx.waitUntiland never block or fail the response.Observability & rollout
x-cache: HIT | MISS | BYPASS+x-cache-chunkson responses. Single-chunk reads report exacthits/total(e.g.1/1); multi-chunk streamed reads reportstream/Nandx-cache: MISS— headers must flush before the chunks resolve, so per-chunk hit precision isn't available there (a conservative floor; the majority small footer/tile reads are single-chunk and reported exactly). Streamed 206s useTransfer-Encoding: chunked(noContent-Length;Content-Rangeis present).blob10 = cache_status, normalized to the exactHIT/MISS/BYPASStokens so a CDN-fronted backend's ownx-cachecan't pollute the taxonomy.CHUNK_CACHE_ENABLEDgates everything: staging + PR previews on, prod off. Flip the prod var after a staging soak; kill switch = flip it back.Performance characteristics
Chunk caching trades bandwidth (over-fetching whole chunks on a cold miss — deliberate prefetch) for reuse (origin offload on repeated reads). This revision (
2559768) keeps that gain while removing the two losses the first cut measured — warm read amplification and large-range TTFB — by range-slicing hits and streaming multi-chunk assembly, and by caching only ranged reads. A/B vs the staging baseline (no chunk cache) on a public 133 MB GeoParquet, warm medians (proxy-perf/scripts/staging_cache_scenarios.py):The wins: origin offload holds (a HIT skips the S3 fetch entirely — worker-side
server-timing: backendcollapses from ~130 ms cold to single digits warm), and the two latency-bound scenarios that matter for cloud-native ranged access — warm TTFB and the real windowed read — are now a clear win and parity respectively, where the first cut was even/slower. The Cache API entry is shared across every request at a PoP, so hot ranges (parquet footer, COG header, popular tiles) are edge-served for every client after the first.The remaining weak spot: large-range throughput stays a touch below origin (~0.7–0.8×) — the cache adds a re-serve hop that a straight origin stream doesn't, and streaming fixes first-byte latency but not sustained MB/s on a one-shot large read. This is the least relevant axis for the target workload (latency-bound ranged reads), and true bulk transfers (full-object,
bytes=0-) now bypass entirely, so they keep origin throughput.Verification
tests/chunk_cache.rs, incl.bytes=0-→From(0)which the bypass keys on); analytics blob10 + token-normalization tests (tests/analytics.rs).tests/test_integration.py), running in CI withCHUNK_CACHE_ENABLED=true, including the four new behaviors: full-object GET bypasses,bytes=0-bypasses, a multi-chunk range streams byte-identically to the same slice fetched directly, and overlapping suffix ranges (bytes=-100thenbytes=-8) resolve from the same cached tail chunk with a warm HIT (footer reuse).wrangler dev(miniflare's real Cache API) and on the live PR preview:MISS, warm re-read →HITwithbackend;durin single digits (origin skipped)x-cache-chunks: 1/1; 8 MiB multi-chunk read reportsstream/2withTransfer-Encoding: chunkedand a correctContent-Range, assembling byte-identicallybytes=0-→BYPASS; suffix-range footer reuse confirmed; past-EOF →BYPASSto origin416; conditionalIf-None-Match→ 304 via direct pathOpen follow-ups (from the issue)
🤖 Generated with Claude Code