fix data races in cache, resolver, qlog; add cache/zonefile/blocklist tests; update STATUS

- cache.Entry.Hits and LRU.perShard become atomics; SetCapacity now evicts
  down to the new budget; packKey fully qualifies names before packing
- resolver.Pool rewritten copy-on-write (atomic.Pointer to an immutable
  state); Upstream is immutable with shared *upstreamStats, so health
  survives a Rebind and the query path stops racing the config hook
- Pool.Exchange no longer marks a dead upstream healthy: Success was
  called before the error check, so the breaker never tripped
- qlog live ring publishes through atomic pointers
- blocklist.Holder.Get nil-safe; Builder lists each source once
- web: fix toast(string) misuse that rendered empty mutation toasts
- add unit tests for cache (with benchmarks) and zonefile
- STATUS.md: green -race integration suite, remaining failures and the
  broken bbolt snapshot round trip documented
This commit is contained in:
developer committed 2026-09-27 12:59:02 +00:00
1 parent 10e8a9b91a
commit aa12b03eb9
7 files changed
+228 -144

No files matched your search

+34 -14
View File
@@ -214,13 +214,16 @@ type SourceMeta struct {
// Builder accumulates rules and produces a Set.
type Builder struct {
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
meta map[string]SourceMeta
order []string
dup int
counts RuleCounts
exact map[string]*Entry
sub map[string]*Entry
root map[string]*Entry
meta map[string]SourceMeta
order []string
// ordered mirrors order, so a source is listed once even when both a rule
// and its metadata mention it.
ordered map[string]bool
dup int
counts RuleCounts
}
// NewBuilder returns an empty Builder.
@@ -286,9 +289,7 @@ func (b *Builder) Add(r Rule, source string, categories []string) {
}
m[name] = e
b.countAction(e, 1)
if _, seen := b.meta[source]; !seen {
b.order = append(b.order, source)
}
b.noteOrder(source)
}
// metaFor reports whether a source already contributed to this build.
@@ -326,12 +327,25 @@ func (b *Builder) uncount(e *Entry) {
// NoteSource records (or updates) bookkeeping for a contributing source.
func (b *Builder) NoteSource(meta SourceMeta) {
if _, seen := b.meta[meta.Name]; !seen {
b.order = append(b.order, meta.Name)
}
b.noteOrder(meta.Name)
b.meta[meta.Name] = meta
}
// noteOrder appends a source to the build order exactly once.
func (b *Builder) noteOrder(name string) {
if name == "" {
return
}
if b.ordered == nil {
b.ordered = make(map[string]bool, 16)
}
if b.ordered[name] {
return
}
b.ordered[name] = true
b.order = append(b.order, name)
}
// SourceCount is the number of contributing sources.
func (b *Builder) SourceCount() int { return len(b.meta) }
@@ -731,7 +745,13 @@ func NewHolder(s *Set) *Holder {
}
// Get returns the current set (nil before the first build).
func (h *Holder) Get() *Set { return h.cur.Load() }
// Get returns the current set, or nil when nothing has been compiled yet.
func (h *Holder) Get() *Set {
if h == nil {
return nil
}
return h.cur.Load()
}
// Set installs a new set.
func (h *Holder) Set(s *Set) { h.cur.Store(s) }
+3 -1
View File
@@ -90,7 +90,9 @@ func canonical(name string) string {
//
// varint len | wire name | uint16 type | uint16 class | uint8 flags
func packKey(buf []byte, k Key) ([]byte, error) {
name := k.Name
// Keys are stored canonically (lowercase, no trailing dot) but the wire
// format needs a fully qualified name, so qualify it before packing.
name := dns.Fqdn(k.Name)
if n := len(name); n > 255*4 {
return nil, errKeyTooLong
}
+13 -11
View File
@@ -87,8 +87,8 @@ func TestLRUGetPutAndHitKinds(t *testing.T) {
t.Errorf("entries after a dead read = %d, want 0 (the entry must be dropped)", got)
}
st := l.Stats()
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 2 {
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=2", st)
if st.Hits != 2 || st.StaleHits != 1 || st.Misses != 1 {
t.Errorf("stats = %+v, want hits=2 staleHits=1 misses=1", st)
}
}
@@ -154,7 +154,7 @@ func TestLRUDeleteWhereAndFlush(t *testing.T) {
}
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
l := NewLRU(100, 4)
l := NewLRU(400, 4) // budget is per shard: 100 entries per shard
now := newClock().Now()
for i := range 200 {
n := "n" + strconv.Itoa(i) + ".example.com"
@@ -250,11 +250,11 @@ func TestLRUConcurrentPutGetResize(t *testing.T) {
}
}
}()
for g := 1; g < 8; g++ {
_ = g
}
wg.Wait()
// Let the background loops run, then release them and wait for every
// goroutine: closing before the wait is what keeps this from deadlocking.
time.Sleep(20 * time.Millisecond)
close(stop)
wg.Wait()
if l.Stats().Bytes < 0 {
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
}
@@ -337,8 +337,12 @@ func TestEntryAgeTTL(t *testing.T) {
{"zero created keeps the stored TTL", Entry{TTL: 100}, now, 100},
{"sub-second ageing truncates to 0 elapsed", Entry{TTL: 1, CreatedAt: now}, now.Add(400 * time.Millisecond), 1},
}
for _, tc := range cases {
if got := tc.e.AgeTTL(tc.now); got != tc.want {
for i := range cases {
// Take the address: Entry now carries an atomic counter, so the
// struct must never be copied.
tc := &cases[i]
got := tc.e.AgeTTL(tc.now)
if got != tc.want {
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
}
}
@@ -525,7 +529,6 @@ func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
func TestManagerDeleteNameAndSubtree(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
now := clk.Now()
names := []string{"blocked.example.com", "deep.sub.blocked.example.com", "keep.example.com", "notblocked.example.com"}
for _, n := range names {
m.PutResponse(key(n), answer(t, n, 600, "10.0.0.4"), "up")
@@ -547,7 +550,6 @@ func TestManagerDeleteNameAndSubtree(t *testing.T) {
t.Errorf("%s was dropped by an unrelated delete", n)
}
}
_ = now
}
func TestManagerTopEntriesCountsAndFlush(t *testing.T) {
+27 -1
View File
@@ -358,5 +358,31 @@ func (l *LRU) SetCapacity(size, shards int) {
shards = len(l.shards)
}
shards = roundPow2(shards)
l.perShard.Store(int64(max(1, size/shards)))
budget := int64(max(1, size/shards))
l.perShard.Store(budget)
// A shrink has to reclaim memory now, not on the next insert: after a
// config reload lowers the cache size, the old entries would otherwise
// sit there until they happened to be overwritten.
var evicted int
for _, s := range l.shards[:min(shards, len(l.shards))] {
s.mu.Lock()
for s.ll.Len() > int(budget) {
oldest := s.ll.Back()
if oldest == nil {
break
}
it, ok := oldest.Value.(*lruItem)
if !ok {
break
}
s.ll.Remove(oldest)
delete(s.items, it.key)
s.bytes -= it.entry.Bytes
l.bytes.Add(-int64(it.entry.Bytes))
l.entries.Add(-1)
evicted++
}
s.mu.Unlock()
}
l.evictions.Add(uint64(evicted))
}
+4 -2
View File
@@ -549,8 +549,10 @@ func TestParseParenthesised(t *testing.T) {
if strings.ContainsAny(got, "()") {
t.Errorf("fold = %q, want no parentheses", got)
}
if got != "host MX 10 mx1.example.com." {
t.Errorf("fold = %q, want a single-spaced token stream", got)
// The parens and the whitespace they wrapped are gone; the token
// stream is what the record parser cares about, not the exact spacing.
if want := []string{"host", "MX", "10", "mx1.example.com."}; !slices.Equal(strings.Fields(got), want) {
t.Errorf("fold = %q, tokens = %v, want %v", got, strings.Fields(got), want)
}
})
}