harness

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package harness is the load generator the resilience and load suites share.

It is the RT-8 command's own generator, lifted out of it and given a ramp. Three properties are the reason it exists rather than a library being pulled in, and every one of them is a decision somebody has to be able to read:

  • It counts from the client. What a server reports about itself under overload is what it managed to record, and a request that never got a worker never appears in it.
  • It paces independently of the responses. A generator whose next request waits for the last answer measures itself: when the installation slows down, the offered load falls with it and the numbers stay beautiful. That is coordinated omission, and it is the single most common way a load test lies.
  • It records a timeline and the class of every call. "P95 held over ten minutes" and "P95 held while the shedding was engaged" are different claims, and only the second one is evidence for RT-6.

Index

Constants

View Source
const Tick = 5 * time.Second

Tick is the resolution of the timeline. Five seconds is fine enough to place an event - a rollout, the moment shedding engaged - inside a run, and coarse enough that the report stays readable.

Variables

This section is empty.

Functions

This section is empty.

Types

type Baseline

type Baseline struct {
	Name       string `json:"name"`
	RecordedAt string `json:"recorded_at"`
	// Hardware is the machine the figures were measured on, and it is load-bearing: comparing a
	// run against figures from different iron measures the iron. A run on hardware this does not
	// name records its measurement and compares nothing (observability-reliability.md §13.2).
	Hardware string `json:"hardware"`
	Dataset  struct {
		Tenants int `json:"tenants"`
		Items   int `json:"items"`
	} `json:"dataset"`
	Figures map[string]Figure `json:"figures"`
}

Baseline is a set of figures, recorded on a named machine over a named dataset.

func LoadBaseline

func LoadBaseline(path string) (Baseline, error)

LoadBaseline reads a baseline from the repository.

func (Baseline) Compare

func (b Baseline) Compare(measured map[string]float64) (regressions []Regression, missing []string)

Compare answers the one question this tier is allowed to answer: did anything get significantly worse?

A figure the run did not measure is a miss rather than a pass - a guard that silently ignores what disappeared would go green on the day the measurement broke. A figure the run measured and the baseline does not carry is not an error: a new figure is added to the baseline when somebody has a number worth recording, not on the first run that produces one.

type Bucket

type Bucket struct {
	SecondsIn       int `json:"seconds_in"`
	OfferedRate     int `json:"offered_rate"`
	Requests        int `json:"requests"`
	NonSuccess      int `json:"non_success"`
	ServerErrors    int `json:"server_errors"`
	Shed            int `json:"shed"`
	TransportErrors int `json:"transport_errors"`
	// InteractiveP95 is the number RT-6 is about, per interval rather than over the run: an
	// average that holds while the middle three minutes were terrible is not evidence.
	InteractiveP95 int64 `json:"interactive_p95_ms"`
	// contains filtered or unexported fields
}

Bucket is one interval of the timeline.

type Class

type Class string

Class is what a request is worth waiting for, and it is the same two-way split the load shedder makes (infrastructure/resilience). The harness has to know it because the whole of RT-6 is a statement about one class while the other is being refused.

const (
	// ClassInteractive is a person waiting. Its latency is the number RT-6 is about.
	ClassInteractive Class = "interactive"
	// ClassDeferrable is bulk, export, search and the query shapes: the work that is shed.
	ClassDeferrable Class = "deferrable"
)

type Direction

type Direction string

Direction says which way is worse. Latency and memory rise; throughput falls. A figure without one would be a comparison that silently reads half of its regressions as improvements.

const (
	LowerIsBetter  Direction = "lower_is_better"
	HigherIsBetter Direction = "higher_is_better"
)

type Figure

type Figure struct {
	Value     float64   `json:"value"`
	Direction Direction `json:"direction"`
	// BandPercent is how far the figure may move in the worse direction before it is a
	// regression. It is per figure because the figures are not equally noisy: a P95 on a shared
	// runner moves far more than a count of refusals does.
	BandPercent float64 `json:"band_percent"`
	// MinimumDelta is the smallest absolute movement worth calling a regression, and it exists
	// because a percentage band is the wrong shape for a small number. A P95 of six milliseconds
	// with a 60 % band is red at ten, and ten is what one garbage collection during the run looks
	// like - the guard would report the machine and be turned off within a week. Both conditions
	// have to hold: past the band *and* moved by at least this much.
	MinimumDelta float64 `json:"minimum_delta,omitempty"`
	// Note says what the figure is, in words, so that a red build is readable without this file
	// beside the test that produced it.
	Note string `json:"note,omitempty"`
}

Figure is one recorded number and the band around it.

type Latency

type Latency struct {
	Count int   `json:"count"`
	P50   int64 `json:"p50"`
	P95   int64 `json:"p95"`
	P99   int64 `json:"p99"`
	Max   int64 `json:"max"`
}

Latency is what a run felt like, not what it promised.

func Percentiles

func Percentiles(samples []int64) Latency

Percentiles sorts a copy rather than the caller's slice: the samples belong to a recorder that is still holding them, and sorting them in place would reorder a live buffer.

type Pacer

type Pacer struct {
	// contains filtered or unexported fields
}

Pacer hands out permission to make a request, at the plan's current rate, shared by every worker.

A rate held by the workers themselves would drift with latency: eight workers waiting 200 ms each is forty requests a second only while every answer is instant. This is the coordinated omission discipline, and it is why the permits are produced by the clock rather than by the answers.

func NewPacer

func NewPacer(ctx context.Context, plan Plan, started time.Time) *Pacer

NewPacer starts producing permits at the plan's rate until the context ends or the plan does.

The goroutine is bare, and it may be: this package is a test harness, and rule 5 governs the production tree. Everything it owns ends with the context.

func (*Pacer) Wait

func (p *Pacer) Wait(ctx context.Context) bool

Wait blocks until this request may be made, and reports false when the run is over.

type Plan

type Plan []Stage

Plan is a ramp. One stage is a flat run, which is what RT-8 has always done; several stages are how capacity is found - the rate is raised until the answers stop keeping up, and the stage in which that happened is visible in the timeline.

Expressed as stages rather than as a start, an end and a slope because a ramp with a plateau is the useful shape: rise, hold, look. A slope alone never holds anything long enough for a P95 to mean something.

func FlatPlan

func FlatPlan(perSecond int, duration time.Duration) Plan

FlatPlan is the one-stage plan: a rate held for a duration.

func (Plan) Duration

func (p Plan) Duration() time.Duration

Duration is how long the whole plan takes.

func (Plan) Peak

func (p Plan) Peak() int

Peak is the highest rate the plan offers. It is what a report calls the run's ceiling.

func (Plan) RateAt

func (p Plan) RateAt(elapsed time.Duration) int

RateAt is the offered rate this far into the run, and zero once the plan is over.

A step function rather than an interpolation between stages: a stage is a measurement, and a rate that is still moving while the percentile is being taken is a percentile of nothing in particular.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder collects what every worker sees.

One mutex rather than per-worker counters merged at the end: the timeline needs each observation placed in time, and the contention of a few thousand requests a minute is not worth a lock-free design.

func NewRecorder

func NewRecorder(start time.Time, plan Plan) *Recorder

func (*Recorder) Created

func (r *Recorder) Created() []string

Created is what was written, in the order it was written.

func (*Recorder) LastClosed

func (r *Recorder) LastClosed(now time.Time) Bucket

LastClosed is the interval that has just ended, and it never creates one. The progress ticker fires on the boundary, so the interval current at that moment is always the one nobody has written to yet - reporting it printed a run of zeroes through a run that was working.

func (*Recorder) Observe

func (r *Recorder) Observe(class Class, status int, took time.Duration, err error)

Observe records one answer. A status of zero with an error is a transport failure - a connection reset carries no status code at all, and a run that only counted 5xx would call that a success.

func (*Recorder) Remember

func (r *Recorder) Remember(id string)

Remember keeps an identifier the installation said it created, so that the run can go looking for all of them afterwards.

func (*Recorder) Shed

func (r *Recorder) Shed(class Class) int

Shed is how many calls of a class were refused. Read while the run is still going, which is what lets a test say the mechanism engaged before it says anything about latency.

func (*Recorder) Summarise

func (r *Recorder) Summarise(ended time.Time) Summary

Summarise closes the run. It takes no lock beyond its own: every worker is finished by the time a caller asks for this.

func (*Recorder) Throughput

func (r *Recorder) Throughput(from, to time.Duration) float64

Throughput is how many calls the installation actually answered per second over a window, counting neither the refusals nor the connections that never got one.

It is the capacity figure and it only means something once the offered rate is past what the process can serve: below saturation this returns the offered rate, because that is what was asked of it. Which is why the capacity figure comes from an overload stage and not from a steady one - a run that kept up measured the client.

func (*Recorder) Window

func (r *Recorder) Window(class Class, from, to time.Duration) Latency

Window is the latency of one class over a slice of the run, pooled from the raw samples rather than averaged out of the per-interval percentiles. A percentile of percentiles is not a percentile of anything, and the stage comparison RT-6 rests on would be built out of one.

from is inclusive and to is exclusive, both measured from the start of the run. It exists so a ramp can be read stage by stage: the claim worth making is not "the P95 held over the run" but "the P95 under overload was within a factor of the P95 before it".

type Regression

type Regression struct {
	Figure   string  `json:"figure"`
	Baseline float64 `json:"baseline"`
	Measured float64 `json:"measured"`
	// Allowed is the worst value that would still have passed - the edge of the band, so that a
	// red build says how far past it the run landed rather than only that it was past.
	Allowed float64 `json:"allowed"`
	Note    string  `json:"note,omitempty"`
}

Regression is one figure that moved too far the wrong way.

func (Regression) String

func (r Regression) String() string

type Stage

type Stage struct {
	// PerSecond is the offered rate over the whole generator, not per worker.
	PerSecond int
	// For is how long this step is held.
	For time.Duration
}

Stage is one step of a ramp: an offered rate, held for a while.

type Summary

type Summary struct {
	StartedAt time.Time `json:"started_at"`
	EndedAt   time.Time `json:"ended_at"`
	// OfferedPeak is the plan's ceiling. Recorded beside the achieved rate, because a run that
	// offered 400 a second and achieved 120 is a capacity finding rather than a failed run.
	OfferedPeak     int                `json:"offered_peak_per_second"`
	Requests        int                `json:"requests"`
	ByStatus        map[string]int     `json:"by_status"`
	Shed            map[string]int     `json:"shed_by_class"`
	TransportErrors int                `json:"transport_errors"`
	ErrorExamples   []string           `json:"transport_error_examples,omitempty"`
	Latency         map[string]Latency `json:"latency_ms_by_class"`
	Timeline        []Bucket           `json:"timeline"`
}

Summary is the finding of one run, and it is JSON because it ends up quoted in an evidence document and compared against a stored baseline.

func (Summary) ServerErrors

func (s Summary) ServerErrors() int

ServerErrors is how many answers were a 5xx that the harness did not ask for. A shed 503 is excluded: it is the mechanism working, not the installation failing.

type Window

type Window struct {
	From time.Duration
	To   time.Duration
}

Window is a slice of a run, measured from its start: from is inclusive, to is exclusive. It exists as a type so that a caller narrowing one - the last half of a stage rather than the whole of it - says so in one place instead of arithmetic at the call site.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL