mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 03:36:35 +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.
333 lines
12 KiB
Go
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
|
|
}
|