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>
This commit is contained in:
1 parent
dd1117fd91
commit
c6c5ca4ff7
31 files changed
+818
-3389
No files matched your search
Vendored
-35
@@ -554,41 +554,6 @@ func TestManagerDeleteNameAndSubtree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
|
||||
for range 5 {
|
||||
m.PutResponse(key("hot.example.com"), answer(t, "hot.example.com", 300, "10.0.0.5"), "up")
|
||||
}
|
||||
for range 2 {
|
||||
m.PutResponse(key("warm.example.com"), answer(t, "warm.example.com", 300, "10.0.0.6"), "up")
|
||||
}
|
||||
top := m.TopEntries(10)
|
||||
if len(top) < 2 {
|
||||
t.Fatalf("TopEntries returned %d rows, want at least 2", len(top))
|
||||
}
|
||||
// Each Put counts too, so a name queried once still shows up.
|
||||
var hot, warm uint64
|
||||
for _, e := range top {
|
||||
switch e.Name {
|
||||
case "hot.example.com":
|
||||
hot = e.Hits
|
||||
case "warm.example.com":
|
||||
warm = e.Hits
|
||||
}
|
||||
}
|
||||
if hot < 5 || warm < 2 {
|
||||
t.Errorf("top hits: hot=%d warm=%d, want >=5 and >=2", hot, warm)
|
||||
}
|
||||
if top[0].Name != "hot.example.com" {
|
||||
t.Errorf("first row = %s, want the hottest name first", top[0].Name)
|
||||
}
|
||||
m.Flush()
|
||||
if got := len(m.TopEntries(10)); got != 0 {
|
||||
t.Errorf("TopEntries after Flush = %d rows, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerStatsShape(t *testing.T) {
|
||||
clk := newClock()
|
||||
m := NewManager(Options{Size: 100, Shards: 4, MaxTTL: time.Hour, MinTTL: time.Second, Now: clk.Now}, nil)
|
||||
|
||||
Vendored
-103
@@ -2,7 +2,6 @@ package cache
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -61,9 +60,6 @@ type Manager struct {
|
||||
// It is set once at wiring time via SetPrefetcher.
|
||||
prefetchFn atomic.Pointer[prefetcher]
|
||||
|
||||
// top holds a rolling per-domain hit counter for the "top cached" UI.
|
||||
top *topDomains
|
||||
|
||||
// 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
|
||||
@@ -87,7 +83,6 @@ func NewManager(opt Options, store *Store) *Manager {
|
||||
opt: opt,
|
||||
now: opt.Now,
|
||||
store: store,
|
||||
top: newTopDomains(5000),
|
||||
}
|
||||
// Applied after construction rather than through NewLRU so the LRU keeps
|
||||
// its two-argument signature; a 0 budget means unbounded.
|
||||
@@ -143,7 +138,6 @@ func (m *Manager) Get(k Key) Result {
|
||||
m.lru.Delete(k)
|
||||
return Result{Kind: Miss}
|
||||
}
|
||||
m.top.observe(k, e)
|
||||
res := Result{
|
||||
Kind: kind,
|
||||
Msg: msg,
|
||||
@@ -289,7 +283,6 @@ func (m *Manager) putWire(k Key, wire []byte, ttl uint32, origin, upstream strin
|
||||
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.
|
||||
m.top.observe(k, &Entry{TTL: ttl, CreatedAt: now})
|
||||
// Key bytes, the wire body and the entry struct: account for all three so
|
||||
// the LRU budget reflects real memory.
|
||||
e := &Entry{
|
||||
@@ -414,7 +407,6 @@ func (m *Manager) Delete(k Key) { m.lru.Delete(k) }
|
||||
// snapshot so a restart does not resurrect it.
|
||||
func (m *Manager) Flush() {
|
||||
m.lru.Flush()
|
||||
m.top.reset()
|
||||
if m.store != nil {
|
||||
_ = m.store.Truncate()
|
||||
}
|
||||
@@ -454,11 +446,6 @@ func (m *Manager) Resize(size, shards int) {
|
||||
// Options returns the manager's current options.
|
||||
func (m *Manager) Options() Options { return m.opt }
|
||||
|
||||
// TopEntries returns the hottest names in the cache for the UI.
|
||||
func (m *Manager) TopEntries(limit int) []TopEntry {
|
||||
return m.top.top(limit)
|
||||
}
|
||||
|
||||
// Stats is the JSON-friendly cache snapshot.
|
||||
type Stats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -540,93 +527,3 @@ func HumanBytes(n int64) string {
|
||||
return neg + strconv.FormatFloat(float64(n)/(1024*1024*1024), 'f', 2, 64) + " GiB"
|
||||
}
|
||||
}
|
||||
|
||||
// topDomains is a bounded, periodic top-N counter. It uses a compact
|
||||
// min-heap-free approach: a plain map plus periodic pruning, which is far
|
||||
// cheaper than maintaining a sorted structure on every query.
|
||||
type topDomains struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]*topCount
|
||||
limit int
|
||||
}
|
||||
|
||||
type topCount struct {
|
||||
name string
|
||||
qtype uint16
|
||||
hits uint64
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func newTopDomains(limit int) *topDomains {
|
||||
return &topDomains{counts: make(map[string]*topCount, 4096), limit: limit}
|
||||
}
|
||||
|
||||
func (t *topDomains) observe(k Key, e *Entry) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
id := k.Name + "|" + strconv.Itoa(int(k.Type))
|
||||
t.mu.Lock()
|
||||
c, ok := t.counts[id]
|
||||
if !ok {
|
||||
if len(t.counts) > t.limit*2 {
|
||||
t.pruneLocked()
|
||||
}
|
||||
c = &topCount{name: k.Name, qtype: k.Type}
|
||||
t.counts[id] = c
|
||||
}
|
||||
c.hits++
|
||||
c.bytes += int64(len(e.Wire))
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *topDomains) pruneLocked() {
|
||||
// Drop the bottom half so pruning is amortised.
|
||||
type kv struct {
|
||||
id string
|
||||
c *topCount
|
||||
}
|
||||
all := make([]kv, 0, len(t.counts))
|
||||
for id, c := range t.counts {
|
||||
all = append(all, kv{id, c})
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].c.hits < all[j].c.hits })
|
||||
keep := len(all) / 2
|
||||
for _, e := range all[keep:] {
|
||||
delete(t.counts, e.id)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *topDomains) reset() {
|
||||
t.mu.Lock()
|
||||
t.counts = make(map[string]*topCount, 4096)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// TopEntry is one row of the "top cached names" table.
|
||||
type TopEntry struct {
|
||||
Name string `json:"name"`
|
||||
Type uint16 `json:"qtype"`
|
||||
Hits uint64 `json:"hits"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func (t *topDomains) top(limit int) []TopEntry {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
t.mu.Lock()
|
||||
out := make([]TopEntry, 0, limit)
|
||||
for _, c := range t.counts {
|
||||
out = append(out, TopEntry{Name: c.name, Type: c.qtype, Hits: c.hits, Bytes: c.bytes})
|
||||
}
|
||||
t.mu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Hits > out[j].Hits })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user