Skip to content

perf(cache): rewrite TTLs at known offsets instead of caching a second copy - #254

Merged
henry40408 merged 1 commit into
mainfrom
refactor/cache-ttl-offsets
Aug 28, 2026
Merged

perf(cache): rewrite TTLs at known offsets instead of caching a second copy#254
henry40408 merged 1 commit into
mainfrom
refactor/cache-ttl-offsets

Conversation

@henry40408

Copy link
Copy Markdown
Owner

Follow-up to #252, and the last technique from Cloudflare's DNS cache memory write-up that applies here: store offsets, not a duplicate.

The problem

Serving a cache hit meant decrementing every TTL by however long the entry had been cached, done by parsing the message and re-encoding it. That cost ~30–50µs, so the result was cached on the entry for the rest of that integer second — a second full copy of the response, behind a Mutex, for every hot entry.

But the stored bytes and the served bytes differ by a four-byte field per record plus the two-byte transaction ID. Caching a whole response to avoid recomputing a dozen bytes is the trade this removes.

The change

src/dns/ttl.rs walks the wire format once, at insert, and records where the TTL fields are (ttl_offsets). A hit is then a copy plus a handful of writes at known offsets (apply_elapsed). No parse, no re-encode, no snapshot, no lock.

Serving also stops perturbing the response: the client gets the upstream's own bytes with adjusted TTLs, where a re-encode rewrote name compression and record order to hickory's conventions.

The sharp edge

⚠️ Two record types carry something other than a TTL in the TTL field. OPT's four bytes are the extended RCODE, the EDNS version and the DO flag (RFC 6891 §6.1.3) — decrementing them corrupts DNSSEC signalling while still parsing cleanly. TSIG's must be transmitted as 0 (RFC 8945 §4.2).

The parse-based path got this for free, because hickory keeps OPT in Message::edns and TSIG in Message::signature, outside the three record sections. A wire walk has to know. tests/ttl_test.rs (12 cases) covers both, plus compression pointers, a CNAME chain, records after a 255-byte TXT, an unknown record type with opaque RDATA, and six malformed messages that must serve unchanged rather than panic.

Numbers

10,000 entries, 106-byte average wire payload:

#252 this
cold entry 504.6 B 498.2 B
added by serving 106.2 B 0.2 B
served entry 610.8 B 498.5 B (-18.4%)
overhead vs wire 5.76x 4.70x

Cache-hit throughput, five cache_hit_bench runs each on the same machine:

runs (qps) median
before 2.61M, 3.17M, 3.28M, 4.01M, 4.08M 3.28M
after 4.08M, 4.16M, 4.31M, 4.60M, 4.97M 4.31M (+31%)

This benchmark is noisy — hence five runs rather than three — but the two ranges only touch at one endpoint. The gain is the parse being gone rather than amortised.

Notes

  • decrement_ttl moved from handler.rs to the new module and is now a thin wrapper over scan-then-apply, for callers patching bytes a single time. Its three existing tests moved with it.
  • CacheValue::try_patched_bytes / store_patched_bytes and PatchSnapshot are gone, along with parking_lot::Mutex from cache.rs.
  • The memory bench's serving phase now measures that serving retains nothing, which is what would regress if a snapshot ever came back.

🤖 Generated with Claude Code

…d copy

Serving a cache hit meant decrementing every TTL by however long the entry
had been cached, done by parsing the message and re-encoding it. The result
was then stored on the entry for the rest of that integer second — a second
full copy of the response, behind a mutex, for every hot entry.

What actually differs between the stored bytes and the served ones is a
four-byte field per record plus the two-byte transaction ID. `src/dns/ttl.rs`
walks the wire format once at insert and records where those fields are; a
hit is then a copy plus a handful of writes at known offsets. No parse, no
re-encode, no snapshot, no lock — and the client gets the upstream's own
bytes with adjusted TTLs rather than a re-encoding, so name compression and
record order survive as the upstream wrote them.

Two record types carry something other than a TTL in that field and the walk
skips both: OPT, whose four bytes are the extended RCODE, EDNS version and DO
flag (RFC 6891 §6.1.3), and TSIG, whose TTL must be transmitted as 0
(RFC 8945 §4.2). The parse-based path got this for free because hickory keeps
both out of the three record sections; the walk has to know. tests/ttl_test.rs
covers them plus compression pointers, unknown record types, and the
malformed messages that must serve unchanged rather than panic.

Measured on 10,000 entries against a 106-byte average payload: a served entry
goes from 611 to 499 bytes, and serving adds 0.2 bytes per entry rather than
106. Cache-hit throughput rose with it — median 3.28M to 4.31M qps over five
cache_hit_bench runs each (before 2.61-4.08M, after 4.08-4.97M) — because the
parse the snapshot existed to amortise is gone rather than cached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90110% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.92%. Comparing base (e19a18e) to head (8328a89).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/dns/ttl.rs 98.43% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #254      +/-   ##
==========================================
+ Coverage   90.90%   90.92%   +0.02%     
==========================================
  Files          29       30       +1     
  Lines       10738    10763      +25     
==========================================
+ Hits         9761     9786      +25     
  Misses        977      977              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@henry40408
henry40408 marked this pull request as ready for review August 28, 2026 13:58
@henry40408
henry40408 merged commit d2769a8 into main Aug 28, 2026
7 checks passed
@henry40408
henry40408 deleted the refactor/cache-ttl-offsets branch August 28, 2026 13:59
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