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

+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))
}