mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 12:16:52 +00:00
18aa67b0e9
* feat: add the e2e guest disk-performance gate Nothing measured guest disk performance in CI, which is why a quadratic index scan shipped in viperblock 35dd9f2 and survived three subsequent fixes to adjacent code. It is also why the two availability clauses at the end of the ebs-performance plan's Stage 0 have never been evaluated: a sustained random-write workload took the guest, and then the host, down, and nothing since has demonstrated that it no longer does. tests/e2e/diskperf/ launches an instance, attaches a dedicated blank volume per repetition, and drives a pinned fio profile over guest SSH. Two profiles: randwrite-4k at 4 jobs x 4 GiB, the shape the exit criterion names, and randrw-70-30-4k, the mixed workload the headline IOPS came from. The target is a raw device with direct=1 rather than a file on the boot disk, so guest page cache, filesystem allocation and journal traffic are out of the path. Two classes of assertion, kept apart because folding them together lets a run that left the host unreachable pass on a respectable IOPS figure. Availability is hard pass/fail and absolute, so it gates from the first run: the job completes inside its budget, a guest responsiveness probe answers throughout on a second SSH channel, and the host's own sshd answers throughout. The guest probe reads the root volume while the workload runs on a different one, so a pass is evidence of isolation between volumes rather than of the job merely being slow. The host probe is a TCP connect plus an SSH banner read, which needs no credential and still proves the kernel scheduled sshd. Throughput and latency are banded against a committed baseline: fail above 25% regression, warn above 10%, per-metric median across repetitions. baseline.json is committed and embedded, with every figure zeroed, and a zero reference records and warns rather than failing. Only the availability class gates until numbers are captured on the bare-metal cell; a throughput band nobody measured would be a fabricated gate. A fresh volume per repetition is the reset. Extent count grows monotonically, so reusing a volume makes the gate order-dependent and drifts its own numbers upward over time, slowly normalising the regression it exists to catch. Each job is its own guest invocation and fio's JSON is fetched by a separate command, so a wedged job loses only itself and reports as a budget breach rather than a parse failure. e2e-baremetal.yml gains suites, suite-env and cell inputs so one reusable workflow serves both the correctness cell and nightly cell 28, which queues behind cell 20 on the shared concurrency group. Cells 25-27 are the dedicated eks, ecs and rds cells. The per-suite Go test timeout is resolved from e2e_suite_timeout in suite-sets.sh, where diskperf declares its 180-minute budget beside rds's rather than in a second place. The gate's own arithmetic runs in preflight via test-harness: band edges, the unmeasured cases, per-stream metric selection, fio JSON parsing, and the probe accounting including a slow-but-successful answer counting as a breach. A gate whose comparison logic is untested is the failure mode the plan is otherwise full of. The extent-count ceiling named as a fourth availability assertion is not implemented: it needs viperblock.index.entries from S1.5, which does not exist. * feat: let a manual bare-metal run choose its e2e suites A dispatch run only sees inputs declared under workflow_dispatch, which had none, so suites and suite-env resolved empty and fell back to the correctness set. Capturing a diskperf baseline by hand was therefore unreachable without a workflow_call caller. * fix: build the bare-metal suites the run step will execute The build step packs pre-built binaries into the tarball and did not see the suite selection, so a selected suite was absent from the node unless it happened to be one of the three the build script hardcoded. * feat: commit the measured disk-performance baseline Medians of three repetitions on the bare-metal cell. The throughput and latency bands now gate instead of warning, so a 2x regression in either job fails the suite. randwrite-4k's write tail is two orders above the mixed job's. It is recorded as measured rather than zeroed: an ungated metric would miss the regression class the gate exists to catch.
135 lines
4.4 KiB
Go
135 lines
4.4 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// sampleFio is a trimmed fio JSON document with the fields the gate reads.
|
|
const sampleFio = `{
|
|
"fio version": "fio-3.36",
|
|
"jobs": [
|
|
{
|
|
"jobname": "randwrite-4k",
|
|
"read": {"io_bytes": 0, "bw_bytes": 0, "iops": 0.0,
|
|
"clat_ns": {"max": 0, "mean": 0, "percentile": {}}},
|
|
"write": {"io_bytes": 17179869184, "bw_bytes": 38000000, "iops": 9280.5,
|
|
"clat_ns": {"max": 342000000, "mean": 3400000, "percentile": {"99.900000": 93000000}}}
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestParseFioReadsNamedJob(t *testing.T) {
|
|
res, err := parseFio("randwrite-4k", sampleFio)
|
|
if err != nil {
|
|
t.Fatalf("parseFio: %v", err)
|
|
}
|
|
if got := res.Aggregate.Write.IOPS; got != 9280.5 {
|
|
t.Errorf("write IOPS = %v, want 9280.5", got)
|
|
}
|
|
if got := res.Aggregate.Write.p999Ms(); got != 93 {
|
|
t.Errorf("write p99.9 = %v ms, want 93", got)
|
|
}
|
|
if got := res.Aggregate.Write.maxMs(); got != 342 {
|
|
t.Errorf("write max = %v ms, want 342", got)
|
|
}
|
|
if res.Report.Version != "fio-3.36" {
|
|
t.Errorf("fio version = %q, want fio-3.36", res.Report.Version)
|
|
}
|
|
}
|
|
|
|
// fio prefixes its JSON with progress output on some builds, so the object is
|
|
// located by its opening brace rather than assumed to start at byte zero.
|
|
func TestParseFioSkipsLeadingNoise(t *testing.T) {
|
|
if _, err := parseFio("randwrite-4k", "Jobs: 4 (f=4)\n"+sampleFio); err != nil {
|
|
t.Fatalf("parseFio with leading noise: %v", err)
|
|
}
|
|
}
|
|
|
|
// A job that ran but moved nothing must not be reported as a result: its
|
|
// numbers describe no workload, and comparing them against a baseline would
|
|
// manufacture either a pass or a regression from no evidence.
|
|
func TestParseFioRejectsEmptyJob(t *testing.T) {
|
|
empty := strings.ReplaceAll(sampleFio, `"io_bytes": 17179869184`, `"io_bytes": 0`)
|
|
empty = strings.ReplaceAll(empty, `"iops": 9280.5`, `"iops": 0`)
|
|
_, err := parseFio("randwrite-4k", empty)
|
|
if err == nil {
|
|
t.Fatal("parseFio accepted a job that moved no bytes")
|
|
}
|
|
if !strings.Contains(err.Error(), "moved no bytes") {
|
|
t.Errorf("error does not name the cause: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseFioRejectsMissingJob(t *testing.T) {
|
|
if _, err := parseFio("randread-4k", sampleFio); err == nil {
|
|
t.Fatal("parseFio accepted output with no matching job name")
|
|
}
|
|
}
|
|
|
|
func TestParseFioRejectsNonJSON(t *testing.T) {
|
|
if _, err := parseFio("randwrite-4k", "fio: engine libaio not loadable"); err == nil {
|
|
t.Fatal("parseFio accepted output containing no JSON object")
|
|
}
|
|
}
|
|
|
|
func TestMedian(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
in []float64
|
|
want float64
|
|
}{
|
|
{"empty", nil, 0},
|
|
{"single", []float64{7}, 7},
|
|
{"odd is the middle value", []float64{9, 1, 5}, 5},
|
|
{"even averages the two central", []float64{1, 2, 3, 4}, 2.5},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := median(tc.in); got != tc.want {
|
|
t.Errorf("median(%v) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// median must not reorder its input: callers hold the per-repetition slices and
|
|
// an in-place sort would silently reassociate metrics across repetitions.
|
|
func TestMedianDoesNotMutateInput(t *testing.T) {
|
|
in := []float64{3, 1, 2}
|
|
median(in)
|
|
if in[0] != 3 || in[1] != 1 || in[2] != 2 {
|
|
t.Errorf("median reordered its input: %v", in)
|
|
}
|
|
}
|
|
|
|
// The device has to be large enough for every job's region, since
|
|
// offset_increment gives each job a disjoint slice rather than sharing one.
|
|
func TestJobCommandCoversTheWholeWorkingSet(t *testing.T) {
|
|
for _, j := range gateJobs {
|
|
cmd := j.command("vdc", "/tmp/out.json")
|
|
for _, want := range []string{"--direct=1", "--filename=/dev/vdc", "--offset_increment=", "--output-format=json"} {
|
|
if !strings.Contains(cmd, want) {
|
|
t.Errorf("job %q command is missing %q:\n%s", j.Name, want, cmd)
|
|
}
|
|
}
|
|
if j.workingSetGiB() != j.SizeGiB*j.NumJobs {
|
|
t.Errorf("job %q working set %d does not match %d jobs x %d GiB", j.Name, j.workingSetGiB(), j.NumJobs, j.SizeGiB)
|
|
}
|
|
}
|
|
}
|
|
|
|
// randrw needs a mix and the single-direction profiles must not carry one,
|
|
// since fio ignores rwmixread outside randrw and its presence would suggest a
|
|
// mix that is not being run.
|
|
func TestJobCommandSetsMixOnlyForRandrw(t *testing.T) {
|
|
for _, j := range gateJobs {
|
|
cmd := j.command("vdc", "/tmp/out.json")
|
|
hasMix := strings.Contains(cmd, "--rwmixread=")
|
|
if want := j.RW == "randrw"; hasMix != want {
|
|
t.Errorf("job %q rw=%s: rwmixread present=%v, want %v", j.Name, j.RW, hasMix, want)
|
|
}
|
|
}
|
|
}
|