perf(cache): rewrite TTLs at known offsets instead of caching a second copy - #254
Merged
Conversation
…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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
henry40408
marked this pull request as ready for review
August 28, 2026 13:58
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rswalks 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
The parse-based path got this for free, because hickory keeps OPT in
Message::ednsand TSIG inMessage::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:
Cache-hit throughput, five
cache_hit_benchruns each on the same machine: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_ttlmoved fromhandler.rsto 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_bytesandPatchSnapshotare gone, along withparking_lot::Mutexfromcache.rs.🤖 Generated with Claude Code