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.
This commit is contained in:
1 parent
48d37db15b
commit
4f49033f1b
12 files changed
+1837
-37
No files matched your search
+10
-5
@@ -56,23 +56,28 @@ func ParseSize(s string) (Size, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size %q", s)
|
||||
}
|
||||
return scaleSize(n, s)
|
||||
return scaleSize(n, u.mult, s)
|
||||
}
|
||||
n, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid size %q", s)
|
||||
}
|
||||
return scaleSize(n, s)
|
||||
return scaleSize(n, 1, s)
|
||||
}
|
||||
|
||||
func scaleSize(n float64, orig string) (Size, error) {
|
||||
// scaleSize applies the unit multiplier. A bare number has mult 1, which is
|
||||
// why the multiplier is a parameter rather than a constant: it used to be
|
||||
// forgotten on the suffixed path, so "512MiB" parsed as 512 *bytes* and every
|
||||
// memory budget written in units was off by the unit's factor.
|
||||
func scaleSize(n float64, mult int64, orig string) (Size, error) {
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("invalid size %q: must not be negative", orig)
|
||||
}
|
||||
if n >= float64(math.MaxInt64) {
|
||||
bytes := n * float64(mult)
|
||||
if bytes >= float64(math.MaxInt64) {
|
||||
return 0, fmt.Errorf("invalid size %q: out of range", orig)
|
||||
}
|
||||
return Size(int64(n)), nil
|
||||
return Size(int64(bytes)), nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML accepts both "512MiB" and a bare number.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user