Skip to content

Cache DNS section parsing instead of re-serializing per access - #120

Merged
EONRaider merged 1 commit into
masterfrom
claude/decoder-depth-polish-8m54to
Sep 1, 2026
Merged

Cache DNS section parsing instead of re-serializing per access#120
EONRaider merged 1 commit into
masterfrom
claude/decoder-depth-polish-8m54to

Conversation

@EONRaider

Copy link
Copy Markdown
Owner

Summary

layer7/dns.py called bytes(self) — a full re-serialization of the whole message — from six sites, because compression offsets are message-relative while the parser only held sections. One .answers access rebuilt the message 14 times. And answers / authorities / additionals each re-parsed all three sections, so reading all three parsed the message three times over.

Both of the changes the issue asks for, in order:

  1. No more round-tripping. The parsers are module-level functions working directly from the sections bytes the instance already holds, in section-relative offsets. Only a compression pointer needs translating (subtract the 12-byte header length) — and a pointer addressing the header itself now lands below zero and raises InvalidFieldError instead of decoding header bytes as labels. bytes(self) appears in no accessor.
  2. One parse for three sections, via a module-level lru_cache keyed by the immutable section bytes plus the counts.

The frozen-instance question

Nothing is stored on the instance: no object.__setattr__, no extra field, no cached_property (which slots=True rules out anyway). Hashability and equality are untouched, bytes(decode(x)) == x still holds, and the memo is bounded at 128 messages. Because it is keyed by value, two equal messages share one parse — records are frozen dataclasses, so sharing them between callers cannot leak mutation, which the tests assert directly.

Measured

Corpus DNS response, 20k iterations, CPython 3.12 on this machine:

beforeafter
one uncached parse17.03 µs12.17 µs1.4× (re-serialization alone)
.answers16.84 µs0.20 µs
.answers + .authorities + .additionals52.08 µs0.54 µsratio 2.9× → 1.03×

Corpus throughput is unchanged (~115k f/s) because the chain walk never touches record accessors — this is a win for callers that read records, which is what the accessors are for.

Verification

  • Reading all three sections parses once — asserted via cache_info() (1 miss, 2 hits)
  • bytes(self) is not called from any accessor — no occurrences remain in the module
  • Round-trip and DNS corpus assertions unchanged
  • uv run ruff check / ruff format --check / mypy clean; uv run pytest — 757 passed

Notes

One deliberate behaviour change worth a reviewer's eye: a compression pointer into the fixed header previously produced garbage labels and now raises InvalidFieldError. It is unreachable in well-formed messages, the fuzz contract (ProtocolError or a value, never anything else) is preserved, and there is a test for it.

Closes#85.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt


Generated by Claude Code

layer7/dns.py called bytes(self) from six sites — each a full
re-serialization of the whole message — because compression offsets are
message-relative while the parser only held the sections. A single
.answers access rebuilt the message 14 times. Worse, answers,
authorities and additionals each called _resource_records(), which
parses all three sections, so reading all three parsed the message
three times over.
Two changes, matching the two the issue asks for:
1. The parsers move to module level and work from the sections bytes
directly, in section-relative offsets. Only a compression pointer
needs translating, by subtracting the 12-byte header length; a
pointer that addresses the header itself now lands below zero and
raises InvalidFieldError rather than decoding header bytes as
labels. bytes(self) is gone from every accessor.
2. The three sections are parsed in one pass by a module-level
lru_cache keyed by the (immutable) section bytes and the counts, so
reading all three parses once. Nothing is stored on the frozen
instance, so hashability and equality are untouched, and the cache
is bounded at 128 messages. Records are frozen dataclasses, so
sharing them between callers cannot leak mutation — asserted.
Measured on a corpus DNS response (20k iterations):
one uncached parse 17.03 -> 12.17 us (1.4x, re-serialization)
.answers 16.84 -> 0.20 us
.answers+auth+addl 52.08 -> 0.54 us (2.9x ratio -> 1.03x)
The corpus walk does not touch record accessors, so end-to-end
throughput is unchanged at ~115k frames/sec; this is a win for callers
that read records, which is the point of the accessors.
Closes#85.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt
@EONRaider
EONRaider merged commit 2f25d3e into masterSep 1, 2026
6 checks passed
Sign up for freeto 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.

Cache DNS section parsing instead of re-serializing per access

2 participants

@EONRaider@claude