mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 04:36:40 +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.
131 lines
3.9 KiB
Go
131 lines
3.9 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
)
|
|
|
|
// Regression bands. Wide enough to survive bare-metal jitter, tight enough to
|
|
// catch the 2x that matters.
|
|
const (
|
|
failFraction = 0.25
|
|
warnFraction = 0.10
|
|
)
|
|
|
|
// baselineJSON is embedded rather than read from disk: the suite is
|
|
// cross-compiled into a .test binary and shipped to the node without its
|
|
// source, so a path-relative read would find nothing there.
|
|
//
|
|
//go:embed baseline.json
|
|
var baselineJSON []byte
|
|
|
|
// Baseline is the committed reference the throughput and latency assertions
|
|
// compare against. It changes only by deliberate PR with a written
|
|
// justification -- a baseline that updates itself is not a gate.
|
|
type Baseline struct {
|
|
Version int `json:"version"`
|
|
// Origin records the hardware and build the numbers were taken on. A
|
|
// figure with no stated origin cannot be judged for whether it still
|
|
// applies to the cell running the gate.
|
|
Origin string `json:"origin"`
|
|
Jobs map[string]BaselineJob `json:"jobs"`
|
|
}
|
|
|
|
// BaselineJob holds the reference metrics for one job. A zero field means "not
|
|
// measured": it records and warns rather than failing, so the gate can land and
|
|
// run before hardware numbers exist.
|
|
type BaselineJob struct {
|
|
ReadIOPS float64 `json:"read_iops"`
|
|
WriteIOPS float64 `json:"write_iops"`
|
|
ReadP999Ms float64 `json:"read_p99_9_ms"`
|
|
WriteP999Ms float64 `json:"write_p99_9_ms"`
|
|
}
|
|
|
|
func loadBaseline() (Baseline, error) {
|
|
var b Baseline
|
|
if err := json.Unmarshal(baselineJSON, &b); err != nil {
|
|
return Baseline{}, fmt.Errorf("parsing embedded baseline.json: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// verdict is the outcome of comparing one metric against its reference.
|
|
type verdict int
|
|
|
|
const (
|
|
verdictUnmeasured verdict = iota
|
|
verdictOK
|
|
verdictWarn
|
|
verdictFail
|
|
)
|
|
|
|
// metric is one measured value and its reference.
|
|
type metric struct {
|
|
Name string
|
|
Got float64
|
|
Want float64
|
|
// HigherIsBetter distinguishes throughput from latency, which regress in
|
|
// opposite directions.
|
|
HigherIsBetter bool
|
|
}
|
|
|
|
// regression returns the fractional move in the bad direction, negative when
|
|
// the measurement improved on the reference.
|
|
func (m metric) regression() float64 {
|
|
if m.Want == 0 {
|
|
return 0
|
|
}
|
|
delta := (m.Want - m.Got) / m.Want
|
|
if !m.HigherIsBetter {
|
|
delta = -delta
|
|
}
|
|
return delta
|
|
}
|
|
|
|
// judge classifies the metric against the bands. An unmeasured reference or a
|
|
// measurement fio did not produce is reported as such rather than compared,
|
|
// since comparing against zero manufactures either a pass or a failure from no
|
|
// evidence.
|
|
func (m metric) judge() (verdict, string) {
|
|
if m.Want == 0 {
|
|
return verdictUnmeasured, fmt.Sprintf("%s: %.1f (no committed baseline — recorded, not gated)", m.Name, m.Got)
|
|
}
|
|
if m.Got == 0 || math.IsNaN(m.Got) {
|
|
return verdictUnmeasured, fmt.Sprintf("%s: not reported by fio (baseline %.1f)", m.Name, m.Want)
|
|
}
|
|
r := m.regression()
|
|
msg := fmt.Sprintf("%s: %.1f vs baseline %.1f (%+.1f%%)", m.Name, m.Got, m.Want, -r*100)
|
|
switch {
|
|
case r > failFraction:
|
|
return verdictFail, msg
|
|
case r > warnFraction:
|
|
return verdictWarn, msg
|
|
default:
|
|
return verdictOK, msg
|
|
}
|
|
}
|
|
|
|
// metricsFor pairs a job's measured aggregate with its reference. Read metrics
|
|
// are omitted for a write-only job: fio reports zeros for the idle stream, and
|
|
// a zero read IOPS is not a regression there.
|
|
func metricsFor(job jobSpec, agg fioJob, base BaselineJob) []metric {
|
|
var ms []metric
|
|
if agg.Write.IOBytes > 0 {
|
|
ms = append(ms,
|
|
metric{Name: "write_iops", Got: agg.Write.IOPS, Want: base.WriteIOPS, HigherIsBetter: true},
|
|
metric{Name: "write_p99_9_ms", Got: agg.Write.p999Ms(), Want: base.WriteP999Ms},
|
|
)
|
|
}
|
|
if agg.Read.IOBytes > 0 {
|
|
ms = append(ms,
|
|
metric{Name: "read_iops", Got: agg.Read.IOPS, Want: base.ReadIOPS, HigherIsBetter: true},
|
|
metric{Name: "read_p99_9_ms", Got: agg.Read.p999Ms(), Want: base.ReadP999Ms},
|
|
)
|
|
}
|
|
return ms
|
|
}
|