Files
developer 4f49033f1b Cover the engine, config.Size and metrics; fix the five bugs they found
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.
2026-09-27 16:03:21 +00:00

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