mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +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.
109 lines
4.0 KiB
Go
109 lines
4.0 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The embedded baseline is the gate's reference. A job renamed without its
|
|
// baseline key would silently drop to "no committed baseline" and stop gating,
|
|
// which is the failure mode this suite exists to prevent elsewhere.
|
|
func TestBaselineCoversEveryGateJob(t *testing.T) {
|
|
base, err := loadBaseline()
|
|
if err != nil {
|
|
t.Fatalf("loadBaseline: %v", err)
|
|
}
|
|
for _, j := range gateJobs {
|
|
if _, ok := base.Jobs[j.Name]; !ok {
|
|
t.Errorf("baseline.json has no entry for job %q", j.Name)
|
|
}
|
|
}
|
|
for name := range base.Jobs {
|
|
if !slicesContainsJob(gateJobs, name) {
|
|
t.Errorf("baseline.json carries %q, which no gate job produces", name)
|
|
}
|
|
}
|
|
if strings.TrimSpace(base.Origin) == "" {
|
|
t.Error("baseline.json has no origin — a reference figure with no stated provenance cannot be judged for whether it still applies")
|
|
}
|
|
}
|
|
|
|
func slicesContainsJob(jobs []jobSpec, name string) bool {
|
|
for _, j := range jobs {
|
|
if j.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestMetricJudgeBands(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
m metric
|
|
want verdict
|
|
}{
|
|
{"throughput on baseline", metric{Name: "write_iops", Got: 1000, Want: 1000, HigherIsBetter: true}, verdictOK},
|
|
{"throughput improved", metric{Name: "write_iops", Got: 2000, Want: 1000, HigherIsBetter: true}, verdictOK},
|
|
{"throughput down 5%", metric{Name: "write_iops", Got: 950, Want: 1000, HigherIsBetter: true}, verdictOK},
|
|
{"throughput down 15%", metric{Name: "write_iops", Got: 850, Want: 1000, HigherIsBetter: true}, verdictWarn},
|
|
{"throughput halved", metric{Name: "write_iops", Got: 500, Want: 1000, HigherIsBetter: true}, verdictFail},
|
|
|
|
{"latency on baseline", metric{Name: "write_p99_9_ms", Got: 100, Want: 100}, verdictOK},
|
|
{"latency improved", metric{Name: "write_p99_9_ms", Got: 50, Want: 100}, verdictOK},
|
|
{"latency up 15%", metric{Name: "write_p99_9_ms", Got: 115, Want: 100}, verdictWarn},
|
|
{"latency doubled", metric{Name: "write_p99_9_ms", Got: 200, Want: 100}, verdictFail},
|
|
|
|
{"no committed baseline", metric{Name: "write_iops", Got: 900, Want: 0, HigherIsBetter: true}, verdictUnmeasured},
|
|
{"fio reported nothing", metric{Name: "write_iops", Got: 0, Want: 1000, HigherIsBetter: true}, verdictUnmeasured},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, msg := tc.m.judge()
|
|
if got != tc.want {
|
|
t.Errorf("judge() = %v, want %v (%s)", got, tc.want, msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The band edges are the contract, so they are pinned rather than left to the
|
|
// nearest test case either side of them.
|
|
func TestMetricJudgeBandEdges(t *testing.T) {
|
|
exactWarn := metric{Name: "write_iops", Got: 900, Want: 1000, HigherIsBetter: true}
|
|
if v, msg := exactWarn.judge(); v != verdictOK {
|
|
t.Errorf("exactly %.0f%% regression = %v, want OK (the band is exclusive): %s", warnFraction*100, v, msg)
|
|
}
|
|
exactFail := metric{Name: "write_iops", Got: 750, Want: 1000, HigherIsBetter: true}
|
|
if v, msg := exactFail.judge(); v != verdictWarn {
|
|
t.Errorf("exactly %.0f%% regression = %v, want warn (the band is exclusive): %s", failFraction*100, v, msg)
|
|
}
|
|
}
|
|
|
|
// A write-only job reports zeros for the idle read stream. Comparing those
|
|
// against a read baseline would report a total regression on every run.
|
|
func TestMetricsForSkipsIdleStreams(t *testing.T) {
|
|
base := BaselineJob{ReadIOPS: 5000, WriteIOPS: 9000, ReadP999Ms: 100, WriteP999Ms: 90}
|
|
writeOnly := fioJob{Name: "randwrite-4k"}
|
|
writeOnly.Write.IOBytes = 1 << 30
|
|
writeOnly.Write.IOPS = 9000
|
|
|
|
ms := metricsFor(jobSpec{Name: "randwrite-4k"}, writeOnly, base)
|
|
for _, m := range ms {
|
|
if strings.HasPrefix(m.Name, "read_") {
|
|
t.Errorf("write-only job produced read metric %q", m.Name)
|
|
}
|
|
}
|
|
if len(ms) != 2 {
|
|
t.Errorf("write-only job produced %d metrics, want 2", len(ms))
|
|
}
|
|
|
|
mixed := writeOnly
|
|
mixed.Read.IOBytes = 1 << 30
|
|
mixed.Read.IOPS = 5000
|
|
if got := len(metricsFor(jobSpec{Name: "randrw-70-30-4k"}, mixed, base)); got != 4 {
|
|
t.Errorf("mixed job produced %d metrics, want 4", got)
|
|
}
|
|
}
|