Files
spinifex/internal/testkit/loadgen/loadgen.go
T
joshsiv-mulga 3f91a3dbcd Merge remote-tracking branch 'origin/dev' into refactor/spinifex-package-boundaries
# Conflicts:
#	spinifex/daemon/daemon.go
#	spinifex/foundation/aws/arn/arn.go
#	spinifex/foundation/aws/arn/arn_test.go
#	spinifex/foundation/state/kvlease/bucket.go
#	spinifex/foundation/state/kvlease/bucket_test.go
#	spinifex/foundation/state/kvstore/wait.go
#	spinifex/foundation/state/kvstore/wait_test.go
#	spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go
#	spinifex/foundation/state/kvutil/export_test.go
#	spinifex/foundation/state/kvutil/kvutil.go
#	spinifex/foundation/state/kvutil/kvutil_test.go
#	spinifex/foundation/state/kvutil/replicas.go
#	spinifex/foundation/state/kvutil/replicas_health_test.go
#	spinifex/foundation/state/kvutil/replicas_test.go
#	spinifex/foundation/state/kvutil/state_test.go
#	spinifex/gateway/auth.go
#	spinifex/gateway/bedrock/logging.go
#	spinifex/gateway/ec2_authz_test.go
#	spinifex/gateway/ecrapi/authz.go
#	spinifex/gateway/eks.go
#	spinifex/gateway/gateway_test.go
#	spinifex/handlers/ec2/eigw/service_impl.go
#	spinifex/handlers/ec2/igw/service_impl.go
#	spinifex/handlers/ec2/instance/service_impl.go
#	spinifex/handlers/ec2/launchtemplate/service_impl_test.go
#	spinifex/handlers/ec2/placementgroup/service_impl.go
#	spinifex/handlers/ec2/placementgroup/service_impl_test.go
#	spinifex/handlers/ec2/routetable/service_impl.go
#	spinifex/handlers/ec2/spotinstance/service_impl.go
#	spinifex/handlers/ec2/volume/service_impl.go
#	spinifex/handlers/ec2/vpc/eni.go
#	spinifex/handlers/ec2/vpc/security_group.go
#	spinifex/handlers/ec2/vpc/service_impl.go
#	spinifex/handlers/ecs/tags.go
#	spinifex/handlers/eks/k3s_ha_control_plane.go
#	spinifex/handlers/iam/groups.go
#	spinifex/handlers/iam/retry.go
#	spinifex/handlers/rds/subnetgroup_test.go
#	spinifex/handlers/sts/assume_role.go
#	spinifex/ingress/aws/query/parser_fuzz_test.go
#	spinifex/runtime/compute/cache/cache_test.go
#	spinifex/runtime/roles/nats/servername.go
#	spinifex/services/awsgw/awsgw.go
#	spinifex/services/viperblockd/volume_dirty.go
#	spinifex/utils/account.go
2026-10-01 13:31:45 +10:00

510 lines
16 KiB
Go

// Package loadgen drives a measured request load at the control plane and
// reports per-operation latency distributions.
//
// It exists because bash cannot measure this: a shell driving the AWS CLI pays
// over a second of interpreter start-up per call, which is larger than the
// latency being measured, so a percentile built from those samples is a
// percentile of the CLI. The engine here holds long-lived SDK clients and times
// only the request.
package loadgen
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
"sort"
"sync"
"time"
)
// Mode is how a stage paces its requests, and the two answer different
// questions. Closed loop asks "with N callers in flight, how fast is it?" and
// slows down when the server does. Open loop asks "at R requests a second, does
// it keep up?" and does not, which is the only way to see a queue form.
type Mode string
const (
ModeClosed Mode = "closed"
ModeOpen Mode = "open"
)
// Kind separates reads from writes because their SLOs differ by an order of
// magnitude: a Describe that takes two seconds is broken, a RunInstances that
// takes two seconds is normal.
type Kind string
const (
KindRead Kind = "read"
KindWrite Kind = "write"
)
// ErrShed marks a request an open-loop stage could not issue because too many
// were already in flight. It is recorded rather than dropped: a stage that
// cannot sustain its rate has found the answer it was looking for.
var ErrShed = errors.New("shed: too many requests in flight")
// shedCode is the error code shed requests are counted under. It is namespaced
// so it can never collide with a code the service returns.
const shedCode = "loadgen.Shed"
// Op is one control-plane call under test. Call does the request and returns
// the service's error unchanged, so the code can be classified.
type Op struct {
Name string
Kind Kind
Call func(ctx context.Context, target *Target) error
}
// Target is one tenant's wiring. A run spreads its workers across every target
// it is given, so the load looks like several customers rather than one.
type Target struct {
Account string
Profile string
Clients *Clients
// VPCID is resolved once before the run for operations that need a
// resource of the tenant's own to act on. Resolving it per request would
// put a second call inside every sample.
VPCID string
// VolumeID lets a run ask for one volume by id rather than for the whole
// listing. The difference between the two is the cost of the listing,
// which is otherwise indistinguishable from the cost of the request.
VolumeID string
}
// Stage is one step of a ramp. Exactly one of Concurrency or RPS applies,
// depending on Mode.
type Stage struct {
Mode Mode `json:"mode"`
Concurrency int `json:"concurrency,omitempty"`
RPS float64 `json:"rps,omitempty"`
Duration time.Duration `json:"-"`
DurationSec float64 `json:"durationSeconds"`
// MaxInFlight caps how many open-loop requests may be outstanding at once.
// Zero means four seconds' worth of the stage's rate, which is enough slack
// for a slow cluster and not enough for a stalled one to exhaust memory.
MaxInFlight int `json:"maxInFlight,omitempty"`
}
// SLO is the pass/fail line. A stage breaches when an operation's p99 exceeds
// the limit for its kind, or when any request fails for a reason other than a
// deliberately tripped quota.
type SLO struct {
ReadP99 time.Duration `json:"-"`
WriteP99 time.Duration `json:"-"`
// ExpectedErrorCodes are not failures. A quota rejection means the cluster
// is working; counting it as an error would fail a correct run.
ExpectedErrorCodes []string `json:"expectedErrorCodes,omitempty"`
ReadP99MS float64 `json:"readP99Ms"`
WriteP99MS float64 `json:"writeP99Ms"`
}
// OpStats is one operation's result within one stage.
type OpStats struct {
Op string `json:"op"`
Kind Kind `json:"kind"`
Count int `json:"count"`
Errors int `json:"errors"`
ErrorCodes map[string]int `json:"errorCodes,omitempty"`
MeanMS float64 `json:"meanMs"`
P50MS float64 `json:"p50Ms"`
P90MS float64 `json:"p90Ms"`
P99MS float64 `json:"p99Ms"`
MaxMS float64 `json:"maxMs"`
// AchievedRPS is what the stage actually managed, which is the number that
// matters in open loop: asking for 40 and achieving 12 is the finding.
AchievedRPS float64 `json:"achievedRps"`
}
// StageResult is one stage's outcome.
type StageResult struct {
Stage Stage `json:"stage"`
Ops map[string]OpStats `json:"ops"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended"`
Breached []string `json:"breached,omitempty"`
}
// Report is the whole run. It is written as JSON for the harness to fold into
// its run directory, and summarised for a human.
type Report struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Targets []string `json:"targets"`
SLO SLO `json:"slo"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended"`
Stages []StageResult `json:"stages"`
// FirstBreach names the first stage of each mode whose p99 crossed the SLO.
// This is the capacity number the run exists to produce.
FirstBreach map[Mode]*Breach `json:"firstBreach,omitempty"`
}
// Breach records where a mode stopped meeting its SLO.
type Breach struct {
StageIndex int `json:"stageIndex"`
Concurrency int `json:"concurrency,omitempty"`
RPS float64 `json:"rps,omitempty"`
Ops []string `json:"ops"`
}
// sample is one timed request.
type sample struct {
op string
latency time.Duration
errCode string
failed bool
}
// Run executes every stage in order and returns the report. Stages run back to
// back with no cool-down: a queue left by one stage is a real condition the
// next one should see, and hiding it would flatter the result.
func Run(ctx context.Context, targets []*Target, ops []Op, stages []Stage, slo SLO) (*Report, error) {
if len(targets) == 0 {
return nil, errors.New("loadgen: no targets")
}
if len(ops) == 0 {
return nil, errors.New("loadgen: no operations")
}
report := &Report{
SLO: slo, Started: time.Now().UTC(), FirstBreach: map[Mode]*Breach{},
}
for _, target := range targets {
report.Targets = append(report.Targets, target.Account)
}
for index, stage := range stages {
// An interrupted run reports the stages that finished rather than
// nothing: someone who stops a ramp still wants the rungs it climbed.
select {
case <-ctx.Done():
report.Ended = time.Now().UTC()
return report, nil
default:
}
result := runStage(ctx, targets, ops, stage)
result.Breached = breachedOps(result, slo)
report.Stages = append(report.Stages, result)
if len(result.Breached) > 0 && report.FirstBreach[stage.Mode] == nil {
report.FirstBreach[stage.Mode] = &Breach{
StageIndex: index, Concurrency: stage.Concurrency,
RPS: stage.RPS, Ops: result.Breached,
}
}
}
report.Ended = time.Now().UTC()
return report, nil
}
func runStage(ctx context.Context, targets []*Target, ops []Op, stage Stage) StageResult {
stage.DurationSec = stage.Duration.Seconds()
started := time.Now()
var samples []sample
switch stage.Mode {
case ModeOpen:
samples = runOpen(ctx, targets, ops, stage)
case ModeClosed:
fallthrough
default:
stage.Mode = ModeClosed
samples = runClosed(ctx, targets, ops, stage)
}
ended := time.Now()
return StageResult{
Stage: stage,
Ops: summarise(samples, ops, ended.Sub(started)),
Started: started.UTC(),
Ended: ended.UTC(),
}
}
// runClosed holds a fixed number of callers in flight. Each worker walks the
// operation list so every operation gets the same share of the concurrency,
// rather than whichever one happens to be fastest getting the most turns.
func runClosed(ctx context.Context, targets []*Target, ops []Op, stage Stage) []sample {
concurrency := max(stage.Concurrency, 1)
stageCtx, cancel := context.WithTimeout(ctx, stage.Duration)
defer cancel()
collected := make([][]sample, concurrency)
var wg sync.WaitGroup
for worker := range concurrency {
wg.Add(1)
go func(worker int) {
defer wg.Done()
target := targets[worker%len(targets)]
for turn := 0; stageCtx.Err() == nil; turn++ {
op := ops[(worker+turn)%len(ops)]
if result, ok := timeCall(stageCtx, target, op, time.Now()); ok {
collected[worker] = append(collected[worker], result)
}
}
}(worker)
}
wg.Wait()
var samples []sample
for _, worker := range collected {
samples = append(samples, worker...)
}
return samples
}
// runOpen issues requests on a fixed schedule regardless of how the server is
// coping, and times each one from when it was *due* rather than when it
// started. Timing from the start instead would hide the queue: a request held
// up for a second before it was issued would report as fast.
func runOpen(ctx context.Context, targets []*Target, ops []Op, stage Stage) []sample {
rps := stage.RPS
if rps <= 0 {
rps = 1
}
interval := time.Duration(float64(time.Second) / rps)
stageCtx, cancel := context.WithTimeout(ctx, stage.Duration)
defer cancel()
// In-flight requests are capped so a stalled cluster cannot make the
// generator allocate without bound. Requests over the cap are recorded as
// shed, which is the honest way to say the rate was not sustained.
maxInFlight := stage.MaxInFlight
if maxInFlight < 1 {
maxInFlight = int(math.Max(rps*maxInFlightSeconds, minInFlight))
}
inFlight := make(chan struct{}, maxInFlight)
var mu sync.Mutex
var samples []sample
var wg sync.WaitGroup
ticker := time.NewTicker(interval)
defer ticker.Stop()
for tick := 0; ; tick++ {
select {
case <-stageCtx.Done():
wg.Wait()
return samples
case due := <-ticker.C:
target := targets[tick%len(targets)]
op := ops[tick%len(ops)]
select {
case inFlight <- struct{}{}:
default:
mu.Lock()
samples = append(samples, sample{op: op.Name, errCode: shedCode, failed: true})
mu.Unlock()
continue
}
wg.Go(func() {
defer func() { <-inFlight }()
result, ok := timeCall(stageCtx, target, op, due)
if !ok {
return
}
mu.Lock()
samples = append(samples, result)
mu.Unlock()
})
}
}
}
const (
// How many seconds of requests may be in flight at once in open loop.
maxInFlightSeconds = 4.0
minInFlight = 8.0
)
// timeCall runs one operation and measures from due, which is the time the
// request was scheduled for. In closed loop that is the moment the call
// starts; in open loop it is the tick, so queueing counts against the result.
//
// It reports false for a request the stage's own deadline cut short. Those are
// not the cluster's failures — every worker has one in flight when time is up,
// so counting them would put a fixed error per worker into every stage and a
// truncated latency into every distribution.
func timeCall(ctx context.Context, target *Target, op Op, due time.Time) (sample, bool) {
err := op.Call(ctx, target)
if err != nil && ctx.Err() != nil && cancelledByStage(err) {
return sample{}, false
}
result := sample{op: op.Name, latency: time.Since(due)}
if err != nil {
result.failed = true
result.errCode = ErrorCode(err)
}
return result, true
}
// cancelledByStage reports whether an error is the shape a cancelled request
// takes. The caller checks the stage context separately, so a per-request
// timeout against a stalled cluster is still counted as the failure it is.
func cancelledByStage(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
return ErrorCode(err) == cancelledCode
}
// The code aws-sdk-go gives a request cancelled through its context.
const cancelledCode = "RequestCanceled"
func summarise(samples []sample, ops []Op, elapsed time.Duration) map[string]OpStats {
kinds := make(map[string]Kind, len(ops))
for _, op := range ops {
kinds[op.Name] = op.Kind
}
latencies := map[string][]float64{}
stats := map[string]OpStats{}
for _, s := range samples {
entry, ok := stats[s.op]
if !ok {
entry = OpStats{Op: s.op, Kind: kinds[s.op], ErrorCodes: map[string]int{}}
}
entry.Count++
if s.failed {
entry.Errors++
entry.ErrorCodes[s.errCode]++
}
stats[s.op] = entry
// A shed request has no latency to report: it was never issued, and
// folding a zero into the distribution would improve the percentiles
// of a stage that failed to keep up.
if s.errCode != shedCode {
latencies[s.op] = append(latencies[s.op], float64(s.latency)/float64(time.Millisecond))
}
}
for name, entry := range stats {
values := latencies[name]
sort.Float64s(values)
entry.MeanMS = round2(mean(values))
entry.P50MS = round2(percentile(values, 0.50))
entry.P90MS = round2(percentile(values, 0.90))
entry.P99MS = round2(percentile(values, 0.99))
if len(values) > 0 {
entry.MaxMS = round2(values[len(values)-1])
}
if elapsed > 0 {
entry.AchievedRPS = round2(float64(entry.Count) / elapsed.Seconds())
}
if len(entry.ErrorCodes) == 0 {
entry.ErrorCodes = nil
}
stats[name] = entry
}
return stats
}
// percentile is nearest-rank over sorted values: the smallest sample at or
// above the quantile. No interpolation, so every reported number is a request
// that actually happened.
func percentile(sorted []float64, quantile float64) float64 {
if len(sorted) == 0 {
return 0
}
rank := min(max(int(math.Ceil(quantile*float64(len(sorted)))), 1), len(sorted))
return sorted[rank-1]
}
func mean(values []float64) float64 {
if len(values) == 0 {
return 0
}
var total float64
for _, v := range values {
total += v
}
return total / float64(len(values))
}
func round2(value float64) float64 {
return math.Round(value*100) / 100
}
// breachedOps names every operation in a stage that missed the SLO, either on
// p99 or by failing at all. An empty result means the stage passed.
func breachedOps(result StageResult, slo SLO) []string {
expected := make(map[string]bool, len(slo.ExpectedErrorCodes))
for _, code := range slo.ExpectedErrorCodes {
expected[code] = true
}
var breached []string
for name, stats := range result.Ops {
limit := slo.ReadP99
if stats.Kind == KindWrite {
limit = slo.WriteP99
}
if limit > 0 && stats.P99MS > float64(limit)/float64(time.Millisecond) {
breached = append(breached, name)
continue
}
for code, count := range stats.ErrorCodes {
if count > 0 && !expected[code] {
breached = append(breached, name)
break
}
}
}
sort.Strings(breached)
return breached
}
// Summary renders the report the way an operator reads it: one line per
// operation per stage, then the capacity number.
func Summary(report *Report) string {
out := fmt.Sprintf("endpoint %s region %s tenants %d\n",
report.Endpoint, report.Region, len(report.Targets))
for index, stage := range report.Stages {
out += fmt.Sprintf("\n[%d] %s ", index, stage.Stage.Mode)
if stage.Stage.Mode == ModeOpen {
out += fmt.Sprintf("%.0f rps", stage.Stage.RPS)
} else {
out += fmt.Sprintf("concurrency %d", stage.Stage.Concurrency)
}
out += fmt.Sprintf(" for %.0fs\n", stage.Stage.DurationSec)
for _, name := range sortedOps(stage.Ops) {
s := stage.Ops[name]
out += fmt.Sprintf(" %-28s n=%-6d err=%-4d rps=%-7.2f p50=%-8.1f p90=%-8.1f p99=%-8.1f max=%.1f\n",
name, s.Count, s.Errors, s.AchievedRPS, s.P50MS, s.P90MS, s.P99MS, s.MaxMS)
}
if len(stage.Breached) > 0 {
out += fmt.Sprintf(" breached: %v\n", stage.Breached)
}
}
out += "\n"
for _, mode := range []Mode{ModeClosed, ModeOpen} {
breach, ok := report.FirstBreach[mode]
if !ok || breach == nil {
out += fmt.Sprintf("%s: no breach at any stage run\n", mode)
continue
}
if mode == ModeOpen {
out += fmt.Sprintf("open: first breach at %.0f rps (stage %d) on %v\n",
breach.RPS, breach.StageIndex, breach.Ops)
continue
}
out += fmt.Sprintf("closed: first breach at concurrency %d (stage %d) on %v\n",
breach.Concurrency, breach.StageIndex, breach.Ops)
}
return out
}
func sortedOps(ops map[string]OpStats) []string {
return slices.Sorted(maps.Keys(ops))
}