Skip to content

Stop the query path doing work it does not need to - #658

Merged
folbricht merged 1 commit into
masterfrom
query-path-allocations
Sep 27, 2026
Merged

folbricht merged 1 commit into
masterfrom
query-path-allocations

Conversation

@folbricht

Copy link
Copy Markdown
Owner

Three things a query paid for on the way through, none of which it used.

A log line that is dropped still built its attributes

Passing them to With appends to a slice before anything checks the level, so a suppressed Debug cost an allocation and 185 ns rather than 70 and none. On the package logger, where With clones the handler, it cost six allocations and 575 ns rather than 11 and none.

Thirty of those sat on the query path, one or more per element, so a chain of five paid for five records nobody was going to read. Where the logger With returns is used once, the attributes go to the call instead. The six places that keep the logger and log through it again are unchanged.

A route ran four regular expressions that were never configured

An unset criterion compiled to the empty expression, which matches everything and still costs as much to run as one that does something: 159 ns each, 561 ns for a route with no criteria at all, on every route a query is offered to until one matches. A criterion that was not given is not compiled, and a route with none now decides in 13 ns.

Five smaller ones, all per query or per record

  • The rate limiter built a mask and formatted the masked address into a string for every query, to look up a counter. The masks are built once and the key is an array, which a map takes without allocating.
  • The DoH client expanded its URL template for every POST. A POST carries the query in its body, so the template takes no values and expands to the same URL every time. It is expanded once.
  • The response name blocklist built a message per record in a response, to hand a name to a matcher that takes a query. One message carries each name in turn.
  • The IP blocklist allocated a match to describe a hit for queries that missed, once per address in a response. It returns nothing on a miss, as the name databases already did.
  • Filtering a response built a new record list even when it dropped nothing, which is almost every response. The list is built once a record is dropped.

Measured

per call before after
route with no criteria 319 ns 13 ns
rate limiter Resolve 389 ns, 48 B, 3 allocs 253 ns, 0 B, 0 allocs
IP filter, nothing dropped 491 ns, 208 B, 5 allocs 244 ns, 0 B, 0 allocs
response name check 368 ns, 170 B, 3 allocs 341 ns, 168 B, 2 allocs

Tests

The rate limiter and the response IP blocklist had none. Both have them now: the first over who shares an allowance with whom under either prefix and over a query with no source address at all, the second over a record dropped first, last, in the middle, all of them, and none of them, since that is where building the list lazily could go wrong.

isDefault and String also read the name expression and had to stop assuming there is one, which the existing router tests caught.

Three things a query paid for on the way through, none of which it used.

A log line that is dropped still built its attributes. Passing them to With
appends to a slice before anything checks the level, so a suppressed Debug cost
an allocation and 185 ns rather than 70 and none; on the package logger, where
With clones the handler, it cost six allocations and 575 ns rather than 11 and
none. Thirty of those sat on the query path, one or more per element, so a
chain of five paid for five records nobody was going to read. Where the logger
With returns is used once, the attributes go to the call instead. The six
places that keep the logger and log through it again are unchanged.

A route ran four regular expressions that were never configured. An unset
criterion compiled to the empty expression, which matches everything and still
costs as much to run as one that does something: 159 ns each, 561 ns for a
route with no criteria at all, on every route a query is offered to until one
matches. A criterion that was not given is not compiled, and a route with none
now decides in 13 ns.

Five smaller ones, all of them per query or per record:

  the rate limiter built a mask and formatted the masked address into a string
  for every query, to look up a counter. The masks are built once and the key
  is an array, which a map takes without allocating.

  the DoH client expanded its URL template for every POST. A POST carries the
  query in its body, so the template takes no values and expands to the same
  URL every time. It is expanded once.

  the response name blocklist built a message per record in a response, to hand
  a name to a matcher that takes a query. One message carries each name in turn.

  the IP blocklist allocated a match to describe a hit for queries that missed,
  once per address in a response. It returns nothing on a miss, as the name
  databases already did.

  filtering a response built a new record list even when it dropped nothing,
  which is almost every response. The list is built once a record is dropped.

Measured per call on the paths that changed:

  route with no criteria      319 -> 13 ns
  rate limiter Resolve        389 -> 253 ns, 3 -> 0 allocations
  IP filter, nothing dropped  491 -> 244 ns, 5 -> 0 allocations
  response name check         368 -> 341 ns, 3 -> 2 allocations

The rate limiter and the response IP blocklist had no tests. Both have them
now, the first over who shares an allowance with whom under either prefix and
over a query with no source address at all, the second over a record dropped
first, last, in the middle, all of them, and none of them, since that is where
building the list lazily could go wrong. isDefault and String also read the
name expression and had to stop assuming there is one.
@folbricht
folbricht merged commit 204f412 into master Sep 27, 2026
16 checks passed
@folbricht
folbricht deleted the query-path-allocations branch September 27, 2026 15:40
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