internal/engine, internal/config and internal/metrics had no test file at all, and writing them turned up five real bugs. internal/engine (23 tests, new harness_test.go): a wired engine over a fake upstream on loopback, a local zone, a blocklist, a cache, a query log and an injectable clock. It pins the pipeline order end to end -- ACL deny before the local zone, local zone before blocklist, blocklist before cache, drop sends nothing, the concurrency and rate-limit refusals, serve-stale served with TTL 1 and refreshed once per key, truncation against a 512-byte client, and exactly one metrics record and one query-log entry per query. Five fixes: - config.ParseSize ignored the unit multiplier, so 512MiB parsed as 512 bytes and the whole memory envelope was inert for anyone who wrote a unit. - finalize (EDNS0 and the UDP size) ran only on the cache-hit path, so a large uncached answer left oversized for a 512-byte client. It now runs on the upstream, local-zone and blocklist paths too. - The upstream UDP client read buffer was hard-coded to 1232 while we advertise dns.max_udp_size, so with a larger buffer configured every answer above 1232 bytes was read truncated and became SERVFAIL. The pool takes the size now. - LRU.prefetchGate was a 64-slot global cap, not the single flight its comment promised: a burst of stale hits launched up to 64 duplicate refreshes of one name. It is a per-key latch now; distinct names still refresh in parallel. - metrics.RecordMiss and RecordStaleHit moved only the Prometheus collector, so the Cache page's misses and stale hits read 0 for the life of the process. AGENTS.md gains the trap that cost the most time: this version of miekg/dns reports a character-string over 255 bytes as zero length, so such a message silently fails to pack, and the write failure looks like a client timeout.
215 lines
6.6 KiB
Go
215 lines
6.6 KiB
Go
package config
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestParseSizeUnits(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want int64
|
|
}{
|
|
{"0", 0},
|
|
{"512", 512},
|
|
{"512B", 512},
|
|
{"1K", 1 << 10},
|
|
{"1KB", 1000}, // decimal, unlike KiB
|
|
{"1KiB", 1 << 10},
|
|
{"1M", 1 << 20},
|
|
{"1MiB", 1 << 20},
|
|
{"1G", 1 << 30},
|
|
{"1GiB", 1 << 30},
|
|
{"1T", 1 << 40},
|
|
{"1TiB", 1 << 40},
|
|
// "M" must not eat the "iB" of "MiB", and "B" must not eat the "iB"
|
|
// of "BiB"-looking input. These are the shadowing cases the longest
|
|
// suffix first ordering exists for.
|
|
{"1MiB", 1 << 20},
|
|
{"1MIB", 1 << 20},
|
|
{"512mib", 1 << 20 * 512},
|
|
{" 2 MiB ", 2 << 20},
|
|
// Fractions scale exactly for binary units.
|
|
{"1.5GiB", 1610612736},
|
|
{"0.5KiB", 512},
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := ParseSize(tc.in)
|
|
if err != nil {
|
|
t.Errorf("ParseSize(%q): unexpected error %v", tc.in, err)
|
|
continue
|
|
}
|
|
if int64(got) != tc.want {
|
|
t.Errorf("ParseSize(%q) = %d, want %d", tc.in, int64(got), tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSizeRejectsJunk(t *testing.T) {
|
|
// Every one of these must be an error rather than a plausible number:
|
|
// a silently misread memory budget is worse than a refusal to start.
|
|
for _, in := range []string{
|
|
"",
|
|
" ",
|
|
"MiB",
|
|
"abc",
|
|
"512MiBx", // trailing junk, not a truncated match
|
|
"1.2.3",
|
|
"12 34",
|
|
"-1",
|
|
"-1MiB",
|
|
"1MiB-", // negative after the unit
|
|
"KiB", // a unit with no number in front
|
|
".", // a number with no digits
|
|
} {
|
|
if got, err := ParseSize(in); err == nil {
|
|
t.Errorf("ParseSize(%q) = %d, want an error", in, int64(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseSizeRangeAndRounding(t *testing.T) {
|
|
// A value that does not fit in an int64 is an error, not a wrap.
|
|
for _, in := range []string{"8EiB", "9223372036854775807B", "99999999999999999999"} {
|
|
if got, err := ParseSize(in); err == nil {
|
|
t.Errorf("ParseSize(%q) = %d, want an out-of-range error", in, int64(got))
|
|
}
|
|
}
|
|
// The largest representable value still parses.
|
|
if got, err := ParseSize("8EiB"); err == nil && int64(got) <= 0 {
|
|
t.Errorf("ParseSize(%q) = %d, want a positive value or an error", "8EiB", int64(got))
|
|
}
|
|
// A fractional value truncates rather than rounding up: a memory budget
|
|
// that rounds up is a budget the process does not have.
|
|
if got, err := ParseSize("0.1KiB"); err != nil || int64(got) != 102 {
|
|
t.Errorf("ParseSize(%q) = %d (err %v), want 102", "0.1KiB", int64(got), err)
|
|
}
|
|
}
|
|
|
|
// TestParseSizeAcceptsExponents pins that ParseFloat's exponent syntax is
|
|
// accepted, with or without a unit: the parser is one ParseFloat call in both
|
|
// paths, and making "1e3" work while "1e3MiB" errors would be a surprise.
|
|
func TestParseSizeAcceptsExponents(t *testing.T) {
|
|
if got, err := ParseSize("1e3MiB"); err != nil || int64(got) != 1000*(1<<20) {
|
|
t.Errorf("ParseSize(%q) = %d (err %v), want %d", "1e3MiB", int64(got), err, 1000*(1<<20))
|
|
}
|
|
}
|
|
|
|
func TestSizeUnmarshalYAML(t *testing.T) {
|
|
type doc struct {
|
|
V Size `yaml:"v"`
|
|
}
|
|
cases := []struct {
|
|
in string
|
|
want int64
|
|
}{
|
|
{"v: 512MiB\n", 512 << 20},
|
|
{"v: \"512MiB\"\n", 512 << 20},
|
|
// A bare integer is bytes: that is what MarshalYAML writes, so a
|
|
// config the API rewrote must load back unchanged.
|
|
{"v: 1073741824\n", 1 << 30},
|
|
{"v: 0\n", 0},
|
|
}
|
|
for _, tc := range cases {
|
|
var d doc
|
|
if err := yaml.Unmarshal([]byte(tc.in), &d); err != nil {
|
|
t.Errorf("unmarshal %q: %v", tc.in, err)
|
|
continue
|
|
}
|
|
if int64(d.V) != tc.want {
|
|
t.Errorf("unmarshal %q = %d, want %d", tc.in, int64(d.V), tc.want)
|
|
}
|
|
}
|
|
|
|
var d doc
|
|
if err := yaml.Unmarshal([]byte("v: not-a-size\n"), &d); err == nil {
|
|
t.Error("unmarshal of junk: want an error")
|
|
}
|
|
// "1Gi" is the k8s spelling and is *not* in the unit table: the parser is
|
|
// deliberately strict, so a memory budget must be written 1GiB. Kept as
|
|
// a test rather than left implicit, because the k8s manifests sit right
|
|
// next to this code and "1Gi" is what an operator reaches for first.
|
|
if err := yaml.Unmarshal([]byte("v: 1Gi\n"), &d); err == nil {
|
|
t.Error(`unmarshal of "1Gi": want an error, the unit is GiB`)
|
|
}
|
|
}
|
|
|
|
func TestSizeMarshalYAMLRoundTrips(t *testing.T) {
|
|
type doc struct {
|
|
V Size `yaml:"v"`
|
|
}
|
|
in := doc{V: Size(1536 << 20)}
|
|
b, err := yaml.Marshal(in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out doc
|
|
if err := yaml.Unmarshal(b, &out); err != nil {
|
|
t.Fatalf("round trip: %v", err)
|
|
}
|
|
if out.V != in.V {
|
|
t.Fatalf("round trip = %d, want %d", int64(out.V), int64(in.V))
|
|
}
|
|
}
|
|
|
|
func TestSizeString(t *testing.T) {
|
|
cases := []struct {
|
|
in Size
|
|
want string
|
|
}{
|
|
{0, "0"},
|
|
{512, "512B"},
|
|
{1 << 10, "1KiB"},
|
|
{1536, "1536B"}, // not a whole KiB, so bytes
|
|
{1 << 20, "1MiB"},
|
|
{1 << 30, "1GiB"},
|
|
{3<<30 + 7, "3221225479B"}, // 3Gi + 7: no whole unit left, so bytes
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.in.String(); got != tc.want {
|
|
t.Errorf("Size(%d).String() = %q, want %q", int64(tc.in), got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMemoryEffectiveLimit(t *testing.T) {
|
|
if got := (Memory{}).EffectiveLimit(); got != math.MaxInt64 {
|
|
t.Errorf("no limit: got %d, want MaxInt64 (no ceiling)", got)
|
|
}
|
|
// A limit below the reserve still leaves the runtime its floor rather
|
|
// than a death spiral.
|
|
if got := (Memory{Limit: 128 << 20}).EffectiveLimit(); got != 64<<20 {
|
|
t.Errorf("limit below the reserve: got %d, want the 64MiB floor", got)
|
|
}
|
|
if got := (Memory{Limit: 64 << 20}).EffectiveLimit(); got != 64<<20 {
|
|
t.Errorf("limit equal to the floor: got %d, want 64MiB", got)
|
|
}
|
|
// The usual case: 1Gi with the default 256MiB reserve.
|
|
if got := (Memory{Limit: 1 << 30}).EffectiveLimit(); got != (1<<30)-(256<<20) {
|
|
t.Errorf("1Gi limit: got %d, want %d", got, (1<<30)-(256<<20))
|
|
}
|
|
if got := (Memory{Limit: 1 << 30, Reserve: 300 << 20}).EffectiveLimit(); got != (1<<30)-(300<<20) {
|
|
t.Errorf("explicit reserve: got %d, want %d", got, (1<<30)-(300<<20))
|
|
}
|
|
}
|
|
|
|
func TestMemoryEffectiveCacheBytes(t *testing.T) {
|
|
// An explicit budget wins outright: the operator said so.
|
|
if got := (Memory{Limit: 1 << 30, CacheMaxBytes: 100 << 20}).EffectiveCacheBytes(); got != 100<<20 {
|
|
t.Errorf("explicit cache budget: got %d, want %d", got, 100<<20)
|
|
}
|
|
// Otherwise it is 60% of the effective limit, leaving the rest for the
|
|
// GC, the live query ring and the per-query working set.
|
|
m := Memory{Limit: 1 << 30, Reserve: 256 << 20}
|
|
want := m.EffectiveLimit() * 3 / 5
|
|
if got := m.EffectiveCacheBytes(); got != want {
|
|
t.Errorf("derived cache budget: got %d, want %d", got, want)
|
|
}
|
|
// No memory limit at all means no byte budget either, not a made-up one.
|
|
if got := (Memory{}).EffectiveCacheBytes(); got != 0 {
|
|
t.Errorf("no limit: got %d, want 0 (unbounded)", got)
|
|
}
|
|
}
|