Skip to content

LAB-1765: turn cachekit compression on for /api/v2 cached bodies - #24

Merged
27Bslash6 merged 5 commits into
masterfrom
lab-1765-cachekit-compression
Aug 14, 2026
Merged

27Bslash6 merged 5 commits into
masterfrom
lab-1765-cachekit-compression

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes LAB-1765.

Turns cachekit's LZ4 ByteStorage envelope (wasm32 cachekit-core) on for the /api/v2 response cache, with the numbers that justify it and the fail-open path the review found on the way.

Measured

Production-shaped columnar /values bodies, 600 series, miniflare workerd, 10 rounds:

shape JSON stored off → on ratio set off → on get off → on
288 buckets — the default 24 h query 966 KiB 966 → 350 KiB 2.76x 7.3 → 17.3 ms 1.0 → 8.5 ms
500 buckets — 300000 rows, the MAX_LIMIT ceiling 1653 KiB 1653 → 606 KiB 2.73x 8.9 → 25.4 ms 1.2 → 13.7 ms

Decision: keep — and not because it's free. A hit costs ~8 ms more, ~14 ms at the ceiling, and that is CPU, not I/O. Cache API storage is free, so the currency is retention: entries are 0.3–1.6 MB, colo LRU evicts the big ones first, and 2.7x more resident entries is a direct hit-rate lever against a miss that costs a D1 aggregation over up to 300000 rows. The revert trigger is recorded in the code — wrangler tail showing hit-path CPU near the limit.

Bundle cost is zero: cachekit's Workers runtime imports the wasm module statically, so it already shipped. Verified byte-identical at 373.44 KiB / 113.30 KiB gz with the flag either way.

compression: true is written explicitly rather than left to cachekit's default ON — upstream is adding a per-backend compressionDefault and the Cache API backend will advertise FALSE, so relying on the default would silently un-compress this cache on a future upgrade.

Fail-open fix (found by the expert panel, pre-existing)

get<CachedResponse> is a blind cast, so any entry that decoded to something else was served as new Response(undefined) — an empty 200 stamped x-cache: HIT, cached client-side for the whole TTL, nothing logged. Not hypothetical: the ByteStorage envelope is itself valid positional MessagePack, so a compression-off reader decodes a k4 entry successfully and hands back the 4-tuple [compressed, checksum, size, 'msgpack'] as the value (cachekit-ts finding, LAB-1388 — the tolerant read path is merged upstream but unreleased as of 0.1.5).

isCachedResponse() turns any foreign shape into a logged miss. Everything else in this layer fails closed; this was the one path that didn't.

KEY_VERSION k3 → k4

The envelope reader can't decode a k3 entry, and createCache.minimal sets degradation: false, so the get throws — survivable (the handler catches, serves from D1, refills) but it would fire once per live key across the deploy. The bump buys the same refill without the error storm. A revert needs a bump too, for the misread reason above.

Tests

152 green, tsc clean. The 2 MiB 'x'.repeat round-trip is replaced with a ceiling-sized production-shaped body compared whole — the old one would have passed on a corrupted payload — plus a per-shape stored-size regression assertion and two mismatch tests.

Both guards were verified by breaking them: disabling the shape guard reds the foreign-entry test; reverting compression reds the pre-flip test.

The latency harness ran once for the numbers above and was then removed — miniflare has no production baseline to regress against, so it would have been 20 multi-MB round trips per CI run asserting nothing.

Expert panel

Ran at high stakes (bug-hunter-supreme, security-specialist, code-craftsman, catchphrase-agent). Applied: the fail-open CRIT (all four found it independently); a broken LCG in the body generator whose overflow collapsed the period to ~10k and inflated the measured ratio ~10% (numbers above are post-fix); a ratio assertion hoisted out of its loop so only one body shape was checked; a "measures the shipped config" claim the sizing test couldn't support (it measures the codec — the mismatch tests own the config); a 518400-row body the API can't emit; a duplicated serializer-cap literal; and a toEqual that would have diffed two multi-MB strings on failure.

Rejected: replacing the LCG generator with something simpler — 'x'.repeat under compression is a fake test. Security dismissed the decompression-bomb and CRIME/BREACH questions with a traced argument: .invalid is a reserved TLD so the cachekit key space can never collide with the zone's CDN entries, every envelope is written by this worker under the 64 MiB pre-compression cap, and the cached values are public AEMO data with no secret in the isolate to leak.

Summary by CodeRabbit

  • Bug Fixes

    • Improved cache reliability by rejecting invalid, outdated or incompatible stored entries.
    • Invalid cache entries are now treated as misses and refreshed automatically.
    • Added compression to reduce cache storage requirements while preserving response integrity.
    • Cache entries exceeding the 64 MiB size limit continue to be rejected safely.
    • Updated cache invalidation behaviour for the latest cache format.
  • Tests

    • Expanded coverage for large datasets, compressed entries, stale data and invalid cache responses.

The compression:false carve-out from LAB-768 was a ticket non-goal, not a
finding, and it stopped being defensible once the wasm32 cachekit-core
build was measured (LAB-431): the envelope's compress/decompress FFI
survives post-processing, and cachekit's Workers runtime imports the wasm
module statically, so it was already in the bundle. Verified: the deploy
is byte-identical at 373.44 KiB / 113.30 KiB gz with the flag either way.

Measured on production-shaped columnar /values bodies (600 series,
miniflare workerd, 10 rounds):

  288 buckets, the default 24 h query          966 KiB JSON
    stored  966 -> 350 KiB (2.76x)   set 7.3 -> 17.3 ms   get 1.0 ->  8.5 ms
  500 buckets, 300000 rows = MAX_LIMIT ceiling 1653 KiB JSON
    stored 1653 -> 606 KiB (2.73x)   set 8.9 -> 25.4 ms   get 1.2 -> 13.7 ms

Keep, and not because it is free: a hit costs ~8 ms more (~14 ms at the
ceiling) and that is CPU, not I/O. Cache API storage is free, so the
currency is retention -- entries are 0.3-1.6 MB, colo LRU evicts the big
ones first, and 2.7x more resident entries is a direct hit-rate lever
against a miss that costs a D1 aggregation over up to 300000 rows. Revert
trigger recorded in the code: wrangler tail showing hit-path CPU near the
limit.

compression is written explicitly rather than left to cachekit's default
ON, because upstream is adding a per-backend compressionDefault and the
Cache API backend will advertise FALSE -- relying on the default would
silently un-compress this cache on a future upgrade.

Fixes a pre-existing fail-open the expert panel found while reviewing the
flip: get<CachedResponse> is a blind cast, so any entry that decodes to
something else served `new Response(undefined)` -- an empty 200 stamped
`x-cache: HIT`, cached client-side for the whole TTL, nothing logged.
Reachable today, not hypothetically: the ByteStorage envelope is itself
valid positional MessagePack, so a compression-off reader decodes a k4
entry and hands back the 4-tuple as the value. isCachedResponse() turns
any foreign shape into a logged miss. Everything else in this layer fails
closed; this was the one path that did not.

KEY_VERSION k3 -> k4. The envelope reader cannot decode a k3 entry and
createCache.minimal sets degradation:false, so the get throws -- survivable
(handleApiCached catches, serves from D1, refills) but it would fire once
per live key across the deploy. The bump buys the same refill without the
error storm. A revert needs a bump too, for the misread reason above.

Tests: the 2 MiB 'x'.repeat round-trip is replaced with a ceiling-sized
production-shaped body compared whole (the old one would have passed on a
corrupted payload), plus a stored-size regression assertion per body shape
and two mismatch tests. Verified by breaking each one: disabling the shape
guard reds the foreign-entry test, reverting compression reds the pre-flip
test. The latency harness was run once for the numbers above and then
removed -- miniflare has no production baseline to regress against, so it
would have been 20 multi-MB round trips per CI run asserting nothing.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be6f053d-08b2-4c67-b903-b0021dbb058c

📥 Commits

Reviewing files that changed from the base of the PR and between 1b289bb and 0987b2d.

📒 Files selected for processing (2)
  • worker/src/cache.ts
  • worker/test/cache.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • worker/test/cache.spec.ts

📝 Walkthrough

Walkthrough

The cache now uses k5 compressed storage with shared 64 MiB serializer limits. Cache reads validate decoded response shapes. Tests cover large production-shaped payloads, compression ratios, stale plain entries, and foreign cache values.

Changes

Cache format and validation

Layer / File(s) Summary
Serializer and compression configuration
worker/src/cache.ts
The cache uses key version k5, LZ4 and xxHash3-64 compression, and shared encoded and decoded limits of 64 MiB.
Cached response validation
worker/src/cache.ts
isCachedResponse validates decoded entries. Invalid entries are logged and treated as cache misses.
Compression and cache compatibility tests
worker/src/cache.ts, worker/test/cache.spec.ts
Tests use production-shaped payloads, verify compression ratios, and confirm that stale plain entries and foreign decoded values are replaced after cache misses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 0987b

The change enables compression for cached API responses while adding validation and cache-version handling; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant cachekit
  participant isCachedResponse
  Worker->>cachekit: Read and decode cache entry
  cachekit-->>Worker: Return decoded value
  Worker->>isCachedResponse: Validate body and expires
  isCachedResponse-->>Worker: Return valid response or miss
  Worker->>cachekit: Store refilled response on miss
Loading

Possibly related PRs

  • 27b-io/nem.api#9: Introduced the cachekit configuration that this change extends with compression, serializer limits, and entry validation.

Poem

I’m a rabbit by the cache door,
Compressing bytes across the floor.
Bad shapes miss, good shapes stay,
Fresh responses fill the way.
k5 hops softly: hip-hip-hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes enabling cachekit compression for cached /api/v2 response bodies.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lab-1765-cachekit-compression

Comment @coderabbitai help to get the list of available commands.

@kodus-27b

This comment has been minimized.

Kody review (PR #24): the shape guard used 'as CachedResponse' internally —
an assertion inside the very function that exists to avoid one. TS 4.9 'in'
narrowing checks property presence AND type with no assertion; runtime
behaviour is identical (typeof undefined === 'undefined' made the old form
safe by accident, the new form is safe by construction) and the dedicated
foreign-entry test pins it. The spec's JSON.parse cast becomes a typed
declaration — the expects that follow are the runtime check there.
Comment thread worker/src/cache.ts Outdated
@kodus-27b

This comment has been minimized.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

@kody start-review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@worker/test/cache.spec.ts`:
- Around line 313-324: Remove the duplicate const hit declaration in the
ceiling-sized round-trip test, keeping a single declaration in the same scope so
the test compiles without block-scoped variable redeclaration errors.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8d65942-cce0-4516-9f96-0faeefc6f876

📥 Commits

Reviewing files that changed from the base of the PR and between 769096e and 97e2736.

📒 Files selected for processing (2)
  • worker/src/cache.ts
  • worker/test/cache.spec.ts

Comment thread worker/test/cache.spec.ts
@27Bslash6

Copy link
Copy Markdown
Contributor Author

Resolved merge conflict in worker/test/cache.spec.ts (import-list union: both sides added one import); auto-rebased onto master via merge commit; CI will re-run.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@27Bslash6

Copy link
Copy Markdown
Contributor Author

Resolved worker/src/cache.ts — both sides had bumped KEY_VERSION to k4 (master's LAB-1702 emissions_factor vs this PR's compression); master's deployed k4 keeps the number and the compression bump is renumbered to k5 so the compressed reader never shares a version with live uncompressed entries. Auto-rebased onto master; CI will re-run. Verified locally: tsc clean on both configs, 220/220 tests green.

Ray asked whether the reported +8-14 ms lands in a user hot path. Checking
rather than arguing turned up that the benchmark answered a question nobody
asked: those numbers come from /api/v2/values, and public/app.js never
fetches it. The dashboard fetches values/aggregate?group_by=fuel, /dispatch
and /intensity -- all bucket-aggregated, all ~20 KiB.

Measured end to end through worker.fetch at hours=24, compression off -> on:

  /values/aggregate?group_by=fuel    21 KiB   hit 0.6 -> 1.1 ms
  /dispatch                          20 KiB   hit 0.5 -> 0.5 ms
  /intensity                         17 KiB   hit 0.4 -> 0.5 ms
  /values (API drill-down only)     501 KiB   hit 1.6 -> 3.8 ms

A page load pays ~+0.6 ms across all three fetches. The +8-14 ms figure is
real but belongs to /values at the 300000-row ceiling, which is a scripted
API consumer's request, not a rendered page.

That also makes the keep argument a better one than the one committed
earlier: the entries big enough for compression to matter are the same ones
that pay the few milliseconds, and the same ones whose miss costs a D1
aggregation over up to 300000 rows. Cost and benefit land on the same
endpoint instead of being traded across them.

No behaviour change -- the comment was understating a cost on one endpoint
and overstating it for every other, and it is the operational record for
the next person deciding this.
@kodus-27b

kodus-27b Bot commented Aug 14, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@27Bslash6
27Bslash6 merged commit 8284e3e into master Aug 14, 2026
3 checks passed
@27b-io 27b-io deleted a comment from kodus-27b Bot Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant