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.
207 lines
6.4 KiB
Go
207 lines
6.4 KiB
Go
//go:build e2e
|
|
|
|
package diskperf
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
)
|
|
|
|
// Availability bounds. These are ceilings on a responsiveness probe, not
|
|
// latency targets: a healthy probe answers in milliseconds and a starved one
|
|
// never answers at all, so the exact value between those two matters little and
|
|
// the bound is set to leave the healthy case orders of magnitude of headroom.
|
|
const (
|
|
// guestProbeBound covers a fresh SSH handshake plus a stat, which costs a
|
|
// few hundred milliseconds on an idle guest.
|
|
guestProbeBound = 15 * time.Second
|
|
// hostProbeBound covers a TCP connect and an SSH banner read on the host,
|
|
// which is sub-millisecond on the local network.
|
|
hostProbeBound = 5 * time.Second
|
|
|
|
// probeInterval is the gap between successive probes. Frequent enough to
|
|
// catch a stall that resolves inside a minute, sparse enough that the
|
|
// probes themselves are not part of the workload.
|
|
probeInterval = 5 * time.Second
|
|
)
|
|
|
|
// probe is one repeated responsiveness check running alongside a workload.
|
|
type probe struct {
|
|
// Name identifies the probe in failure output.
|
|
Name string
|
|
// Bound is the ceiling a single attempt must answer within.
|
|
Bound time.Duration
|
|
// Attempt performs one check. It must return promptly on failure rather
|
|
// than block, since exceeding Bound is itself the signal.
|
|
Attempt func() error
|
|
}
|
|
|
|
// probeReport is what one probe observed across a workload.
|
|
type probeReport struct {
|
|
Name string
|
|
Samples int
|
|
Failures int
|
|
Max time.Duration
|
|
// FirstFailure is the earliest breach, kept because the first one carries
|
|
// the cause and later ones are usually consequences of it.
|
|
FirstFailure error
|
|
// FirstFailureAt is measured from the start of the watch.
|
|
FirstFailureAt time.Duration
|
|
}
|
|
|
|
// ok reports whether every sample answered inside the bound.
|
|
func (r probeReport) ok() bool { return r.Failures == 0 && r.Samples > 0 }
|
|
|
|
// summary renders the report for test output.
|
|
func (r probeReport) summary() string {
|
|
return fmt.Sprintf("%s: %d samples, %d over bound, max %s", r.Name, r.Samples, r.Failures, r.Max.Round(time.Millisecond))
|
|
}
|
|
|
|
// probeRecords renders the reports for the result artifact. FirstFailure is an
|
|
// error and does not marshal, so it is flattened to its message.
|
|
func probeRecords(reports []probeReport) []map[string]any {
|
|
out := make([]map[string]any, 0, len(reports))
|
|
for _, r := range reports {
|
|
rec := map[string]any{
|
|
"name": r.Name,
|
|
"samples": r.Samples,
|
|
"over_bound": r.Failures,
|
|
"max_ms": r.Max.Milliseconds(),
|
|
}
|
|
if r.FirstFailure != nil {
|
|
rec["first_failure"] = r.FirstFailure.Error()
|
|
rec["first_failure_at_s"] = int(r.FirstFailureAt.Seconds())
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watch runs every probe on its own goroutine until stop is closed, and
|
|
// returns what each observed. Probes run concurrently with each other and with
|
|
// the workload; each holds its own connection, so one blocking does not
|
|
// serialise the rest.
|
|
func watch(probes []probe, stop <-chan struct{}) []probeReport {
|
|
reports := make([]probeReport, len(probes))
|
|
var wg sync.WaitGroup
|
|
origin := time.Now()
|
|
|
|
for i, p := range probes {
|
|
wg.Add(1)
|
|
go func(i int, p probe) {
|
|
defer wg.Done()
|
|
rep := probeReport{Name: p.Name}
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
reports[i] = rep
|
|
return
|
|
default:
|
|
}
|
|
|
|
started := time.Now()
|
|
err := p.Attempt()
|
|
elapsed := time.Since(started)
|
|
rep.Samples++
|
|
if elapsed > rep.Max {
|
|
rep.Max = elapsed
|
|
}
|
|
if err != nil || elapsed > p.Bound {
|
|
rep.Failures++
|
|
if rep.FirstFailure == nil {
|
|
rep.FirstFailure = failureCause(err, elapsed, p.Bound)
|
|
rep.FirstFailureAt = started.Sub(origin)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-stop:
|
|
reports[i] = rep
|
|
return
|
|
case <-time.After(probeInterval):
|
|
}
|
|
}
|
|
}(i, p)
|
|
}
|
|
wg.Wait()
|
|
return reports
|
|
}
|
|
|
|
// failureCause distinguishes a probe that answered too late from one that did
|
|
// not answer at all. The two have different causes and the message has to say
|
|
// which was seen.
|
|
func failureCause(err error, elapsed, bound time.Duration) error {
|
|
if err != nil {
|
|
return fmt.Errorf("failed after %s: %w", elapsed.Round(time.Millisecond), err)
|
|
}
|
|
return fmt.Errorf("answered in %s, over the %s bound", elapsed.Round(time.Millisecond), bound)
|
|
}
|
|
|
|
// guestProbe checks that the guest still services ordinary I/O on its root
|
|
// volume while the workload runs on a different volume. Reading the root
|
|
// deliberately targets a volume the job never touches, so a breach is evidence
|
|
// that one volume's workload degrades another rather than merely that the job
|
|
// is slow.
|
|
//
|
|
// The command is a directory read and a stat of the root filesystem: the
|
|
// original symptom was `ls` and tab-completion freezing outright, not a
|
|
// measurable slowdown.
|
|
func guestProbe(tgt harness.SSHTarget) probe {
|
|
return probe{
|
|
Name: "guest responsiveness (root volume)",
|
|
Bound: guestProbeBound,
|
|
Attempt: func() error {
|
|
out, err := harness.GuestExecTimeout(tgt, "stat -f / >/dev/null && ls -1 /usr/bin >/dev/null", guestProbeBound)
|
|
if err != nil {
|
|
return fmt.Errorf("%w\n%s", err, out)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// hostProbe checks that the host's own sshd still accepts a connection and
|
|
// speaks first. The original blocker escaped the guest and made SSH to the host
|
|
// fail, so this is the assertion that the blast radius stays inside the tenant.
|
|
//
|
|
// A banner read rather than a full authentication: it needs no credential, and
|
|
// receiving the banner already proves the kernel completed a handshake and
|
|
// scheduled sshd, which is exactly what a starved host cannot do.
|
|
func hostProbe(addr string) probe {
|
|
return probe{
|
|
Name: "host sshd (" + addr + ")",
|
|
Bound: hostProbeBound,
|
|
Attempt: func() error {
|
|
return readSSHBanner(addr, hostProbeBound)
|
|
},
|
|
}
|
|
}
|
|
|
|
// readSSHBanner dials addr and reads the SSH identification string the server
|
|
// sends on connect.
|
|
func readSSHBanner(addr string, timeout time.Duration) error {
|
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("dial: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
|
return fmt.Errorf("set deadline: %w", err)
|
|
}
|
|
buf := make([]byte, 64)
|
|
n, err := conn.Read(buf)
|
|
if err != nil {
|
|
return fmt.Errorf("read banner: %w", err)
|
|
}
|
|
if n < 4 || string(buf[:4]) != "SSH-" {
|
|
return errors.New("peer sent no SSH identification string")
|
|
}
|
|
return nil
|
|
}
|