Cover the engine, config.Size and metrics; fix the five bugs they found

internal/engine, internal/config and internal/metrics had no test file at all,
and writing them turned up five real bugs.

internal/engine (23 tests, new harness_test.go): a wired engine over a fake
upstream on loopback, a local zone, a blocklist, a cache, a query log and an
injectable clock. It pins the pipeline order end to end -- ACL deny before the
local zone, local zone before blocklist, blocklist before cache, drop sends
nothing, the concurrency and rate-limit refusals, serve-stale served with TTL 1
and refreshed once per key, truncation against a 512-byte client, and exactly
one metrics record and one query-log entry per query.

Five fixes:

- config.ParseSize ignored the unit multiplier, so 512MiB parsed as 512 bytes
  and the whole memory envelope was inert for anyone who wrote a unit.

- finalize (EDNS0 and the UDP size) ran only on the cache-hit path, so a large
  uncached answer left oversized for a 512-byte client. It now runs on the
  upstream, local-zone and blocklist paths too.

- The upstream UDP client read buffer was hard-coded to 1232 while we advertise
  dns.max_udp_size, so with a larger buffer configured every answer above 1232
  bytes was read truncated and became SERVFAIL. The pool takes the size now.

- LRU.prefetchGate was a 64-slot global cap, not the single flight its comment
  promised: a burst of stale hits launched up to 64 duplicate refreshes of one
  name. It is a per-key latch now; distinct names still refresh in parallel.

- metrics.RecordMiss and RecordStaleHit moved only the Prometheus collector, so
  the Cache page's misses and stale hits read 0 for the life of the process.

AGENTS.md gains the trap that cost the most time: this version of miekg/dns
reports a character-string over 255 bytes as zero length, so such a message
silently fails to pack, and the write failure looks like a client timeout.
This commit is contained in:
developer committed 2026-09-27 16:03:21 +00:00
1 parent 48d37db15b
commit 4f49033f1b
12 files changed
+1837 -37

No files matched your search

+28 -20
View File
@@ -46,9 +46,11 @@ type LRU struct {
seed maphash.Seed
// prefetch delays revalidation without blocking Get. It is a semaphore of
// "refreshes in flight" so a burst of stale hits triggers one fetch.
prefetchGate chan struct{}
// prefetch delays revalidation without blocking Get. It holds the keys
// with a refresh in flight, so a burst of stale hits for one name starts
// one fetch while unrelated names still refresh in parallel.
prefetchMu sync.Mutex
prefetching map[Key]struct{}
}
type shard struct {
@@ -88,10 +90,10 @@ func NewLRU(size, shards int) *LRU {
size = shards
}
l := &LRU{
shards: make([]*shard, shards),
mask: uint64(shards - 1),
seed: maphash.MakeSeed(),
prefetchGate: make(chan struct{}, 64),
shards: make([]*shard, shards),
mask: uint64(shards - 1),
seed: maphash.MakeSeed(),
prefetching: make(map[Key]struct{}),
}
l.perShard.Store(int64(max(1, size/shards)))
for i := range l.shards {
@@ -286,23 +288,29 @@ func (l *LRU) DeleteWhere(pred func(Key, *Entry) bool) int {
return removed
}
// TryPrefetch acquires a refresh slot without blocking. Callers use it to
// make sure a burst of stale hits triggers exactly one background refresh.
func (l *LRU) TryPrefetch() bool {
select {
case l.prefetchGate <- struct{}{}:
return true
default:
// TryPrefetch claims the refresh slot for one key without blocking. It
// reports false when a refresh for that key is already in flight, which is
// what makes a burst of stale hits for a hot name cost one upstream exchange
// instead of one per query.
//
// The latch is per key, not global: a single global slot would serialise
// recovery after an upstream outage, when thousands of unrelated stale names
// each want a refresh.
func (l *LRU) TryPrefetch(k Key) bool {
l.prefetchMu.Lock()
defer l.prefetchMu.Unlock()
if _, busy := l.prefetching[k]; busy {
return false
}
l.prefetching[k] = struct{}{}
return true
}
// ReleasePrefetch returns a slot taken by TryPrefetch.
func (l *LRU) ReleasePrefetch() {
select {
case <-l.prefetchGate:
default:
}
// ReleasePrefetch frees the slot taken by TryPrefetch for k.
func (l *LRU) ReleasePrefetch(k Key) {
l.prefetchMu.Lock()
delete(l.prefetching, k)
l.prefetchMu.Unlock()
}
// HitKind describes how a lookup was satisfied.
+2 -2
View File
@@ -153,10 +153,10 @@ func (m *Manager) Get(k Key) Result {
}
if kind == Stale {
res.TTL = 0
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch() {
if p := m.prefetchFn.Load(); p != nil && m.opt.Prefetch && m.lru.TryPrefetch(k) {
fn := *p
go func() {
defer m.lru.ReleasePrefetch()
defer m.lru.ReleasePrefetch(k)
fn(k)
}()
}