Files
fancy-dns/internal/cache/manager.go
T
matsandClaude Sonnet 5.5 c6c5ca4ff7 Make the resolver pure DNS: JSON access log on stdout instead of a log store
The resolver kept its own query-log store (a bbolt ring, a live ring with SSE
fanout, rolling per-domain counters and dashboard time-series) for the console.
In a multi-node deployment each pod held a different slice of that, so every
view disagreed with itself, and the cost sat on the query path. Charts belong
in Grafana and log search in the log collector, so the store is gone.

internal/accesslog writes one JSON line per notable query to stdout. The default
is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped,
refused); log_cached and sample_rate add ordinary answers for a debugging
session. Lines go through a bounded buffer to a single writer goroutine and are
dropped, and counted in fancydns_access_log_dropped_total, when the stream
cannot keep up, so a stalled log pipe can never stall a query. The config key is
access_log (the old qlog block is ignored, like any unknown key).

The metrics gain what the dashboard used to compute: fancydns_group_queries_total
by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the
store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear,
/api/cache/top, the series rings, and the cache's per-name hit table, which took
a mutex and built a string on every cache hit.

Tests that read the old log now read the access-log lines; the contract test
drops the removed endpoints. The web console still calls them and is changed in
the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:53:04 +02:00

530 lines
14 KiB
Go

package cache
import (
"math"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
)
var (
errKeyTooLong = errString("cache: name too long to serialise")
errBadKey = errString("cache: corrupt persisted key")
)
type errString string
func (e errString) Error() string { return string(e) }
// Options configures a Manager.
type Options struct {
// Size is the global entry budget for the in-memory LRU.
Size int
// MaxBytes bounds the same LRU by accounted bytes. The entry budget alone
// does not bound the footprint: one cached ANY or TXT answer can be tens
// of kilobytes, so 500k entries is anywhere from 150MB to several GB
// depending entirely on what the traffic asked for. 0 means unbounded.
MaxBytes int64
// Shards is the LRU shard count (rounded to a power of two).
Shards int
// MinTTL and MaxTTL clamp the TTLs taken from upstream answers.
MinTTL time.Duration
MaxTTL time.Duration
// NegativeTTL bounds cached NXDOMAIN/NODATA answers.
NegativeTTL time.Duration
// ServeStale keeps expired entries answerable for this long.
ServeStale time.Duration
// Prefetch refreshes stale entries in the background when a hook is set.
Prefetch bool
// Now is injectable for tests.
Now func() time.Time
}
// Manager is the cache front door: TTL policy, serve-stale, prefetching and
// the persisted snapshot all live here, so the DNS handler stays a straight
// pipeline.
type Manager struct {
lru *LRU
opt Options
now func() time.Time
// set holds a snapshot-aware view: the LRU plus the persisted store.
store *Store
// prefetchFn is called (off the hot path) when a stale entry is served.
// It is set once at wiring time via SetPrefetcher.
prefetchFn atomic.Pointer[prefetcher]
// Blocked counts blocklist hits served from the matcher, kept here so the
// dashboard can show block volume without a second counter package.
blockedSince atomic.Uint64
mu sync.Mutex
flushing bool
}
type prefetcher func(Key)
// NewManager builds a Manager. store may be nil (persistence disabled).
func NewManager(opt Options, store *Store) *Manager {
if opt.Now == nil {
opt.Now = time.Now
}
if opt.Size <= 0 {
opt.Size = 200000
}
m := &Manager{
lru: NewLRU(opt.Size, opt.Shards),
opt: opt,
now: opt.Now,
store: store,
}
// Applied after construction rather than through NewLRU so the LRU keeps
// its two-argument signature; a 0 budget means unbounded.
m.lru.SetMaxBytes(opt.MaxBytes)
return m
}
// SetPrefetcher installs the callback invoked when a stale answer is served.
func (m *Manager) SetPrefetcher(fn func(Key)) {
if fn == nil {
m.prefetchFn.Store(nil)
return
}
m.prefetchFn.Store((*prefetcher)(&fn))
}
// LRU exposes the underlying LRU for stats and tests.
func (m *Manager) LRU() *LRU { return m.lru }
// Now returns the manager's clock.
func (m *Manager) Now() time.Time { return m.now() }
// Result describes a cache hit.
type Result struct {
// Kind is Fresh, Stale or Miss.
Kind HitKind
// Msg is a private unpacked copy for Fresh and Stale hits.
Msg *dns.Msg
// TTL is the TTL the answer was served with (already aged).
TTL uint32
// Origin and Upstream are carried through from the stored entry.
Origin string
Upstream string
// Age is how long ago the entry was stored.
Age time.Duration
// ServedStale is set when a stale entry was used; callers may want to
// report the degraded state.
ServedStale bool
}
// Get looks up a key. On a Fresh hit it returns a private, TTL-aged copy.
// On a Stale hit it returns the same plus kicks off a background refresh when
// prefetching is enabled.
func (m *Manager) Get(k Key) Result {
now := m.now()
e, kind := m.lru.Get(k, now)
if e == nil {
return Result{Kind: Miss}
}
msg, err := e.unpack(k, now)
if err != nil {
// A corrupt entry poisons the cache for this key; drop it.
m.lru.Delete(k)
return Result{Kind: Miss}
}
res := Result{
Kind: kind,
Msg: msg,
TTL: e.AgeTTL(now),
Origin: e.Origin,
Upstream: e.Upstream,
Age: now.Sub(e.CreatedAt),
ServedStale: kind == Stale,
}
if kind == Stale {
res.TTL = 0
}
if m.opt.Prefetch && (kind == Stale || m.nearExpiry(e, now)) {
if p := m.prefetchFn.Load(); p != nil && m.lru.TryPrefetch(k) {
fn := *p
metrics.RecordPrefetch()
go func() {
defer m.lru.ReleasePrefetch(k)
fn(k)
}()
}
}
return res
}
// Prefetch thresholds. An entry is refreshed ahead of its expiry when it is
// popular (it has been served at least prefetchMinHits times since it was
// stored) and has under a tenth of its lifetime left, so a hot name is replaced
// before a client ever sees it expire. The hit counter starts again with each
// stored entry, so a name that stops being asked for stops being refreshed
// after one more cycle.
const (
prefetchMinHits = 2
prefetchRemaining = 10 // refresh when remaining < lifetime/prefetchRemaining
prefetchMinLifetime = 10 * time.Second
)
func (m *Manager) nearExpiry(e *Entry, now time.Time) bool {
if e.Neg || e.Hits.Load() < prefetchMinHits {
return false
}
life := e.ExpiresAt.Sub(e.CreatedAt)
if life < prefetchMinLifetime {
return false
}
return e.ExpiresAt.Sub(now) < life/prefetchRemaining
}
// unpack rebuilds a private message from stored wire bytes and ages the TTLs
// down to what is left of the entry's lifetime.
func (e *Entry) unpack(k Key, now time.Time) (*dns.Msg, error) {
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
return nil, err
}
remaining := e.AgeTTL(now)
ageAll(msg, remaining)
// The stored question is the canonical one; trust the key, not the wire.
if len(msg.Question) == 0 || !equalName(msg.Question[0].Name, k.Name) {
msg.Question = []dns.Question{{
Name: dns.Fqdn(k.Name),
Qtype: k.Type,
Qclass: k.Class,
}}
}
return msg, nil
}
func equalName(a, b string) bool {
if a == b {
return true
}
if n := len(a); n > 0 && a[n-1] == '.' {
a = a[:n-1]
}
if n := len(b); n > 0 && b[n-1] == '.' {
b = b[:n-1]
}
return a == b
}
// ageAll sets the TTL of every record in msg to ttl.
func ageAll(msg *dns.Msg, ttl uint32) {
for i := range msg.Answer {
msg.Answer[i].Header().Ttl = ttl
}
for i := range msg.Ns {
msg.Ns[i].Header().Ttl = ttl
}
for i := range msg.Extra {
// OPT records carry no TTL semantics; leave the flags alone.
if _, ok := msg.Extra[i].(*dns.OPT); ok {
continue
}
msg.Extra[i].Header().Ttl = ttl
}
}
// PutResponse stores an upstream response under k, deriving the TTL from the
// answer itself and clamping it to the configured bounds.
//
// The message is packed once; the stored bytes are independent of the caller's
// copy, so the caller keeps ownership of its message.
func (m *Manager) PutResponse(k Key, msg *dns.Msg, upstream string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
// A zero TTL means "do not cache"; respect that rather than pinning an
// uncacheable answer in memory.
return
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, "upstream", upstream, neg)
}
// PutLocal stores a synthesised answer (local zone, blocklist response).
func (m *Manager) PutLocal(k Key, msg *dns.Msg, origin string) {
ttl, neg := m.TTLFor(msg)
if ttl <= 0 {
ttl = 60
}
wire, err := msg.Pack()
if err != nil {
return
}
m.putWire(k, wire, ttl, origin, "", neg)
}
// PutRaw stores pre-packed wire bytes with an explicit TTL. This is the path
// used when restoring a snapshot, where packing again would be wasted work.
func (m *Manager) PutRaw(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
m.putWire(k, wire, ttl, origin, upstream, neg)
}
func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream string, neg bool) {
if m == nil || len(wire) == 0 || ttl == 0 {
return
}
now := m.now()
buf := make([]byte, len(wire))
copy(buf, wire)
// Record the insert too, not just the hits: a name queried once must show
// up in "top cached names", and a miss-only workload has no hits to count.
// Key bytes, the wire body and the entry struct: account for all three so
// the LRU budget reflects real memory.
e := &Entry{
Wire: buf,
TTL: ttl,
Origin: origin,
Upstream: upstream,
CreatedAt: now,
ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + m.serveStaleWindow()),
Bytes: len(wire) + len(k.Name) + 96,
Neg: neg,
}
m.lru.Put(k, e)
}
func (m *Manager) serveStaleWindow() time.Duration {
if m.opt.ServeStale < 0 {
return 0
}
return m.opt.ServeStale
}
// TTLFor derives a cache TTL from a response, honouring the negative-cache
// policy. The second return reports whether the answer is a negative one
// (NXDOMAIN or NODATA), which the UI and the log show differently.
func (m *Manager) TTLFor(msg *dns.Msg) (ttl uint32, neg bool) {
if msg == nil {
return 0, false
}
if msg.Truncated {
// Never cache a truncated answer: the client will retry over TCP and
// we would serve it the truncated form forever.
return 0, false
}
switch msg.Rcode {
case dns.RcodeNameError:
return m.negativeTTL(msg), true
case dns.RcodeSuccess, dns.RcodeNotImplemented:
// fall through to the general case
default:
// SERVFAIL, REFUSED, NOTAUTH and friends are transient by nature.
return 0, true
}
if len(msg.Answer) == 0 {
// NODATA: the authority section's SOA carries the negative TTL.
return m.negativeTTL(msg), true
}
minTTL := uint32(math.MaxUint32)
found := false
for _, rr := range msg.Answer {
switch rr.(type) {
case *dns.OPT:
continue
}
if t := rr.Header().Ttl; t < minTTL {
minTTL = t
found = true
}
}
if !found {
return 0, true
}
return m.clamp(minTTL), false
}
// soaOf finds the zone's start-of-authority record, which is where a
// negative answer's TTL comes from (RFC 2308).
func soaOf(msg *dns.Msg) *dns.SOA {
for _, rr := range msg.Ns {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
for _, rr := range msg.Answer {
if soa, ok := rr.(*dns.SOA); ok {
return soa
}
}
return nil
}
func (m *Manager) negativeTTL(msg *dns.Msg) uint32 {
soa := soaOf(msg)
ttl := uint32(0)
if soa != nil {
ttl = soa.Hdr.Ttl
if soa.Minttl < ttl {
ttl = soa.Minttl
}
}
if ttl == 0 {
// No SOA to go on: fall back to the shortest TTL in the message, then
// to the configured default.
for _, rr := range msg.Ns {
if t := rr.Header().Ttl; t > 0 && t < ttl {
ttl = t
}
}
if ttl == 0 {
ttl = uint32(m.opt.NegativeTTL / time.Second)
}
}
return m.clamp(ttl)
}
func (m *Manager) clamp(ttl uint32) uint32 {
if min := uint32(m.opt.MinTTL / time.Second); min > 0 && ttl < min {
ttl = min
}
if max := uint32(m.opt.MaxTTL / time.Second); max > 0 && ttl > max {
ttl = max
}
return ttl
}
// Delete drops one key.
func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
// Flush empties the cache and, when persistence is enabled, removes the
// snapshot so a restart does not resurrect it.
func (m *Manager) Flush() {
m.lru.Flush()
if m.store != nil {
_ = m.store.Truncate()
}
}
// DeleteName drops every entry for a name, across all types. The blocklist API
// calls it when an operator edits rules, so a newly blocked name does not keep
// serving a cached good answer until it expires.
func (m *Manager) DeleteName(name string) int {
want := canonical(name)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool { return k.Name == want })
}
// DeleteSubtree drops every entry at or below a name suffix. Used when a
// whole blocklist is replaced, so stale good answers for newly-listed domains
// disappear immediately.
func (m *Manager) DeleteSubtree(suffix string) int {
want := canonical(suffix)
return m.lru.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == want || hasSuffix(k.Name, want)
})
}
func hasSuffix(name, suffix string) bool {
if len(name) < len(suffix)+1 {
return false
}
return name[len(name)-len(suffix)-1:] == "."+suffix
}
// Resize applies a new capacity, e.g. after a config reload.
func (m *Manager) Resize(size, shards int) {
m.opt.Size = size
m.lru.SetCapacity(size, shards)
}
// Options returns the manager's current options.
func (m *Manager) Options() Options { return m.opt }
// Stats is the JSON-friendly cache snapshot.
type Stats struct {
Enabled bool `json:"enabled"`
Size int `json:"size"`
Entries int `json:"entries"`
Shards int `json:"shards"`
MinTTLMS int64 `json:"min_ttl_ms"`
MaxTTLMS int64 `json:"max_ttl_ms"`
ServeStaleM int64 `json:"serve_stale_ms"`
NegativeMS int64 `json:"negative_ttl_ms"`
HitRate float64 `json:"hit_rate"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
StaleHits uint64 `json:"stale_hits"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expired uint64 `json:"expired"`
Bytes int64 `json:"bytes"`
MemoryHuman string `json:"bytes_human"`
Persist PersistSt `json:"persisted"`
}
// PersistSt describes the snapshot file.
type PersistSt struct {
Enabled bool `json:"enabled"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Entries int `json:"entries"`
LastSnapshot time.Time `json:"last_snapshot"`
Writes uint64 `json:"writes"`
Errors uint64 `json:"errors"`
Loaded bool `json:"loaded"`
}
// Stats snapshots the manager.
func (m *Manager) Stats() Stats {
s := m.lru.Stats()
st := Stats{
Enabled: true,
Size: m.opt.Size,
Entries: s.Entries,
Shards: s.Shards,
MinTTLMS: m.opt.MinTTL.Milliseconds(),
MaxTTLMS: m.opt.MaxTTL.Milliseconds(),
ServeStaleM: m.opt.ServeStale.Milliseconds(),
NegativeMS: m.opt.NegativeTTL.Milliseconds(),
HitRate: s.HitRate,
Hits: s.Hits,
Misses: s.Misses,
StaleHits: s.StaleHits,
Inserts: s.Inserts,
Evictions: s.Evictions,
Expired: s.Expired,
Bytes: s.Bytes,
}
st.MemoryHuman = HumanBytes(s.Bytes)
if m.store != nil {
st.Persist = m.store.Stats()
} else {
st.Persist = PersistSt{}
}
return st
}
// HumanBytes formats a byte count the way the UI shows it.
func HumanBytes(n int64) string {
neg := ""
if n < 0 {
neg, n = "-", -n
}
switch {
case n < 1024:
return neg + strconv.FormatInt(n, 10) + " B"
case n < 1024*1024:
return neg + strconv.FormatFloat(float64(n)/1024, 'f', 1, 64) + " KiB"
case n < 1024*1024*1024:
return neg + strconv.FormatFloat(float64(n)/(1024*1024), 'f', 1, 64) + " MiB"
default:
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
}
}