more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5636
-440
No files matched your search
Vendored
+868
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user