Files
fancy-dns/internal/engine/flight.go
T
matsandClaude Sonnet 5.5 dd1117fd91 Coalesce identical misses, refuse non-QUERY opcodes, refresh hot entries early, check the answered question
Concurrent cache misses for one name each went upstream. After a restart, a
cache flush or a cluster-object change that is a burst of identical queries
exactly when the cache is empty. Identical exchanges (same name, type, class,
DO bit, pinned upstream set and forwarded client subnet) now share one: the
first query runs it on its own goroutine and context, the rest wait and get
private copies, and the leader alone writes the cache. If the leader's client
leaves, waiters take over instead of failing with its cancellation. Shared
answers are counted in fancydns_upstream_coalesced_total.

validate() accepted OpcodeUpdate, so a dynamic update was forwarded upstream as
an ordinary lookup, and every other opcode got FORMERR. A recursive resolver
implements QUERY only: anything else is now NOTIMP (RFC 1035 4.1.1), checked
before the question count since STATUS carries no question.

Prefetch only fired when a stale entry was served, so the first client after a
hot name expired got a TTL-1 stale answer. A fresh entry is now refreshed in
the background when it has been served at least twice and under a tenth of its
lifetime remains; the hit counter restarts with each stored entry, so a name
nobody comes back for stops being refreshed. Counted in
fancydns_cache_prefetch_total.

miekg/dns matches only the message ID of an upstream reply, so a forged reply
with the right ID for a different question was accepted and cached under the
name that was asked. exchangeOne now requires the reply to carry the question
that was sent (an empty section only for FORMERR, SERVFAIL, NOTIMP and
REFUSED). A mismatch fails the attempt without counting against the upstream,
so a spoofer cannot trip the circuit breaker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 08:58:56 +02:00

84 lines
2.4 KiB
Go

package engine
import (
"context"
"sync"
"github.com/miekg/dns"
)
// flightKey identifies exchanges that would produce the same upstream answer:
// the cache key, the upstream set an ACL group pinned, and the client subnet
// that was forwarded (an ECS-aware upstream answers by subnet).
type flightKey struct {
name, ups, ecs string
qtype, qclass uint16
do bool
}
// flight is one upstream exchange other queries may wait on.
type flight struct {
done chan struct{}
up *dns.Msg
addr string
err error
// leaderGaveUp is true when the leader's own context ended the exchange.
// That says nothing about the upstream, so waiters try again themselves.
leaderGaveUp bool
}
// flightGroup coalesces identical concurrent upstream exchanges, so a burst of
// cache misses for one name (a restart, a cache flush, a viral link) costs the
// upstream one query rather than one per client.
//
// The leader runs the exchange on its own goroutine and its own context;
// there is no detached work, so a shutdown that waits for in-flight queries
// still waits for every exchange.
type flightGroup struct {
mu sync.Mutex
m map[flightKey]*flight
}
// do runs fn once per key among concurrent callers. shared is true for a
// caller that received another caller's answer; its message is a private copy.
func (g *flightGroup) do(ctx context.Context, key flightKey, fn func() (*dns.Msg, string, error)) (up *dns.Msg, addr string, shared bool, err error) {
for {
g.mu.Lock()
if g.m == nil {
g.m = make(map[flightKey]*flight)
}
if f, ok := g.m[key]; ok {
g.mu.Unlock()
select {
case <-f.done:
case <-ctx.Done():
return nil, "", true, ctx.Err()
}
if f.leaderGaveUp {
continue // the leader's client left; take over
}
if f.err != nil {
return nil, f.addr, true, f.err
}
return f.up.Copy(), f.addr, true, nil
}
f := &flight{done: make(chan struct{})}
g.m[key] = f
g.mu.Unlock()
f.up, f.addr, f.err = fn()
f.leaderGaveUp = f.err != nil && ctx.Err() != nil
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
close(f.done)
// The leader keeps the original; waiters get copies. f.up is read by
// waiters only after done is closed, and the leader's later changes to
// its message must not reach them, so hand the leader a copy instead.
if f.err == nil && f.up != nil {
return f.up.Copy(), f.addr, false, nil
}
return f.up, f.addr, false, f.err
}
}