Files
spinifex/tests/e2e/diskperf/diskperf_test.go
T
Bryn Mailer 18aa67b0e9 feat: add the e2e guest disk-performance gate (#964)
* 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.
2026-09-03 14:33:21 +10:00

333 lines
12 KiB
Go

//go:build e2e
package diskperf
import (
"encoding/json"
"net"
"os"
"strconv"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/tests/e2e/harness"
)
const (
// diskperfDevice is the guest-visible attach point requested for the volume
// under test.
diskperfDevice = "/dev/sdp"
// volumeHeadroomGiB pads the volume past the working set so fio's last job
// is not writing into the final block of the device.
volumeHeadroomGiB = 4
// volumeReadyTimeout bounds the wait for a hotplugged volume to reach the
// guest kernel.
volumeReadyTimeout = 90 * time.Second
)
// TestGuestDiskPerformance is the gate. It drives each pinned fio profile
// against its own freshly created raw volume and asserts two separable classes
// of property: that the guest and the host stayed responsive throughout, hard
// pass/fail; and that throughput and latency have not regressed past the band,
// against a committed baseline.
//
// The two are kept apart deliberately. The availability class is what would
// have caught the original blocker, and it would have caught it on the first
// run, because its thresholds are absolute rather than relative to a number
// somebody measured on a good day. Folding them together would let a run that
// left the host unreachable pass on the strength of a respectable IOPS figure.
func TestGuestDiskPerformance(t *testing.T) {
fix := requireDiskPerfFixture(t)
harness.Phase(t, "Guest Disk Performance")
base, err := loadBaseline()
if err != nil {
t.Fatalf("baseline: %v", err)
}
reps := envInt(t, "SPINIFEX_DISKPERF_REPS", 1)
instanceID, tgt := ensureDiskPerfInstance(t, fix)
az := harness.DiscoverDefaultAZ(t, fix.Harness)
harness.Detail(t, "instance", instanceID, "az", az, "reps", reps, "baseline_origin", base.Origin)
if err := installFio(tgt); err != nil {
t.Fatalf("%v", err)
}
hostAddr := hostSSHAddr(fix.Env)
if err := readSSHBanner(hostAddr, hostProbeBound); err != nil {
// Establish the probe works before the workload starts. Skipping the
// host assertion loudly is honest; asserting on a channel that was
// already broken would report the harness as a storage regression.
t.Errorf("host sshd probe is unusable before any load (%s): %v — the host-responsiveness assertion cannot run", hostAddr, err)
hostAddr = ""
}
for _, job := range gateJobs {
t.Run(job.Name, func(t *testing.T) {
runJob(t, fix, job, base.Jobs[job.Name], instanceID, tgt, az, hostAddr, reps)
})
}
}
// runJob executes one profile reps times, each against a volume created for
// that repetition. The fresh volume is the reset: the extent index grows
// monotonically, so a reused volume makes each repetition scan a larger index
// than the last and drifts the gate's own numbers upward over time.
func runJob(t *testing.T, fix *Fixture, job jobSpec, base BaselineJob, instanceID string, tgt harness.SSHTarget, az, hostAddr string, reps int) {
t.Helper()
sizeGiB := int64(job.workingSetGiB() + volumeHeadroomGiB)
var results []runResult
var probes []probeReport
for rep := 1; rep <= reps; rep++ {
harness.Step(t, "rep %d/%d: %d jobs x %d GiB %s %s at qd%d on a fresh %d GiB volume",
rep, reps, job.NumJobs, job.SizeGiB, job.BlockSize, job.RW, job.IODepth, sizeGiB)
volID := createVolume(t, fix, az, sizeGiB)
before := harness.GuestDiskSet(t, tgt)
harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, diskperfDevice)
dev := harness.WaitForNewGuestDisk(t, tgt, before, volumeReadyTimeout)
res, reports := driveWithProbes(t, job, tgt, dev, hostAddr)
harness.DetachVolumeWait(t, fix.AWS, volID)
assertAvailability(t, job, reports)
results = append(results, res)
probes = append(probes, reports...)
}
report := aggregate(t, job, base, results, probes)
harness.DumpFile(t, resultsDir(t, fix), job.Name+"-result.json", report)
}
// resultsDir is the run's top-level artifact directory rather than the
// per-test one. The per-test directory deletes itself when the test passes,
// which is right for failure diagnostics and wrong for these: a passing run is
// exactly the run whose numbers a baseline capture needs to read.
func resultsDir(t *testing.T, fix *Fixture) string {
t.Helper()
dir := fix.Env.ArtifactDir
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("results dir %s: %v", dir, err)
}
return dir
}
// driveWithProbes runs the job while the responsiveness probes watch. The
// probes only bracket the availability job: they cost a connection every few
// seconds, which is negligible against the workload but is still load, and it
// has no place in a run whose numbers feed a baseline.
func driveWithProbes(t *testing.T, job jobSpec, tgt harness.SSHTarget, dev, hostAddr string) (runResult, []probeReport) {
t.Helper()
if !job.Availability {
res, err := runFio(tgt, job, dev)
if err != nil {
t.Fatalf("%v", err)
}
return res, nil
}
probes := []probe{guestProbe(tgt)}
if hostAddr != "" {
probes = append(probes, hostProbe(hostAddr))
}
stop := make(chan struct{})
done := make(chan []probeReport, 1)
go func() { done <- watch(probes, stop) }()
res, err := runFio(tgt, job, dev)
close(stop)
reports := <-done
if err != nil {
// A job that did not finish inside its budget is the stall itself, so
// what the probes saw while it ran is the diagnosis and has to be
// printed alongside the failure rather than dropped with the run.
for _, r := range reports {
t.Logf(" %s", r.summary())
if r.FirstFailure != nil {
t.Logf(" first breach at +%s: %v", r.FirstFailureAt.Round(time.Second), r.FirstFailure)
}
}
t.Fatalf("%v", err)
}
return res, reports
}
// assertAvailability turns the probe reports into hard pass/fail. Zero
// tolerance: one breach means the guest or the host stopped answering while a
// single tenant ran an ordinary workload.
func assertAvailability(t *testing.T, job jobSpec, reports []probeReport) {
t.Helper()
if !job.Availability {
return
}
if len(reports) == 0 {
t.Fatal("availability job ran with no probes — nothing was asserted")
}
for _, r := range reports {
harness.Detail(t, "probe", r.Name, "samples", r.Samples, "over_bound", r.Failures, "max_ms", r.Max.Milliseconds())
if r.Samples == 0 {
t.Errorf("%s: no samples taken — the probe never ran, so it proved nothing", r.Name)
continue
}
if r.ok() {
continue
}
t.Errorf("%s: %d of %d samples breached the bound, first at +%s: %v",
r.Name, r.Failures, r.Samples, r.FirstFailureAt.Round(time.Second), r.FirstFailure)
}
}
// aggregate compares the run against the baseline and returns the recorded
// result for the artifact bundle. Every metric is printed whatever its verdict,
// so a passing run still leaves the numbers behind for the next baseline
// capture to be judged against.
func aggregate(t *testing.T, job jobSpec, base BaselineJob, results []runResult, probes []probeReport) []byte {
t.Helper()
if len(results) == 0 {
t.Fatalf("job %q produced no results", job.Name)
}
agg := medianAggregate(results)
recorded := map[string]any{
"job": job.Name,
"reps": len(results),
"fio_version": results[0].Report.Version,
"read_iops": agg.Read.IOPS,
"write_iops": agg.Write.IOPS,
"read_p99_9": agg.Read.p999Ms(),
"write_p99_9": agg.Write.p999Ms(),
"read_max_ms": agg.Read.maxMs(),
"write_max_ms": agg.Write.maxMs(),
// The availability evidence belongs in the same record as the numbers.
// A throughput figure taken from a run that left the host unreachable
// is not a throughput figure worth keeping.
"probes": probeRecords(probes),
}
for _, m := range metricsFor(job, agg, base) {
v, msg := m.judge()
switch v {
case verdictFail:
t.Errorf("REGRESSION %s", msg)
case verdictWarn:
t.Logf(" warn: %s", msg)
default:
t.Logf(" %s", msg)
}
}
blob, err := json.MarshalIndent(recorded, "", " ")
if err != nil {
t.Fatalf("encoding result: %v", err)
}
return blob
}
// medianAggregate reduces the repetitions to one aggregate by taking the median
// of each metric independently. Per-metric rather than picking a single
// representative run: the median run by IOPS is not necessarily the median run
// by tail latency, and the tail is the number this gate cares most about.
func medianAggregate(results []runResult) fioJob {
pick := func(f func(fioJob) float64) float64 {
xs := make([]float64, 0, len(results))
for _, r := range results {
xs = append(xs, f(r.Aggregate))
}
return median(xs)
}
var out fioJob
out.Name = results[0].Job
out.Read.IOPS = pick(func(j fioJob) float64 { return j.Read.IOPS })
out.Write.IOPS = pick(func(j fioJob) float64 { return j.Write.IOPS })
out.Read.Clat.Percentile = map[string]int64{p999Key: int64(pick(func(j fioJob) float64 { return float64(j.Read.Clat.Percentile[p999Key]) }))}
out.Write.Clat.Percentile = map[string]int64{p999Key: int64(pick(func(j fioJob) float64 { return float64(j.Write.Clat.Percentile[p999Key]) }))}
out.Read.Clat.Max = int64(pick(func(j fioJob) float64 { return float64(j.Read.Clat.Max) }))
out.Write.Clat.Max = int64(pick(func(j fioJob) float64 { return float64(j.Write.Clat.Max) }))
// Carry the byte counts so metricsFor can still tell which streams the job
// actually exercised.
for _, r := range results {
out.Read.IOBytes += r.Aggregate.Read.IOBytes
out.Write.IOBytes += r.Aggregate.Write.IOBytes
}
return out
}
// createVolume makes a volume for a single repetition and registers its
// teardown. The harness Ensure* helper memoizes on (az, size) and would hand
// back the same volume every time, which is the opposite of the reset this
// needs.
func createVolume(t *testing.T, fix *Fixture, az string, sizeGiB int64) string {
t.Helper()
// e2e:allow-create — a fresh volume per repetition is the reset this gate depends on.
out, err := fix.AWS.EC2.CreateVolume(&ec2.CreateVolumeInput{
AvailabilityZone: aws.String(az),
Size: aws.Int64(sizeGiB),
})
if err != nil {
t.Fatalf("CreateVolume(%d GiB in %s): %v", sizeGiB, az, err)
}
volID := aws.StringValue(out.VolumeId)
harness.RegisterVolumeTeardown(t, fix.AWS, volID)
harness.WaitForVolumeState(t, fix.AWS, volID, "available")
return volID
}
// ensureDiskPerfInstance launches (or returns the memoized) guest and waits for
// SSH.
func ensureDiskPerfInstance(t *testing.T, fix *Fixture) (instanceID string, tgt harness.SSHTarget) {
t.Helper()
instType, arch := harness.DiscoverNanoInstanceType(t, fix.Harness)
ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch)
keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t))
vpc := harness.EnsureDefaultVPC(t, fix.Harness)
harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID)
instanceID = harness.EnsureInstance(t, fix.Harness, harness.InstanceSpec{
AMIID: ami,
InstanceType: instType,
KeyName: keyName,
SubnetID: vpc.SubnetID,
SGID: vpc.SGID,
})
inst := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running")
host, port := harness.InstancePublicSSHHost(t, inst)
harness.Step(t, "waiting for guest SSH on %s:%d (instance_type=%s)", host, port, instType)
if !harness.TryGuestSSHReady(host, port, "ubuntu", keyPath, 5*time.Minute) {
t.Fatalf("guest %s SSH %s:%d not ready after 5m", instanceID, host, port)
}
return instanceID, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}
}
// hostSSHAddr is the address the host-responsiveness probe dials. WANHost is
// the externally reachable node address, which is what an operator locked out
// by the original defect would have been trying to reach.
func hostSSHAddr(env *harness.Env) string {
return net.JoinHostPort(env.WANHost, "22")
}
// envInt reads a positive integer override, failing outright on a malformed
// value rather than silently falling back to the default.
func envInt(t *testing.T, key string, def int) int {
t.Helper()
v := os.Getenv(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
t.Fatalf("%s=%q is not a positive integer", key, v)
}
return n
}