more code

This commit is contained in:
developer committed 2026-09-27 11:34:26 +00:00
1 parent f24fd63e9a
commit ee72257c64
29 files changed
+5636 -440

No files matched your search

+868
View File
@@ -0,0 +1,868 @@
package cache
import (
"errors"
"math/rand/v2"
"net"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"github.com/miekg/dns"
)
// fakeClock is a manually advanced clock so TTL, ageing and serve-stale
// behaviour is tested without sleeping.
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func newClock() *fakeClock { return &fakeClock{t: time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)} }
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// answer builds a packed A response for name.
func answer(t *testing.T, name string, ttl uint32, ip string) *dns.Msg {
t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
m.Response = true
m.Answer = []dns.RR{&dns.A{
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: net.ParseIP(ip).To4(),
}}
return m
}
func key(name string) Key { return QueryKey(name, dns.TypeA, dns.ClassINET, false, 0) }
func TestLRUGetPutAndHitKinds(t *testing.T) {
clk := newClock()
l := NewLRU(100, 4)
m := answer(t, "example.com", 60, "1.2.3.4")
wire, err := m.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
k := key("example.com")
now := clk.Now()
l.Put(k, &Entry{Wire: wire, TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: len(wire)})
if e, kind := l.Get(k, now); e == nil || kind != Fresh {
t.Fatalf("fresh hit: got entry=%v kind=%v, want non-nil/Fresh", e, kind)
}
// One second later the entry is still fresh; after the TTL it is stale but
// answerable until StaleUntil, and past that it is a miss and dropped.
clk.Advance(59 * time.Second)
if _, kind := l.Get(k, clk.Now()); kind != Fresh {
t.Errorf("at TTL-1s: kind = %v, want Fresh", kind)
}
clk.Advance(2 * time.Second)
e, kind := l.Get(k, clk.Now())
if e == nil || kind != Stale {
t.Fatalf("just past TTL: got entry=%v kind=%v, want non-nil/Stale", e, kind)
}
if got := e.Hits.Load(); got != 3 {
t.Errorf("entry Hits = %d, want 3 (every Get counts, fresh or stale)", got)
}
clk.Advance(31 * time.Second) // now past StaleUntil (TTL+30s)
if e, kind := l.Get(k, clk.Now()); e != nil || kind != Miss {
t.Fatalf("past stale window: got entry=%v kind=%v, want nil/Miss", e, kind)
}
if got := l.Stats().Entries; got != 0 {
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)
}
}
func TestLRUEvictionKeepsRecentlyUsed(t *testing.T) {
// One shard so the budget is exactly what we set: eviction order is then
// fully determined and the test can assert it.
l := NewLRU(4, 1)
clk := newClock()
now := clk.Now()
put := func(name string) {
l.Put(key(name), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(time.Minute), Bytes: 8})
}
put("a.example.com")
put("b.example.com")
put("c.example.com")
// Touch a so it is the most recent, then overflow the 4-entry budget.
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Fatalf("a: kind = %v, want Fresh", kind)
}
put("d.example.com")
put("e.example.com") // 5 inserts, budget 4
if got := l.Stats().Entries; got > 4 {
t.Fatalf("entries = %d, want <= 4 (per-shard budget)", got)
}
if _, kind := l.Get(key("a.example.com"), now); kind != Fresh {
t.Errorf("a was recently used, want it kept")
}
if _, kind := l.Get(key("b.example.com"), now); kind != Miss {
t.Errorf("b was the least recently used, want it evicted")
}
if l.Stats().Evictions == 0 {
t.Error("evictions = 0, want > 0")
}
}
func TestLRUDeleteWhereAndFlush(t *testing.T) {
l := NewLRU(100, 2)
now := newClock().Now()
names := []string{"x.example.com", "y.example.com", "z.other.net"}
for _, n := range names {
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
dropped := l.DeleteWhere(func(k Key, _ *Entry) bool {
return k.Name == "x.example.com" || k.Name == "y.example.com"
})
if dropped != 2 {
t.Errorf("DeleteWhere = %d, want 2", dropped)
}
if _, kind := l.Get(key("x.example.com"), now); kind != Miss {
t.Error("x.example.com still present after DeleteWhere")
}
if _, kind := l.Get(key("z.other.net"), now); kind != Fresh {
t.Error("z.other.net must survive an unrelated DeleteWhere")
}
l.Flush()
if got := l.Stats().Entries; got != 0 {
t.Errorf("entries after Flush = %d, want 0", got)
}
if l.Stats().Bytes != 0 {
t.Errorf("bytes after Flush = %d, want 0", l.Stats().Bytes)
}
}
func TestLRUWalkVisitsEveryEntryOnce(t *testing.T) {
l := NewLRU(100, 4)
now := newClock().Now()
for i := range 200 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
seen := make(map[string]int, 200)
l.Walk(func(k Key, _ *Entry) bool {
seen[k.Name]++
return true
})
if len(seen) != 200 {
t.Fatalf("Walk visited %d names, want 200", len(seen))
}
for n, c := range seen {
if c != 1 {
t.Fatalf("%s visited %d times, want 1", n, c)
}
}
// Returning false stops the walk.
count := 0
l.Walk(func(Key, *Entry) bool { count++; return false })
if count != 1 {
t.Errorf("Walk with stop visited %d entries, want 1", count)
}
}
func TestSetCapacityEvictsDownToTheNewBudget(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
for i := range 50 {
n := "n" + strconv.Itoa(i) + ".example.com"
l.Put(key(n), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
}
l.SetCapacity(10, 1)
if got := l.Stats().Entries; got > 10 {
t.Errorf("entries after SetCapacity(10) = %d, want <= 10", got)
}
if got := l.Stats().Shards; got != 1 {
t.Errorf("shards after SetCapacity(10, 1) = %d, want 1", got)
}
// The cache must still work after a resize.
l.Put(key("after-resize.example.com"), &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
if _, kind := l.Get(key("after-resize.example.com"), now); kind != Fresh {
t.Error("a Put after a resize must be readable")
}
}
func TestLRUConcurrentPutGetResize(t *testing.T) {
// Exercises the resizing path: SetCapacity rewrites the per-shard budget
// while other goroutines insert and read. Run with -race.
l := NewLRU(512, 8)
now := newClock().Now()
var wg sync.WaitGroup
stop := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.SetCapacity(200, 4)
l.SetCapacity(4096, 16)
}
}
}()
for g := range 8 {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := range 500 {
n := "g" + strconv.Itoa(g) + "-n" + strconv.Itoa(i) + ".example.com"
k := key(n)
l.Put(k, &Entry{TTL: 30, CreatedAt: now, ExpiresAt: now.Add(30 * time.Second), StaleUntil: now.Add(time.Minute), Bytes: 4})
e, _ := l.Get(k, now)
if e != nil {
_ = e.Hits.Load()
}
}
}(g)
}
// Readers keep walking while the resizer runs.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.Walk(func(Key, *Entry) bool { return true })
}
}
}()
for g := 1; g < 8; g++ {
_ = g
}
wg.Wait()
close(stop)
if l.Stats().Bytes < 0 {
t.Errorf("byte accounting went negative: %d", l.Stats().Bytes)
}
}
func TestKeyPackUnpackRoundTrip(t *testing.T) {
cases := []Key{
{Name: "example.com", Type: dns.TypeA, Class: dns.ClassINET},
{Name: "a.b.c.d.example.com", Type: dns.TypeAAAA, Class: dns.ClassINET, DO: true},
{Name: "x", Type: dns.TypeMX, Class: dns.ClassCHAOS, Scope: 3},
{Name: "wildcard.example.com", Type: dns.TypeTXT, Class: dns.ClassINET, DO: true, Scope: 7},
}
for _, k := range cases {
buf, err := packKey(nil, k)
if err != nil {
t.Fatalf("packKey(%v): %v", k, err)
}
got, rest, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey(%v): %v", k, err)
}
if len(rest) != 0 {
t.Errorf("unpackKey(%v) left %d trailing bytes, want 0", k, len(rest))
}
if got != k {
t.Errorf("round trip: got %+v, want %+v", got, k)
}
}
// The wire name survives, so the canonical form comes back normalised.
buf, err := packKey(nil, Key{Name: "Example.COM", Type: dns.TypeA, Class: dns.ClassINET})
if err != nil {
t.Fatalf("packKey: %v", err)
}
got, _, err := unpackKey(buf)
if err != nil {
t.Fatalf("unpackKey: %v", err)
}
if got.Name != "example.com" {
t.Errorf("unpacked name = %q, want %q", got.Name, "example.com")
}
}
func TestKeyPackRejectsBadInput(t *testing.T) {
if _, err := packKey(nil, Key{Name: string(make([]byte, 256*4+1))}); !errors.Is(err, errKeyTooLong) {
t.Errorf("packKey(oversized name) error = %v, want errKeyTooLong", err)
}
if _, err := packKey(nil, Key{Name: "a..b", Type: dns.TypeA}); err == nil {
t.Error("packKey(name with an empty label) error = nil, want an error")
}
if _, _, err := unpackKey([]byte{0x05, 0x00}); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(truncated) error = %v, want errBadKey", err)
}
if _, _, err := unpackKey(nil); !errors.Is(err, errBadKey) {
t.Errorf("unpackKey(empty) error = %v, want errBadKey", err)
}
}
func TestQueryKeyCanonicalises(t *testing.T) {
a := QueryKey("Example.COM.", dns.TypeA, dns.ClassINET, false, 0)
b := QueryKey("example.com", dns.TypeA, dns.ClassINET, false, 0)
if a != b {
t.Errorf("QueryKey did not canonicalise: %+v != %+v", a, b)
}
if a.Name != "example.com" {
t.Errorf("canonical name = %q, want %q", a.Name, "example.com")
}
}
func TestEntryAgeTTL(t *testing.T) {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
name string
e Entry
now time.Time
want uint32
}{
{"fresh", Entry{TTL: 100, CreatedAt: now}, now.Add(30 * time.Second), 70},
{"expiring", Entry{TTL: 100, CreatedAt: now}, now.Add(100 * time.Second), 0},
{"past", Entry{TTL: 100, CreatedAt: now}, now.Add(500 * time.Second), 0},
{"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 {
t.Errorf("%s: AgeTTL = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestManagerTTLFor(t *testing.T) {
clk := newClock()
m := NewManager(Options{MinTTL: 0, MaxTTL: time.Hour, NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
soa := func(ttl uint32) *dns.SOA {
return &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl}, Minttl: ttl}
}
t.Run("answers use the lowest TTL and clamp", func(t *testing.T) {
m := NewManager(Options{MinTTL: 10 * time.Second, MaxTTL: time.Hour, Now: clk.Now}, nil)
msg := answer(t, "a.example.com", 300, "1.2.3.4")
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "a.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 5},
A: net.ParseIP("1.2.3.5").To4(),
})
ttl, neg := m.TTLFor(msg)
if neg {
t.Error("neg = true for a positive answer")
}
if ttl != 10 {
t.Errorf("ttl = %d, want 10 (5s clamped up to MinTTL)", ttl)
}
})
t.Run("clamp down to MaxTTL", func(t *testing.T) {
m := NewManager(Options{MaxTTL: 60 * time.Second, Now: clk.Now}, nil)
ttl, _ := m.TTLFor(answer(t, "a.example.com", 86400, "1.2.3.4"))
if ttl != 60 {
t.Errorf("ttl = %d, want 60 (clamped to MaxTTL)", ttl)
}
})
t.Run("NXDOMAIN takes the SOA TTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Rcode = dns.RcodeNameError
msg.Ns = []dns.RR{soa(45)}
ttl, neg := m.TTLFor(msg)
if !neg {
t.Error("neg = false for NXDOMAIN")
}
if ttl != 45 {
t.Errorf("ttl = %d, want 45", ttl)
}
})
t.Run("NODATA takes the SOA TTL and counts as negative", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
msg.Ns = []dns.RR{soa(12)}
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 12 {
t.Errorf("NODATA: ttl = %d neg = %v, want 12/true", ttl, neg)
}
})
t.Run("NODATA without an SOA falls back to NegativeTTL", func(t *testing.T) {
m := NewManager(Options{NegativeTTL: 30 * time.Second, Now: clk.Now}, nil)
msg := new(dns.Msg)
msg.SetQuestion("nope.example.com.", dns.TypeA)
msg.Response = true
ttl, neg := m.TTLFor(msg)
if !neg || ttl != 30 {
t.Errorf("ttl = %d neg = %v, want 30/true", ttl, neg)
}
})
t.Run("truncated answers are never cached", func(t *testing.T) {
msg := answer(t, "big.example.com", 600, "1.2.3.4")
msg.Truncated = true
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("ttl = %d for a truncated answer, want 0", ttl)
}
})
t.Run("transient rcodes are never cached", func(t *testing.T) {
for _, rc := range []int{dns.RcodeServerFailure, dns.RcodeRefused, dns.RcodeNotAuth} {
msg := answer(t, "a.example.com", 600, "1.2.3.4")
msg.Rcode = rc
if ttl, _ := m.TTLFor(msg); ttl != 0 {
t.Errorf("rcode %d: ttl = %d, want 0", rc, ttl)
}
}
})
t.Run("nil message", func(t *testing.T) {
if ttl, _ := m.TTLFor(nil); ttl != 0 {
t.Errorf("ttl = %d, want 0", ttl)
}
})
}
func TestManagerPutResponseGetAndAgeing(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("www.example.com")
m.PutResponse(k, answer(t, "www.example.com", 120, "93.184.216.34"), "127.0.0.53:53")
res := m.Get(k)
if res.Kind != Fresh {
t.Fatalf("kind = %v, want Fresh", res.Kind)
}
if res.TTL != 120 {
t.Errorf("TTL = %d, want 120", res.TTL)
}
if res.Upstream != "127.0.0.53:53" {
t.Errorf("upstream = %q, want the address it came from", res.Upstream)
}
if got := res.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer = %s, want 93.184.216.34", got)
}
// The stored copy is private: mutating the returned message must not
// change what the next reader sees.
res.Msg.Answer[0].(*dns.A).A = net.ParseIP("9.9.9.9").To4()
again := m.Get(k)
if got := again.Msg.Answer[0].(*dns.A).A.String(); got != "93.184.216.34" {
t.Errorf("answer after mutating the first result = %s, want an independent copy", got)
}
// TTLs age down monotonically.
clk.Advance(30 * time.Second)
if got := m.Get(k).TTL; got != 90 {
t.Errorf("TTL after 30s = %d, want 90", got)
}
if got := m.Get(k).Msg.Answer[0].Header().Ttl; got != 90 {
t.Errorf("record TTL after 30s = %d, want 90", got)
}
}
func TestManagerServeStale(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, ServeStale: time.Minute, Now: clk.Now}, nil)
k := key("stale.example.com")
m.PutResponse(k, answer(t, "stale.example.com", 60, "10.0.0.1"), "up")
clk.Advance(90 * time.Second) // 30s past the TTL, inside the stale window
res := m.Get(k)
if res.Kind != Stale {
t.Fatalf("kind = %v, want Stale", res.Kind)
}
if !res.ServedStale {
t.Error("ServedStale = false on a stale hit")
}
if res.TTL != 0 {
t.Errorf("TTL = %d on a stale hit, want 0 (the handler forces TTL 1)", res.TTL)
}
clk.Advance(61 * time.Second) // now past TTL+ServeStale
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v past the stale window, want Miss", res.Kind)
}
}
func TestManagerServeStaleDisabled(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nostale.example.com")
m.PutResponse(k, answer(t, "nostale.example.com", 60, "10.0.0.2"), "up")
clk.Advance(61 * time.Second)
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss when ServeStale is 0", res.Kind)
}
}
func TestManagerPutResponseRespectsZeroTTL(t *testing.T) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
k := key("nocache.example.com")
m.PutResponse(k, answer(t, "nocache.example.com", 0, "10.0.0.3"), "up")
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v, want Miss: a zero TTL means do not cache", res.Kind)
}
}
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")
}
// "sub.blocked.example.com" must not be caught by the "blocked.example.com"
// subtree walk, but "blocked.example.com" must not catch
// "notblocked.example.com" either.
if n := m.DeleteName("Blocked.Example.Com."); n != 1 {
t.Errorf("DeleteName = %d, want 1 (canonicalised, one name)", n)
}
if res := m.Get(key("blocked.example.com")); res.Kind != Miss {
t.Error("deleted name still served")
}
if n := m.DeleteSubtree("deep.sub.blocked.example.com"); n != 1 {
t.Errorf("DeleteSubtree = %d, want 1", n)
}
for _, n := range []string{"keep.example.com", "notblocked.example.com"} {
if res := m.Get(key(n)); res.Kind != Fresh {
t.Errorf("%s was dropped by an unrelated delete", n)
}
}
_ = now
}
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)
m.PutResponse(key("a.example.com"), answer(t, "a.example.com", 60, "10.0.0.7"), "up")
m.Get(key("a.example.com"))
m.Get(key("miss.example.com"))
s := m.Stats()
if !s.Enabled {
t.Error("Enabled = false on a live manager")
}
if s.Entries != 1 {
t.Errorf("Entries = %d, want 1", s.Entries)
}
if s.Hits != 1 || s.Misses != 1 {
t.Errorf("hits = %d misses = %d, want 1/1", s.Hits, s.Misses)
}
if s.Size != 100 || s.Shards != 4 {
t.Errorf("size/shards = %d/%d, want 100/4", s.Size, s.Shards)
}
if s.MemoryHuman == "" || s.Bytes <= 0 {
t.Errorf("memory accounting: bytes = %d human = %q, want a positive byte count and a human string", s.Bytes, s.MemoryHuman)
}
if s.Persist.Enabled {
t.Error("Persist.Enabled = true without a store")
}
}
func TestHumanBytes(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1024 * 1024, "1.0 MiB"},
{3 * 1024 * 1024 * 1024 / 2, "1.50 GiB"},
{-1024, "-1.0 KiB"},
}
for _, tc := range cases {
if got := HumanBytes(tc.in); got != tc.want {
t.Errorf("HumanBytes(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStoreSnapshotAndLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 2)
for i := range 25 {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, err := answer(t, n, 600, "10.0.0.8").Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
l.Put(key(n), &Entry{
Wire: wire, TTL: 600, Origin: "upstream", Upstream: "1.1.1.1:53",
CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire),
})
}
written, err := st.Snapshot(l, now)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if written != 25 {
t.Errorf("Snapshot wrote %d entries, want 25", written)
}
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
t.Fatalf("snapshot file missing or empty: %v", err)
}
// A fresh LRU restored from the file keeps the answers and their metadata.
restored := NewLRU(100, 2)
loaded, skipped, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 25 || skipped != 0 {
t.Errorf("Load = %d loaded, %d skipped, want 25/0", loaded, skipped)
}
e, kind := restored.Get(key("n7.example.com"), now)
if e == nil || kind != Fresh {
t.Fatalf("restored entry: e = %v kind = %v, want non-nil/Fresh", e, kind)
}
if e.Upstream != "1.1.1.1:53" || e.Origin != "upstream" {
t.Errorf("restored metadata: origin = %q upstream = %q, want upstream/1.1.1.1:53", e.Origin, e.Upstream)
}
// The wire is intact.
msg := new(dns.Msg)
if err := msg.Unpack(e.Wire); err != nil {
t.Fatalf("unpack restored wire: %v", err)
}
if len(msg.Answer) != 1 {
t.Errorf("restored answer has %d records, want 1", len(msg.Answer))
}
}
func TestStoreLoadSkipsExpiredEntries(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
for i, ttl := range []uint32{600, 600, 5} {
n := "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(t, n, ttl, "10.0.0.9").Pack()
l.Put(key(n), &Entry{
Wire: wire, TTL: ttl, CreatedAt: now, ExpiresAt: now.Add(time.Duration(ttl) * time.Second),
StaleUntil: now.Add(time.Duration(ttl)*time.Second + 30*time.Second), Bytes: len(wire),
})
}
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
// Load 10s later: the 5s entry is dead, the two 600s entries are fresh.
restored := NewLRU(100, 1)
loaded, skipped, err := st.Load(restored, now.Add(10*time.Second))
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 2 {
t.Errorf("loaded = %d, want 2", loaded)
}
if skipped != 1 {
t.Errorf("skipped = %d, want 1", skipped)
}
if _, kind := restored.Get(key("n2.example.com"), now.Add(10*time.Second)); kind != Miss {
t.Errorf("expired entry came back after a reload (kind = %v)", kind)
}
}
func TestStoreTruncateAndStats(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.db")
st, err := OpenStore(StoreOptions{Path: path})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
now := clk.Now()
l := NewLRU(100, 1)
wire, _ := answer(t, "a.example.com", 600, "10.0.0.10").Pack()
l.Put(key("a.example.com"), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
if _, err := st.Snapshot(l, now); err != nil {
t.Fatalf("Snapshot: %v", err)
}
s := st.Stats()
if !s.Enabled || s.Path != path || s.Entries != 1 || s.Writes == 0 || s.Bytes <= 0 {
t.Errorf("Stats = %+v, want enabled with 1 entry and at least one write", s)
}
if !s.LastSnapshot.IsZero() {
t.Logf("LastSnapshot = %v", s.LastSnapshot)
}
if err := st.Truncate(); err != nil {
t.Fatalf("Truncate: %v", err)
}
if s := st.Stats(); s.Entries != 0 {
t.Errorf("entries after Truncate = %d, want 0", s.Entries)
}
restored := NewLRU(100, 1)
loaded, _, err := st.Load(restored, now)
if err != nil {
t.Fatalf("Load after Truncate: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d after Truncate, want 0", loaded)
}
}
// TestManagerFlushDropsSnapshot checks the interaction the blocklist API
// depends on: flushing the cache must also clear the persisted snapshot, or a
// restart would resurrect answers an operator just invalidated.
func TestManagerFlushDropsSnapshot(t *testing.T) {
dir := t.TempDir()
st, err := OpenStore(StoreOptions{Path: filepath.Join(dir, "cache.db")})
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
defer st.Close()
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, st)
k := key("flushed.example.com")
m.PutResponse(k, answer(t, "flushed.example.com", 600, "10.0.0.11"), "up")
if _, err := st.Snapshot(m.LRU(), clk.Now()); err != nil {
t.Fatalf("Snapshot: %v", err)
}
m.Flush()
restored := NewLRU(10, 1)
loaded, _, err := st.Load(restored, clk.Now())
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded != 0 {
t.Errorf("loaded = %d entries after Flush, want 0", loaded)
}
if res := m.Get(k); res.Kind != Miss {
t.Errorf("kind = %v after Flush, want Miss", res.Kind)
}
}
func TestLRUStatsHitRate(t *testing.T) {
l := NewLRU(100, 1)
now := newClock().Now()
l.Put(key("a.example.com"), &Entry{TTL: 60, CreatedAt: now, ExpiresAt: now.Add(time.Minute), StaleUntil: now.Add(90 * time.Second), Bytes: 4})
for range 3 {
l.Get(key("a.example.com"), now)
}
for range 1 {
l.Get(key("nothere.example.com"), now)
}
s := l.Stats()
if s.Hits != 3 || s.Misses != 1 {
t.Fatalf("hits/misses = %d/%d, want 3/1", s.Hits, s.Misses)
}
if s.HitRate < 0.74 || s.HitRate > 0.76 {
t.Errorf("HitRate = %v, want ~0.75", s.HitRate)
}
}
func BenchmarkLRUGet(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
wire, _ := answer(&testing.T{}, names[i], 600, "10.0.0.12").Pack()
l.Put(key(names[i]), &Entry{Wire: wire, TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: len(wire)})
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
l.Get(key(names[i%len(names)]), now)
i += 7
}
})
}
func BenchmarkLRUPut(b *testing.B) {
l := NewLRU(1<<16, 16)
now := time.Now()
b.ResetTimer()
for i := 0; b.Loop(); i++ {
l.Put(key("n"+strconv.Itoa(i)+".example.com"), &Entry{TTL: 600, CreatedAt: now, ExpiresAt: now.Add(600 * time.Second), StaleUntil: now.Add(630 * time.Second), Bytes: 64})
}
}
func BenchmarkManagerGetUnpack(b *testing.B) {
clk := newClock()
m := NewManager(Options{MaxTTL: time.Hour, Now: clk.Now}, nil)
names := make([]string, 4096)
for i := range names {
names[i] = "n" + strconv.Itoa(i) + ".example.com"
m.PutResponse(key(names[i]), answer(&testing.T{}, names[i], 600, "10.0.0.13"), "up")
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := rand.Int()
for pb.Next() {
m.Get(key(names[i%len(names)]))
i += 7
}
})
}