metronome

package module
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 14 Imported by: 0

README

metronome

CI Coverage Go Reference

A protocol-agnostic Go load kernel: drive a unit of work at a controlled, live-adjustable rate across N workers, and measure latency and errors.

metronome knows nothing about HTTP, gRPC, Prometheus, configuration files or UI. You supply a Runner — one unit of work — and a RateController, and it gives you a stream of Results and an aggregator that turns them into percentiles.

go get github.com/RomanAgaltsev/metronome

Example

runner := metronome.RunnerFunc(func(ctx context.Context) metronome.Result {
	start := time.Now()
	resp, err := http.Get("http://localhost:8080/api/users/42")
	if err != nil {
		return metronome.Result{Start: start, Latency: time.Since(start), Err: err}
	}
	defer resp.Body.Close()
	n, _ := io.Copy(io.Discard, resp.Body)
	return metronome.Result{
		Start:   start,
		Latency: time.Since(start),
		Code:    strconv.Itoa(resp.StatusCode),
		Bytes:   n,
	}
})

d := metronome.Driver{
	Runner:      runner,
	Rate:        metronome.Ramp{Start: 10, End: 200, Over: 30 * time.Second},
	Workers:     16,
	MaxRequests: 5000,
}

stats := metronome.NewStats()
for r := range d.Run(context.Background()) {
	stats.Record(r)
}

fmt.Println(stats.Snapshot())
// 5000 req, 198.7 rps, 0.14% err (0 saturated), p50/p95/p99 12ms/41ms/88ms,
// corrected p95/p99 41ms/89ms, behind schedule 1.2ms

Snapshot is a plain struct — reach for the fields when you want them (snap.P95, snap.Codes, snap.Bytes). String() is there so every consumer does not reinvent the same summary line, and so the numbers that matter appear in the order they should be read.

You must drain the channel until it closes, or cancel the context — abandoning a live channel leaks the workers.

What's in it

Piece What it does
Runner / RunnerFunc one unit of work; the integration seam
Mix(...Weighted) weighted random pick among sub-Runners
Constant, Ramp, Phased static rate profiles
Adaptive + SetRate rate driven live from outside (a control loop)
Sine a smooth periodic curve — diurnal load, where a phase table would step
Sum(...RateController) add controllers together — a spike on a baseline, or a floor under Adaptive
Repeat(c, period) cycle any finite shape forever; Repeat(p, p.Duration()) for a phase table
Scale(factor, c) run an agreed profile at a fraction of its intensity
Driver the paced worker pool; Run(ctx) <-chan Result
Pacing + ClosedLoop / OpenLoop how the Driver reacts when the target cannot keep up
Burst rate-limiter burst size; 0 means 1 (smoothest schedule)
ErrSaturated open-loop marker: no worker was free at the scheduled time
Stats / Snapshot HDR-histogram percentiles, error rate, achieved rps, bytes/codes
RollingStats / Rolling the same aggregate over a trailing window, for watching a live run
Snapshot.Window the duration a Snapshot covers; zero means cumulative
Snapshot.Saturated how much of the error rate was the target refusing work
Snapshot.MaxScheduleLag how far the generator itself fell behind its own schedule
Snapshot.String() the numbers a run is judged on, in the order to read them
Snapshot.Corrected* coordinated-omission-corrected percentiles (see the caveat below)
Clock / ManualClock injected time, for deterministic tests
Recorder the aggregate seam: Record + Snapshot; Stats, RollingStats and LabeledStats all satisfy it
LabeledStats / Labeled per-label breakdown — a child Recorder per Result.Labels value, plus a real total
MaxSeries + __overflow__ cardinality cap, so a mislabelled run loses reporting detail rather than memory
Multi(...Recorder) fan one Result to several recorders, without a second channel
Filter(keep, rec) record only the Results a predicate accepts
After / AfterTime / AfterN warmup exclusion, anchored on Result.Scheduled
Skipper.Skipped() what an exclusion left out, so Count + Skipped is the whole population
Drain(ch, rec) the canonical result-channel loop, named so it stops being rewritten
Stats.Merge fold one Stats into another — ad-hoc subtotals, sharded recording, combining runs
Bytes() on Stats / RollingStats / LabeledStats what the aggregate currently holds — a RollingStats starts far under its budget
Rolling.Bytes() price a ring configuration before building it; the ceiling every bucket promoting would reach
Phased.PhaseEnd / Phased.Duration phase boundaries, so a warmup and a phase table cannot drift

Pacing model — read this before trusting the numbers

metronome offers two pacing modes and reports latency two ways. The defaults are conservative; the honest defaults for a latency-measuring tool are the other ones.

Closed loop (default)

Each worker waits for a rate-limiter token, calls your Runner, and only then asks for the next token. Simple, self-throttling, and the right choice when you are the backpressure mechanism (a control loop that deliberately probes for the point where the target sags). Two consequences:

  • Rate sag. When the target slows, workers sit inside Do, tokens go unclaimed, and the achieved rate falls below the offered rate — silently.
  • Coordinated omission. Latency is measured from when work actually started, so every stall suppresses exactly the samples that would have revealed it.
Open loop (Pacing: metronome.OpenLoop)

One dispatcher keeps the schedule and never blocks on the target. A unit that finds no free worker is delivered immediately as a Result whose Err matches errors.Is(err, metronome.ErrSaturated). Saturation therefore shows up as Snapshot.ErrorRate, not as invisible sag — and Snapshot.Saturated counts it separately, so "my generator ran out of workers" stays distinguishable from "the target failed". Workers becomes the maximum in-flight cap.

Open loop paces from one dispatcher goroutine, which sleeps once per unit of work. Below roughly a 250µs interval that sleep costs more than the interval, and the dispatcher — not the target — becomes the bottleneck: on the machine measured below, open loop holds 1,000 rps exactly and delivers only 74% of 5,000 rps, with zero ErrSaturated results, because the target was never asked. Snapshot.MaxScheduleLag is what makes that visible; see the table below. Closed loop does not have this ceiling (its Workers goroutines sleep concurrently, so each individual sleep is Workers times longer). Above ~1,000 rps, check MaxScheduleLag, and prefer closed loop if throughput matters more to you than schedule fidelity.

Raw vs corrected percentiles

Every Result from a Driver carries Scheduled, the time it should have been sent. Stats reports both:

  • P50 / P95 / P99 — measured from when work started. Optimistic under stall.
  • CorrectedP50 / CorrectedP95 / CorrectedP99 — measured from when the work was due, i.e. including the queueing delay a schedule-faithful client would have suffered.

Read them as a pair. A large gap means the generator queued: the raw numbers understate what a real client would experience by roughly that amount.

snap := stats.Snapshot()
fmt.Printf("p95 %v (corrected %v), achieved %.1f rps, %v behind schedule\n",
	snap.P95, snap.CorrectedP95, snap.RPS, snap.MaxScheduleLag)

The schedule is anchored: Scheduled is the run's origin plus one interval per unit dispatched, advanced whether or not the generator got there on time. A late generator is therefore measured against the schedule rather than redefining it. One worker offered 100 rps against a 50ms target achieves 19.9 rps and reports:

raw       P50=50.0ms  P99=50.0ms
corrected P50=412.9ms P99=816.1ms   MaxScheduleLag=765.7ms

The 816ms is the honest number: by the twentieth request, a client that kept to the 10ms schedule would have been waiting that long. (Before v0.3 both lines read 50ms.)

Who fell behind — you or the target?

Snapshot.Saturated and Snapshot.MaxScheduleLag answer that between them:

Saturated MaxScheduleLag Meaning
0 ~0 the schedule was kept and the target kept up
> 0 ~0 the target could not keep up — open loop working as designed
0 large the generator could not keep up — lower the rate, or use closed loop

The third row is why MaxScheduleLag exists: ErrSaturated can only report a target that was too slow, never a dispatcher that was. At 5,000 rps open loop reports Saturated=0, MaxScheduleLag=330ms — nothing was wrong with the target.

Rolling window

Every number a Snapshot carries is cumulative over the whole run. That is right for the report you print at the end and wrong for anything watching a run while it happens: MaxScheduleLag is a running maximum, so one stall in the first second pins it for the rest of the run, and a target that stops answering never moves any cumulative number at all — there are no new Results to move them, so RPS goes on reporting the rate the run used to achieve.

RollingStats records into both views at once. Use Snapshot() for the end-of-run report — it means exactly what Stats.Snapshot() means — and Window() for a control loop, a progress line, or anything else reading a run live.

rs := metronome.NewRollingStats(metronome.Rolling{Window: 10 * time.Second, Buckets: 10})
for r := range d.Run(ctx) {
	rs.Record(r)
}

// live, from a control loop or a progress ticker:
fmt.Println(rs.Window())   // last 9.7s: 4821 req, 497.0 rps, 0.00% err (0 saturated), ...
// at the end:
fmt.Println(rs.Snapshot()) // 50210 req, 499.1 rps, 0.02% err (0 saturated), ...

The zero Rolling{} is valid: a 10-second window in 10 buckets on the wall clock over the NewStats histogram range. Pass a Clock to make window tests exact, the same ManualClock the Driver takes.

Window() divides by the time the ring actually covers, not by the nominal window, and reports that duration in Snapshot.Window — non-zero on a windowed Snapshot, zero on a lifetime one, and String() prefixes last 9.7s: so the two are never confused in a log. So three seconds into a ten-second window a healthy 100 rps reads as 100.0 over Window: 3s rather than 30.0 over a window two-thirds empty, and at steady state the figure sawtooths across [window − interval, window] because the newest bucket is always partial. The numbers are correct for the period they cover; that field is what the period is.

Rotation happens on read as well as write, which is the whole point: with no Record traffic to drive it, only a read can notice that time has passed, so a full stall drains the window to zero instead of freezing it at the last healthy figures.

Measured on the machine described under Measured accuracy:

Operation Cost
Stats.Record 201 ns/op
RollingStats.Record 256 ns/op — about +55 ns, or 1.3×, for the second view
Window(), ring full of traffic 700 µs/op, 2 allocs
Window(), ring live but empty (a stall) 2.9 µs/op, 2 allocs

Record is the hot path and pays a flat 55 ns. Window() is not: it merges the live buckets into a dense scratch target and reads the percentiles off that, so it costs roughly live × countsLen — 0.7 ms at the default range and a full ring. Poll it at 1–10 Hz from a control loop, not per request. Empty buckets are skipped, so the stall case a control loop polls hardest in is the cheap one.

v0.9's sparse buckets did not make this cheaper, and the honest reason is worth stating: the cost is in the destination, not the sources. A sparse bucket merges as a walk over its entries rather than over a 17,408-slot array, but the scratch target is reset and quantile-read across its whole array either way, and that dominates. The one claim that did change is the old "independent of how many Results are in them": a sparse bucket's merge now scales with the distinct latencies it holds, so a busier bucket costs marginally more until it promotes. Reproduce with:

go test -run '^$' -bench 'BenchmarkStatsRecord|BenchmarkRollingStats' -benchtime=2s -benchmem ./...
Memory

A RollingStats is sized for Buckets+2 histogram pairs — the ring, the lifetime aggregate and a scratch merge target — so Buckets multiplies memory as well as resolution. Price any configuration before building it with Rolling.Bytes().

Rolling Ceiling
Rolling{} (10 buckets, 1µs–60s, 3 sig figs) 3.2 MiB
Rolling{Buckets: 100} 27 MiB
Rolling{Buckets: 1000} 266 MiB
Rolling{Buckets: 1000, Lo: time.Millisecond, Hi: time.Second, Sigfigs: 1} 2.0 MiB

These are ceilings, not what a run costs. Since v0.9 a ring bucket starts as a sparse value-to-count store and allocates a dense HDR histogram only once it holds enough distinct latencies to be worth one — about 2,785 distinct microseconds at the default range. Rolling.Bytes() is the budget above; RollingStats.Bytes() reports what is actually held, and is typically far below it. Measured on the 266 MiB row:

Rolling{Buckets: 1000} under load Held vs. ceiling
every bucket filled, 100 Results over 97 distinct latencies 9.8 MiB 27× smaller
a short burst, one bucket touched 0.54 MiB 492× smaller

A bucket that does promote keeps its histogram for the life of the ring, so a run whose latencies genuinely spread wide converges on the ceiling — which is why the ceiling is still the number to budget with. The last two table rows remain the rule: narrow the histogram range when you want a large ring. Rolling.Bytes() panics on exactly the configurations NewRollingStats panics on, so pricing an unbuildable config reports the problem rather than a number for it.

The representation is invisible in output: every Window() and Snapshot() field is identical whether a bucket is sparse or dense, clamp counters included, and a property test compares a sparse ring against a dense one on random streams to keep it that way.

Per-endpoint breakdown

A Mix of ten endpoints reports one P99 that no endpoint exhibits. LabeledStats splits the stream on one Result.Labels key:

stats := metronome.NewLabeledStats(metronome.Labeled[*metronome.Stats]{
	Key: "endpoint",
	New: metronome.NewStats,
})
for r := range d.Run(ctx) {
	stats.Record(r)
}

stats.Snapshot()                      // the total — same as a plain Stats
stats.Series()["search"].Snapshot()   // just that endpoint

The total is exactly what a plain Stats fed the same stream would have produced, so existing code holding a *Stats can hold one of these instead and see its numbers unchanged. The breakdown is something it gains, not something it trades for. Every Result lands in the total and in exactly one series, so Σ Series().Count == Snapshot().Count — the breakdown reads as a decomposition rather than as a sample.

LabeledStats is generic over the child recorder, so use Labeled[*metronome.RollingStats] for a trailing window per endpoint — stats.Series()["search"].Window(), typed, no assertion. That is the one that answers which endpoint is slow right now, which is the live question.

A Result whose Labels is nil, lacks the key, or holds an empty string for it goes to a named __none__ series rather than being dropped, because "which requests were not labelled?" is exactly the debugging question.

Cardinality is memory. Each series is a whole aggregate, not three counters, so MaxSeries (default 100) caps it and further values share an __overflow__ series:

Child Per series 10 series 100 series
*Stats, default range 272 KiB ~3.3 MiB ~27 MiB
*RollingStats, Rolling{} ~3.2 MiB ~38 MiB ~320 MiB

The bottom-right figure is the one to know before you type Labeled{Key: "endpoint"}. It is not a defect — it is ten buckets of two histograms for each of a hundred endpoints, every term of which you asked for — but series are created lazily, so a run only discovers it if the cardinality turns out to be real. That is what the cap is for: a label value that turns out to carry a request ID rather than a route name costs you reporting detail, not memory.

stats.Bytes() reports what is currently held, and -1 if the child type does not report its own size (which a custom Recorder is not required to do). The __none__ series counts against MaxSeries; __overflow__ does not.

Measured cost of the breakdown, on the machine described under Measured accuracy. Every row records the identical Result, so the delta is the breakdown and nothing else:

ns/op allocs
a flat Stats, same Results 107 0
LabeledStats, 1 series 389 0
LabeledStats, 10 series 342 0
LabeledStats, 100 series 352 0

A breakdown costs about 3.3× a flat Stats — roughly +250 ns per Result, zero allocations. Two aggregates rather than one is about half of that; the rest is the label lookup, the RWMutex and the extra lock traffic. It is not free, and at 350 ns it is still far below any real request.

The shape matters more than the level: 100 series costs 0.90× what 1 series costs, so the map does not bind as cardinality grows. With a single series every goroutine contends on that one child's mutex as well as the total's; spreading the traffic across a hundred children removes the child contention. A breakdown gets cheaper as it gets wider.

Stamping the label is a separate cost, paid by whoever sets Result.Labels whether or not anything reads it, and it is not counted above. Recording a Result carrying a one-entry Labels map into a plain Stats — which ignores it — costs 354 ns against 91 ns with Labels nil: +263 ns and 2 allocations, for the map alone. Leave it nil when you are not breaking down. Reproduce with:

go test -run '^$' -bench 'BenchmarkLabeledRecord|BenchmarkLabelStampCost' -benchtime=2s -benchmem ./...
Warmup exclusion and fan-out

The first seconds of a run measure cold connection pools, TLS handshakes that will be reused and a target that has not warmed up — none of which is the system under test. After keeps them out of the measurement without changing the load:

report  := metronome.NewStats()
byRoute := metronome.NewLabeledStats(metronome.Labeled[*metronome.Stats]{
	Key: "endpoint", New: metronome.NewStats,
})

measured := metronome.After(10*time.Second, metronome.Multi(report, byRoute))
metronome.Drain(d.Run(ctx), measured)

fmt.Println(report.Snapshot())
fmt.Printf("measured %d, excluded %d as warmup\n",
	report.Snapshot().Count, measured.Skipped())

Count + Skipped() is always the whole population, so an exclusion is auditable. "Measured 4,500 of 5,000 — 500 excluded as warmup" is the honest line; "4,500 requests" alone invites the reader to wonder where the rest went, and a threshold evaluated over a silently reduced population is the thing this exists to prevent.

The boundary anchors on the first Result's Scheduled stamp, so it is fixed by the stream rather than by when you built the recorder — measured against the schedule, not against when the generator got there. Pair it with Phased to keep one source of truth: metronome.After(rate.PhaseEnd(0), stats) cannot drift from the phase table the way a repeated literal duration can. Use AfterTime when the boundary must be exact (record clock.Now() immediately before Run and pass origin.Add(warmup)), and AfterN to count units instead of time. After(0) admits everything and never anchors, so an unset --warmup means exactly nothing.

Order is visible in the expression, and both readings are valid: After(w, Multi(report, byRoute)) gives two views of one warmed population; Multi(After(w, report), byRoute) gives a warmed report beside an unwarmed breakdown.

Multi is synchronous. Everything in one should be an in-memory aggregate. Time spent in Record is time not spent receiving from the result channel, so a slow writer fills the channel, then the Driver's delivery backlog, and in open loop surfaces as ErrSaturated — which reads as "the generator ran out of workers" and is counted inside Errors and ErrorRate. A slow file writer in a Multi can therefore present itself as a target problem. Put I/O behind your own buffered goroutine, where you own the policy for what happens when it falls behind.

Every recorder in a Multi receives the same Result, and Result holds Labels by reference, so treat Results as read-only: a recorder that mutates the map corrupts what the others see, including the series LabeledStats picks.

Composing a load shape

A RateController is a pure function of elapsed time, so shapes compose by arithmetic. Sine is the one curve composition cannot reach; Sum, Repeat and Scale reach the rest.

// Diurnal: a smooth curve between 100 and 1,000 rps, once an hour, starting low.
rate := metronome.Sine{Min: 100, Max: 1000, Period: time.Hour}

// A spike on a baseline: 100 rps, rising to 500 for one minute in every ten.
rate = metronome.Sum(metronome.Constant(100),
	metronome.Repeat(metronome.Phased{Phases: []metronome.Phase{
		{Duration: 9 * time.Minute, TargetRPS: 0},
		{Duration: 1 * time.Minute, TargetRPS: 400},
	}}, 10*time.Minute))

// The same agreed profile at half intensity — a canary, or a smaller blast radius.
rate = metronome.Scale(0.5, rate)

There is no Burst controller, and that is the point. A burst is a constant plus a repeating phase table, and the same three combinators also give you a burst on a ramp, a burst on a sine, two bursts at different periods, and a burst that is itself a curve. (The Burst in the table above is unrelated: it is the rate limiter's bucket size.)

Sine starts at Min and peaks at Period/2, so a run opens at its lowest load and rises, the way Ramp opens at Start. It is already periodic, so it is never wrapped in Repeat — Repeat is for finite shapes such as Phased and Ramp. Pair a phase table with its own length, Repeat(p, p.Duration()), and the cycle cannot drift from the table.

Two more compositions worth knowing, neither of which required Adaptive to change: Sum(metronome.Constant(50), adaptive) puts a floor under a control loop, so a feedback signal that collapses cannot drive the run down to nothing; and Scale(0.5, profile) reruns a named profile at half intensity.

Sum, Repeat and Scale panic at construction on a nil controller — Sum naming the argument index — rather than mid-run from the rate-updater goroutine. A non-positive Repeat period delegates to the inner controller instead of cycling, and a non-positive Sine.Period reports Min.

⚠️ The controller is sampled ten times a second — not once per request. The Driver re-reads it every 100 ms and the limiter holds the last value in between. So shape features shorter than about 200 ms do not exist: a one-second Sine gets ten samples and renders as visible steps, and a Repeat period near the sampling interval aliases into a shape unrelated to the one you asked for. Keep Period, and any spike inside it, at a second or more — ten times the sampling interval, which is where the rendered shape stops depending on it. The upside of the same fact: math.Cos runs 10 times a second for the whole run, so a composed controller costs nothing measurable no matter how deep it is.

Measured accuracy

Measured on an AMD Ryzen 5 3600 (6 cores / 12 threads), Windows 11, Go 1.26.6, machine otherwise idle. Adherence is achieved rps ÷ offered rps; 1.00 is perfect.

Offered rate Closed-loop adherence Open-loop adherence
10 rps 1.00 1.00
100 rps 1.00 1.00
1,000 rps 1.00 1.00
5,000 rps 1.00 0.74

The 0.74 is real and reproducible, and it is a limit of the generator, not of the target — see the open-loop note above. That run reports Saturated=0 and MaxScheduleLag=330ms, which is how you would tell without reading this table. Anchoring the schedule in v0.3 made the shortfall visible, not smaller: the adherence numbers are unchanged from v0.2. Reproduce with:

go test -run '^$' -bench BenchmarkDriverPaced -benchtime 3x ./...

Per-request kernel overhead (no-op Runner, unlimited rate, Workers = GOMAXPROCS): 600 ns/op closed-loop and 895 ns/op open-loop, i.e. a plumbing ceiling near 1.7M rps and 1.1M rps respectively. Note the gap between that ceiling and the 5,000 rps adherence figure above: metronome's limit at realistic rates is sleep granularity, not CPU. Stats.Record costs 201 ns/op under full contention (RollingStats.Record 256 — see Rolling window; LabeledStats under Per-endpoint breakdown). Reproduce with:

go test -run '^$' -bench 'BenchmarkDriverOverhead|BenchmarkStatsRecord' ./...

Status

v0.9 — API is stable in shape and pinned by two consumers, but pre-v1: minor versions may carry small breaking changes, always with a migration note in the CHANGELOG. Pin an exact version.

Nothing has been removed or changed in meaning since v0.3; v0.4 through v0.9 are additive but for one named exception. v0.9 changed what RollingStats.Bytes() reports — the memory actually held rather than the budgeted ceiling. No signature changed; Rolling.Bytes() is still the ceiling. The Driver, Result and Snapshot a v0.4 consumer compiled against behave identically today.

Used by

  • crescendo — PromQL-driven adaptive load: a Mix of endpoint Runners under an Adaptive controller steered by a Prometheus feedback loop.
  • quiver — qv load: one Runner over a saved API request, Constant/Ramp, MaxRequests, Stats summary.

License

MIT

Documentation

Overview

Package metronome drives a unit of work at a controlled, live-adjustable rate across N workers and measures latency and errors. It is protocol-agnostic: it knows nothing about HTTP, gRPC, Prometheus, configuration, or UI.

The two seams are Runner (what to send) and RateController (how fast). Driver paces the work. Stats aggregates the resulting Result stream into percentile [Snapshot]s.

Pacing model

Driver.Pacing selects between two modes.

ClosedLoop (the default, and v0.1's only behaviour) gives each worker a rate-limiter token, runs the work, and only then asks for the next token. It is self-throttling, but when the target slows the achieved rate sags below the offered rate — silently.

OpenLoop paces from a single dispatcher that never blocks on the target. A unit that finds no free worker is delivered immediately as a Result whose Err matches ErrSaturated, so saturation is counted rather than absorbed. Workers becomes the maximum in-flight cap. The dispatcher sleeps once per unit, so above roughly 4,000 rps it becomes the bottleneck itself; see the README's measured accuracy table.

Compare Snapshot.RPS against the rate you asked for on every run, in either mode.

Raw and corrected percentiles

Every Result produced by a Driver carries Result.Scheduled, the time the unit was due. The schedule is anchored: Scheduled is the run's origin plus one interval per unit dispatched, advanced whether or not the generator got there on time, so a late generator is measured against the schedule rather than redefining it.

Stats reports raw percentiles (P50/P95/P99, from when work started) alongside coordinated-omission-corrected ones (CorrectedP50/P95/P99, from when the work was due). Read them as a pair: a large gap means the generator queued, and the raw numbers understate what a real client would have suffered by roughly that much.

For a run being watched while it happens, use RollingStats instead of Stats. It records into both a lifetime aggregate and a ring of trailing buckets, so Snapshot keeps its cumulative meaning while Window reports only the recent past. A ring bucket stores its latencies sparsely until it holds enough distinct values to earn a dense histogram, so Rolling.Bytes is the ceiling a configuration budgets for and RollingStats.Bytes is what a run actually holds — usually far less. The representation is invisible: every Window and Snapshot field reads the same either way. The distinction matters most for Snapshot.MaxScheduleLag, which is a lifetime maximum: one early stall pins it for the rest of the run, and a target that stops answering never moves any cumulative number at all, because there are no new Results to move it.

Recording, breaking down, and composing

Recorder names what Stats and RollingStats both do. LabeledStats splits a Result stream on one Result.Labels key into a child Recorder per value plus a total, so a Mix of endpoints reports per-endpoint percentiles instead of one aggregate that describes no endpoint. It is generic over the child, so LabeledStats[*RollingStats] gives a trailing window per endpoint.

A series is a whole aggregate, so cardinality is capped: past MaxSeries distinct values, Results land in a single overflow series. Price a configuration with Bytes before paying for it.

Recorders compose. Multi fans one Result to several of them, Filter keeps only the Results a predicate accepts, and After, AfterTime and AfterN exclude a warmup period so that cold connection pools and unwarmed targets stay out of a threshold. Drain is the canonical loop over a Driver's result channel. Each is a pure Recorder transform: none owns a goroutine, a channel or a clock.

Diagnosing a run that fell short

Snapshot.Saturated counts units the target had no free worker for; Snapshot.MaxScheduleLag is how far the generator itself fell behind. Saturation with no lag means the target could not keep up. Lag with no saturation means metronome could not — lower the rate, or use ClosedLoop.

Composing a load shape

A RateController is a pure function of elapsed time, and four of them compose into the rest: Sine for a smooth periodic curve, and Sum, Repeat and Scale over any controller. A spike on a baseline is a constant plus a repeating phase table, which is why there is no Burst type.

The Driver samples the controller ten times a second, so shape features shorter than about 200ms do not exist and a cycle near that length aliases. Keep periods and spikes at a second or more.

Index

Examples

Constants

View Source
const DefaultBuckets = 10

DefaultBuckets is the ring size NewRollingStats uses when Rolling.Buckets is zero.

View Source
const DefaultMaxSeries = 100

DefaultMaxSeries is the number of distinct label values a LabeledStats gives their own child recorder before further values land in the overflow series.

View Source
const DefaultOverflowLabel = "__overflow__"

DefaultOverflowLabel names the series that absorbs label values past MaxSeries.

View Source
const DefaultResultBuffer = 1024

DefaultResultBuffer is the capacity of Run's result channel when Driver.ResultBuffer is zero.

View Source
const DefaultUnlabeledLabel = "__none__"

DefaultUnlabeledLabel names the series for Results carrying no usable value for the configured key.

View Source
const DefaultWindow = 10 * time.Second

DefaultWindow is the trailing window NewRollingStats uses when Rolling.Window is zero.

View Source
const DefaultWorkers = 10

DefaultWorkers is the worker count Run uses when Driver.Workers is not positive.

Variables

View Source
var ErrSaturated = errors.New("metronome: no worker free at scheduled time")

ErrSaturated marks an open-loop unit of work that found no free worker at its scheduled time. It is recorded rather than silently dropped, so saturation appears in Snapshot.ErrorRate instead of as invisible rate sag. Test for it with errors.Is.

Functions

func Drain added in v0.6.1

func Drain(ch <-chan Result, rec Recorder)

Drain records every Result from ch into rec until ch is closed. It is the canonical consumption loop, named so that consumers stop rewriting the one place where getting it wrong leaks goroutines.

It deliberately takes no context. The Driver's contract on its result channel is blunt — abandoning a live one leaks the units still in flight for the lifetime of the process — so a Drain that returned early on cancellation would be a leak generator in the shape of good practice. Cancel the Driver's context instead: it stops, closes the channel, and this returns because the range ended.

It panics if rec is nil, rather than on the first Result to arrive — which, on a live run, would be after the Driver had already started generating load.

Types

type Adaptive

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

Adaptive is a RateController whose rate is set externally. Safe for concurrent SetRate/Rate.

func NewAdaptive

func NewAdaptive(initial float64) *Adaptive

NewAdaptive returns an Adaptive controller starting at initial rps.

func (*Adaptive) Rate

func (a *Adaptive) Rate(time.Duration) float64

Rate reports the rate most recently passed to SetRate, ignoring elapsed time.

func (*Adaptive) SetRate

func (a *Adaptive) SetRate(rps float64)

SetRate sets the rate reported from the next Rate call onward. It is safe to call concurrently with Rate and from any goroutine.

A rate of zero or less is floored by the Driver to a token every ~10,000 seconds: effectively paused, and resumable — a later SetRate takes effect within one rate-update interval (see maxReservationWait), so pausing is not a one-way door. NaN is floored the same way rather than treated as "unlimited", because a control loop dividing by an empty PromQL vector produces NaN and flooding the target is the wrong direction to fail; see sanitizeRate.

type Clock

type Clock interface {
	// Now reports the current time.
	Now() time.Time

	// Sleep blocks for d, returning nil, or ctx.Err() if ctx is done first. A
	// non-positive d returns immediately with ctx.Err() (nil for a live ctx).
	Sleep(ctx context.Context, d time.Duration) error
}

Clock supplies time to the Driver. It is injected so that pacing is testable: with a ManualClock the whole pacing path — reservations, sleeps and the rate update cadence — advances only when the test says so.

func SystemClock

func SystemClock() Clock

SystemClock returns a Clock backed by the wall clock. It is what the Driver uses when Driver.Clock is nil.

type Constant

type Constant float64

Constant holds a fixed rate.

func (Constant) Rate

func (c Constant) Rate(time.Duration) float64

Rate reports the fixed rate, ignoring elapsed time.

type Driver

type Driver struct {
	Runner      Runner
	Rate        RateController
	Workers     int
	MaxRequests int

	// Clock supplies time to the whole pacing path: the rate schedule, the
	// sleeps between units of work, and the rate-update cadence. Leave nil for
	// the wall clock; inject a ManualClock to drive pacing exactly in tests.
	Clock Clock

	// ResultBuffer is the capacity of the channel Run returns. Zero selects
	// DefaultResultBuffer; a negative value selects an unbuffered channel.
	//
	// Buffering matters for measurement, not just throughput: on an unbuffered
	// channel a consumer that pauses blocks a worker that is holding a
	// rate-limiter token, producing rate sag the target did not cause.
	ResultBuffer int

	// Pacing selects ClosedLoop (default) or OpenLoop. See PacingMode.
	Pacing PacingMode

	// Burst is the rate limiter's burst size; 0 means 1. Burst 1 gives the
	// smoothest schedule. A larger burst lets the generator catch up in a bunch
	// after a stall, which is sometimes what you want and is never smooth.
	Burst int
}

Driver runs a Runner under a RateController across Workers goroutines, emitting Results on the channel Run returns until ctx is cancelled or MaxRequests results have been produced (MaxRequests == 0 means unlimited).

Contract: when MaxRequests is set, exactly MaxRequests Results are delivered unless ctx is cancelled first (cancellation aborts promptly and may drop in-flight results). In OpenLoop mode a saturated attempt counts as one of them. Results are NOT ordered — in OpenLoop a saturated unit is emitted immediately while an earlier unit is still running. The channel is closed once all internal goroutines exit; the caller must drain it or cancel ctx.

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	runner := metronome.RunnerFunc(func(context.Context) metronome.Result {
		return metronome.Result{Start: time.Now(), Latency: time.Millisecond}
	})
	d := metronome.Driver{Runner: runner, Rate: metronome.Constant(500), Workers: 4, MaxRequests: 100}
	stats := metronome.NewStats()
	for r := range d.Run(context.Background()) {
		stats.Record(r)
	}
	fmt.Println(stats.Snapshot().Count)
}
Output:
100

func (*Driver) Run

func (d *Driver) Run(ctx context.Context) <-chan Result

Run starts the workers and returns the channel Results are delivered on.

Run has a pointer receiver, so it cannot be called on a composite literal: bind the Driver to a variable first (d := Driver{...}; d.Run(ctx)).

The caller MUST drain the returned channel until it closes, or cancel ctx. Abandoning a live channel leaves the workers blocked on the send and leaks them for the lifetime of the process.

Run panics if Runner or Rate is nil — programmer errors, not runtime conditions. Workers <= 0 defaults to DefaultWorkers.

type Labeled added in v0.6.0

type Labeled[R Recorder] struct {
	// Key is the Result.Labels key to split on. Required.
	//
	// One key, not a set of them: a series here is a whole aggregate — at
	// least 272 KiB as a *Stats and about 3.2 MiB as a *RollingStats — so
	// keying on combinations would multiply memory by the cardinality of every
	// label a Result happens to carry.
	Key string

	// New constructs one child recorder. Required: there is no expression for
	// the zero value of an arbitrary R that would produce a working recorder,
	// so this cannot be defaulted. NewStats has exactly this signature:
	//
	//	Labeled[*Stats]{Key: "endpoint", New: NewStats}
	//
	// It is called lazily — once per distinct label value, plus once for the
	// total and once for overflow — so a run with four endpoints allocates six
	// children, not MaxSeries of them.
	New func() R

	// MaxSeries caps the number of distinct label values that get their own
	// child. 0 means DefaultMaxSeries.
	//
	// This is a memory bound, not a tuning knob. A label value that turns out
	// to carry a request ID rather than a route name would otherwise allocate a
	// child per request; past the cap those Results land in the Overflow series
	// instead, so a mislabelled run loses reporting detail rather than memory.
	//
	// The Unlabeled series counts against the cap, because it is created by the
	// same lazy path a real value takes. A run at the default that carries any
	// unlabeled traffic therefore breaks down 99 label values, not 100. The
	// Overflow series does not count against it — it is where the cap sends
	// things, so charging the cap for it would be circular.
	MaxSeries int

	// Overflow names the series absorbing label values past MaxSeries.
	// "" means DefaultOverflowLabel. A real label value equal to it shares that
	// series.
	Overflow string

	// Unlabeled names the series for Results whose Labels is nil, lacks Key, or
	// holds an empty string for it. "" means DefaultUnlabeledLabel. Such
	// Results get a named series rather than being dropped, because "which
	// requests were not labelled?" is the question being asked, and dropping
	// them would stop the series reconciling with the total.
	Unlabeled string
}

Labeled configures a LabeledStats. Key and New are required.

type LabeledStats added in v0.6.0

type LabeledStats[R Recorder] struct {
	// contains filtered or unexported fields
}

LabeledStats splits a Result stream on one Result.Labels key, accumulating a child Recorder per distinct value alongside a total fed by every Result.

Snapshot reports the total, and is exactly what a plain Stats fed the same stream would have produced — so existing code holding a *Stats can hold one of these instead and see its numbers unchanged, gaining the breakdown rather than trading for it.

It is generic over the child so that a breakdown of rolling windows keeps its type: LabeledStats[*RollingStats].Series()["get_user"].Window() needs no type assertion.

Nesting typechecks — *LabeledStats[R] is itself a Recorder — and is almost always a mistake: LabeledStats[*LabeledStats[*Stats]] multiplies the child count by both caps, which at the defaults is 100 x 100 x 272 KiB. A second dimension usually wants to be a second LabeledStats fed the same stream through Multi, not a nested one.

Safe for concurrent Record.

Example
package main

import (
	"context"
	"fmt"
	"sort"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	endpoint := func(name string, lat time.Duration) metronome.Runner {
		return metronome.RunnerFunc(func(context.Context) metronome.Result {
			return metronome.Result{
				Latency: lat,
				Labels:  map[string]string{"endpoint": name},
			}
		})
	}

	d := metronome.Driver{
		Runner: metronome.Mix(
			metronome.Weighted{Runner: endpoint("list", 5*time.Millisecond), Weight: 3},
			metronome.Weighted{Runner: endpoint("search", 80*time.Millisecond), Weight: 1},
		),
		Rate:        metronome.Constant(5000),
		Workers:     8,
		MaxRequests: 400,
	}

	stats := metronome.NewLabeledStats(metronome.Labeled[*metronome.Stats]{
		Key: "endpoint",
		New: metronome.NewStats,
	})
	for r := range d.Run(context.Background()) {
		stats.Record(r)
	}

	names := make([]string, 0, stats.Len())
	for name := range stats.Series() {
		names = append(names, name)
	}
	sort.Strings(names)

	series := stats.Series()
	list, search := series["list"].Snapshot(), series["search"].Snapshot()
	total := stats.Snapshot()

	fmt.Println("series:", names)
	fmt.Println("total count:", total.Count)

	// The motivating defect, shown rather than described. list is three
	// quarters of the traffic and sixteen times faster, but the aggregate P99
	// is search's alone — so the one number a flat Stats reports describes a
	// quarter of the requests and hides the rest.
	fmt.Println("list   p99:", list.P99.Round(time.Millisecond))
	fmt.Println("search p99:", search.P99.Round(time.Millisecond))
	fmt.Println("total  p99:", total.P99.Round(time.Millisecond))

	// And the series always reconcile with the total.
	fmt.Println("series sum == total:", list.Count+search.Count == total.Count)
}
Output:
series: [list search]
total count: 400
list   p99: 5ms
search p99: 80ms
total  p99: 80ms
series sum == total: true

func NewLabeledStats added in v0.6.0

func NewLabeledStats[R Recorder](cfg Labeled[R]) *LabeledStats[R]

NewLabeledStats returns a LabeledStats configured by cfg. It panics on the programmer errors listed on Labeled.

func (*LabeledStats[R]) Bytes added in v0.6.0

func (ls *LabeledStats[R]) Bytes() int64

Bytes reports the histogram memory currently held: the total plus every live series. It returns -1 if the child type does not report its own size, which a custom Recorder is not required to do — -1 rather than 0, because 0 is a plausible-looking number that would read as "free".

Series are lazy, so this is what is held now. The worst case is (MaxSeries + 2) x one child: the cap, plus overflow, plus the total.

func (*LabeledStats[R]) Len added in v0.6.0

func (ls *LabeledStats[R]) Len() int

Len reports how many series exist so far, including the overflow series once it has been created. Series are lazy, so this grows as label values are seen.

func (*LabeledStats[R]) Record added in v0.6.0

func (ls *LabeledStats[R]) Record(r Result)

Record adds r to the total and to exactly one series. It is safe to call concurrently.

Every Result lands in the total and in exactly one series, so once recording has stopped the sum of the series' Counts equals the total's. That reconciles at rest, not at every instant: the two child calls below are made in sequence and outside this LabeledStats' lock, so a reader polling a live run can catch a Result counted in the total and not yet in its series, or a series that exists with a Count of zero. Serialising them would make the invariant hold continuously at the cost of funnelling every series through one mutex, which is the contention this type is shaped to avoid.

func (*LabeledStats[R]) Series added in v0.6.0

func (ls *LabeledStats[R]) Series() map[string]R

Series returns the per-label recorders, including the overflow and unlabeled series once they exist.

The map is a copy the caller owns; the recorders in it are live and shared with this LabeledStats. Read them — Snapshot, and Window when the child type has one — but do not Record into them, which would count a Result in a series without counting it in the total.

func (*LabeledStats[R]) Snapshot added in v0.6.0

func (ls *LabeledStats[R]) Snapshot() Snapshot

Snapshot returns the total: the aggregate over every Result recorded, regardless of label. It is exactly what a plain Stats fed the same stream would have produced.

func (*LabeledStats[R]) Total added in v0.6.0

func (ls *LabeledStats[R]) Total() R

Total returns the recorder behind Snapshot, typed. Use it to reach a child method the Recorder seam does not carry — most usefully LabeledStats[*RollingStats].Total().Window().

type ManualClock

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

ManualClock is a Clock whose time only advances when Advance is called. Goroutines sleeping on it wake when Advance moves the clock to or past their deadline, which makes pacing tests exact instead of tolerance-based.

func NewManualClock

func NewManualClock(t time.Time) *ManualClock

NewManualClock returns a ManualClock reading t.

func (*ManualClock) Advance

func (m *ManualClock) Advance(d time.Duration)

Advance moves the clock forward by d, waking every sleeper whose deadline the clock has now reached, in deadline order.

func (*ManualClock) BlockUntilSleepers added in v0.2.0

func (m *ManualClock) BlockUntilSleepers(n int)

BlockUntilSleepers blocks until at least n goroutines are asleep on this clock. It exists to remove the race between a goroutine reaching Sleep and a test calling Advance: without it, an Advance that lands first is simply lost and the test hangs.

It has no timeout on purpose — if the count is never reached, go test's own deadline reports it with every goroutine's stack, which is more useful than a bespoke error.

func (*ManualClock) Now

func (m *ManualClock) Now() time.Time

Now reports the clock's current time.

func (*ManualClock) Sleep added in v0.2.0

func (m *ManualClock) Sleep(ctx context.Context, d time.Duration) error

Sleep blocks until Advance moves the clock to or past now+d, or ctx is done.

type PacingMode added in v0.2.0

type PacingMode int

PacingMode selects how the Driver reacts when the target cannot keep up.

const (
	// ClosedLoop is the default: a worker does not ask for its next token until
	// the current unit of work has completed. Simple and self-throttling, but a
	// slow target reduces the achieved rate, and latency percentiles are subject
	// to coordinated omission (Stats' Corrected* fields quantify how much).
	ClosedLoop PacingMode = iota

	// OpenLoop keeps the schedule regardless of the target: one dispatcher paces
	// and hands each unit to a free worker, or — if all Workers are busy —
	// records a Result carrying ErrSaturated and moves on. Saturation becomes a
	// counted signal instead of silent rate sag. Workers is the maximum
	// in-flight cap in this mode.
	OpenLoop
)

type PanicError added in v0.2.0

type PanicError struct {
	Value any
	Stack []byte
}

PanicError reports a panic raised inside a Runner. The Driver recovers it so that one bad unit of work cannot abort a whole load run; the panic is delivered as a failed Result instead, and Stack holds the stack captured at the point of recovery.

func (*PanicError) Error added in v0.2.0

func (e *PanicError) Error() string

type Phase

type Phase struct {
	Duration  time.Duration
	TargetRPS float64
}

Phase is one flat-rate segment of a Phased controller.

type Phased

type Phased struct {
	Phases []Phase
}

Phased steps through phases by elapsed time, holding the last phase's rate past the end.

func (Phased) Duration added in v0.6.1

func (p Phased) Duration() time.Duration

Duration reports the elapsed time at which the last phase ends, or zero when there are no phases.

Rate holds the last phase's rate past this point, so this is a measurement boundary rather than a stop condition — a Driver runs until its context ends or MaxRequests is reached, whatever the controller says.

func (Phased) PhaseEnd added in v0.6.1

func (p Phased) PhaseEnd(i int) time.Duration

PhaseEnd reports the elapsed time at which phase i ends: the sum of the durations of phases 0 through i. It panics if i is out of range.

Pair it with After to keep one source of truth for a warmup boundary:

measured := metronome.After(rate.PhaseEnd(0), stats)

rather than repeating the first phase's duration, where the two can drift and only one of them fails loudly.

func (Phased) Rate

func (p Phased) Rate(elapsed time.Duration) float64

Rate reports the TargetRPS of the phase covering elapsed, holding the last phase's rate past the end. It reports 0 when there are no phases.

type Ramp

type Ramp struct {
	Start, End float64
	Over       time.Duration
}

Ramp linearly interpolates from Start to End over the Over duration, then holds End.

func (Ramp) Rate

func (r Ramp) Rate(elapsed time.Duration) float64

Rate reports the interpolated rate at elapsed: Start at 0, End at Over and after. A non-positive Over reports End immediately.

type RateController

type RateController interface {
	Rate(elapsed time.Duration) float64
}

RateController decides the target rate (requests/sec) over elapsed time.

func Repeat added in v0.8.0

func Repeat(c RateController, period time.Duration) RateController

Repeat returns a RateController that cycles c with the given period, reporting c.Rate(elapsed mod period). It panics if c is nil.

This is what makes a finite shape endless, and cyclic is what diurnal traffic, periodic spikes and sawtooth all are. Pair it with a phase table's own length so the two cannot drift:

metronome.Repeat(p, p.Duration())

Sine is already periodic and is not wrapped in Repeat.

A non-positive period delegates straight to c and does not cycle: there is no cycle length to place elapsed on, and unlike a nil controller a zero period plausibly means "do not repeat". Ramp sets the same precedent, where a non-positive Over reports End immediately.

The Driver samples the controller ten times a second, so a period near 200ms aliases into a shape unrelated to the one asked for, and anything shorter does not survive sampling at all. Keep the period, and any feature inside it, at a second or more.

Example

Repeat makes a finite shape endless. Pairing a phase table with its own Duration keeps the cycle length in one place.

package main

import (
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	p := metronome.Phased{Phases: []metronome.Phase{
		{Duration: 2 * time.Second, TargetRPS: 10},
		{Duration: 3 * time.Second, TargetRPS: 40},
	}}
	c := metronome.Repeat(p, p.Duration())

	for _, at := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 7 * time.Second} {
		fmt.Printf("%v: %.0f rps\n", at, c.Rate(at))
	}
}
Output:
0s: 10 rps
2s: 40 rps
5s: 10 rps
7s: 40 rps

func Scale added in v0.8.0

func Scale(factor float64, c RateController) RateController

Scale returns a RateController reporting factor times c's rate. It panics if c is nil.

It exists for reuse rather than arithmetic: a Sine can be rescaled by hand through its Min and Max, but a Phased table cannot without rewriting every phase. Scale runs an agreed profile at a fraction of its intensity — a canary, or a reduced-blast-radius rerun of the same shape.

A negative factor is not rejected here; the Driver floors the result, as it does for any controller.

func Sum added in v0.8.0

func Sum(cs ...RateController) RateController

Sum returns a RateController reporting the sum of every controller's rate at a given elapsed. It panics if cs is empty or if any controller is nil.

This is how a spike on a baseline is expressed, which is why the package ships no Burst type:

metronome.Sum(metronome.Constant(100),
	metronome.Repeat(spike, 10*time.Minute))

It also puts a floor under an Adaptive controller, so a control signal that collapses cannot drive the run down to nothing.

Nothing clamps the total, in either direction. A sum large enough to exceed what the target or the generator can serve shows up as ErrSaturated and shortfall in the Snapshot, the same as any other over-ambitious rate; a total that a negative member drags to zero or below is floored by the Driver, as any controller's result is.

Example

A spike on a baseline needs no Burst type: it is a constant plus a repeating phase table. Sum adds the two, so the composed shape is 100 rps rising to 500 for one minute in every ten.

package main

import (
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	c := metronome.Sum(metronome.Constant(100),
		metronome.Repeat(metronome.Phased{Phases: []metronome.Phase{
			{Duration: 9 * time.Minute, TargetRPS: 0},
			{Duration: 1 * time.Minute, TargetRPS: 400},
		}}, 10*time.Minute))

	for _, at := range []time.Duration{0, 9 * time.Minute, 10 * time.Minute} {
		fmt.Printf("%v: %.0f rps\n", at, c.Rate(at))
	}
}
Output:
0s: 100 rps
9m0s: 500 rps
10m0s: 100 rps

type Recorder added in v0.6.0

type Recorder interface {
	Record(Result)
	Snapshot() Snapshot
}

Recorder accumulates Results into an aggregate.

*Stats, *RollingStats and *LabeledStats all satisfy it. It is the seam the per-label breakdown and the recorder combinators are written against, so a caller can substitute any of them — or their own — wherever one is taken.

Window is deliberately absent: it is meaningful for *RollingStats and for nothing else, and the honest answers a lifetime aggregate could give (a zero Snapshot, or the lifetime one relabelled) are both worse than not being asked. LabeledStats recovers typed access to it by being generic over the child type instead.

Bytes is absent for a different reason: a Recorder that writes CSV has no meaningful size to report, and should not owe one to satisfy an interface about recording. LabeledStats.Bytes reaches it through an optional interface.

func Filter added in v0.6.1

func Filter(keep func(Result) bool, rec Recorder) Recorder

Filter returns a Recorder that records only the Results for which keep reports true. It panics if keep or rec is nil.

keep is called once per Result, on the calling goroutine, and may keep state — the warmup skippers are exactly this with a stateful predicate. It must not mutate the Result.

Snapshot delegates to rec, so the filtered aggregate reads normally.

func Multi added in v0.6.1

func Multi(recs ...Recorder) Recorder

Multi returns a Recorder that records each Result into every rec, in argument order. It panics if given no recorders.

Use it to keep several views of one run without plumbing a second channel: a flat Stats beside a LabeledStats beside a RollingStats, all fed from one drain loop.

Snapshot delegates to the first recorder. There is no non-arbitrary aggregate answer — the recorders may be different types measuring different populations — so Snapshot exists here to satisfy Recorder, which is what lets a Multi nest inside After. Hold each recorder by name and read it directly.

It is synchronous, on the calling goroutine, and that has a sharp edge: time spent in Record is time not spent receiving from the result channel, so a slow recorder fills the channel, then the Driver's delivery backlog, and in open loop surfaces as ErrSaturated — which reads as "the generator ran out of workers" and is counted inside Errors and ErrorRate. A slow writer in a Multi can therefore present itself as a target problem. Everything in a Multi should be an in-memory aggregate; anything doing I/O belongs behind the caller's own buffered goroutine, where the caller owns the policy for falling behind.

Every recorder receives the same Result, and Result holds Labels by reference, so a recorder that mutates the map corrupts what the others see. Treat Results as read-only.

type Result

type Result struct {
	// Scheduled is the time this unit of work was due per the pacing schedule —
	// the run's origin plus one interval for each unit dispatched before it,
	// advanced independently of when the generator actually got to it.
	//
	// Start minus Scheduled is therefore the generator's own queueing delay: the
	// wait a client that never fell behind would have suffered before its
	// request even started. It is what Stats uses to correct for coordinated
	// omission, and it is aggregated as Snapshot.MaxScheduleLag.
	//
	// It may be in the past relative to Start (the generator was late — the
	// whole point) or in the future when Burst > 1 lets several units go at once
	// and the schedule says the later ones were not due yet; Stats floors the
	// correction at zero for that case. Zero if the Result did not come from a
	// Driver.
	Scheduled time.Time

	Start   time.Time
	Latency time.Duration
	Err     error
	Code    string // status/gRPC code, caller-supplied
	Bytes   int64

	// Labels is caller-side metadata carried alongside the Result, e.g.
	// {"endpoint": "get_user"}.
	//
	// Stats ignores it; LabeledStats is what reads it back, splitting the stream
	// on one key into a child Recorder per value plus a total:
	//
	//	NewLabeledStats(Labeled[*Stats]{Key: "endpoint", New: NewStats})
	//
	// Leave it nil if unused. It is a map, so stamping one costs an allocation
	// and roughly 150ns per request — measured, and the reason Mix does not
	// stamp a label of its own.
	Labels map[string]string
}

Result is the outcome of one unit of work.

func (Result) Success

func (r Result) Success() bool

Success reports whether the unit of work completed without error.

type Rolling added in v0.5.0

type Rolling struct {
	// Window is the trailing duration Window() reports over. Zero selects
	// DefaultWindow.
	//
	// It is truncated to a whole multiple of Buckets: 10s over 3 buckets gives
	// 3.333s buckets spanning 9.999s. Snapshot.Window always reports what the
	// ring actually covers, so the difference is visible rather than assumed.
	Window time.Duration

	// Buckets is the ring size — the resolution at which the window retires old
	// data. Zero selects DefaultBuckets. More buckets means a smoother window.
	//
	// It is also a memory multiplier, which is the part that surprises people.
	// Each bucket holds a raw and a corrected HDR histogram, and a RollingStats
	// allocates Buckets+2 of them: the ring, the lifetime aggregate and a scratch
	// merge target. At the default range that is ~272 KiB per bucket, so the
	// default Rolling is ~3.2 MiB and Buckets: 1000 is ~270 MiB. Widening Lo/Hi
	// or raising Sigfigs multiplies again — 5 significant figures over a
	// microsecond-to-hour range is ~16 MiB per histogram.
	//
	// Call Bytes to price a configuration before building it. Narrow the
	// histogram range when you want a large ring: 1,000 buckets over
	// 1ms-1s at 1 significant figure is ~2 MiB.
	Buckets int

	// Clock supplies the time that rotates the ring. Nil selects the wall clock;
	// inject a ManualClock to make window tests exact. Wire it to the same clock
	// as Driver.Clock when both are in play.
	Clock Clock

	// Lo, Hi and Sigfigs are the histogram range every bucket, the lifetime
	// aggregate and the scratch target are built with, exactly as in
	// NewStatsRange. Zero values select that function's defaults: 1µs, 60s and
	// 3 significant digits.
	Lo, Hi  time.Duration
	Sigfigs int
}

Rolling configures a RollingStats. The zero value is valid and gives a 10s window in 10 buckets on the wall clock over the NewStats histogram range.

func (Rolling) Bytes added in v0.5.0

func (cfg Rolling) Bytes() int64

Bytes reports the memory NewRollingStats(cfg) would allocate for its histograms, so a configuration can be priced before it is built:

if cfg.Bytes() > budget {
    cfg.Buckets /= 2
}

Buckets is a memory multiplier as much as a resolution knob — Buckets+2 Stats, each holding a raw and a corrected histogram — and this is the number that says so. It counts the histogram count arrays, which dominate; the surrounding structs, the codes maps and the recorded Results are not included.

It panics on exactly the configurations NewRollingStats panics on, so pricing an unbuildable config reports the problem rather than a number for it.

This is the ceiling, reached once every ring bucket has promoted to a dense histogram. Buckets start sparse, so a live RollingStats.Bytes reports what is actually held and is typically well under this. Budget with this one; read the other to see what a run costs.

type RollingStats added in v0.5.0

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

RollingStats aggregates Results into two views at once: the cumulative lifetime aggregate Stats gives, and a trailing window over the recent past.

Use Snapshot for an end-of-run report and Window for anything watching a run while it happens. Every number Stats produces is cumulative, so one early stall pins Snapshot.MaxScheduleLag at its worst value for the rest of the run and a target that stops answering entirely never changes Snapshot.RPS — there are no new Results to move it. Window has neither property.

Safe for concurrent Record.

func NewRollingStats added in v0.5.0

func NewRollingStats(cfg Rolling) *RollingStats

NewRollingStats returns a RollingStats configured by cfg. The zero Rolling is valid.

It panics on configuration no defaulting can rescue — a negative field, a histogram range NewStatsRange rejects, or a Window narrower than one nanosecond per bucket. Those are programmer errors, and there is no error path on a constructor two consumers call at startup.

func (*RollingStats) Bytes added in v0.6.0

func (rs *RollingStats) Bytes() int64

Bytes reports the memory this RollingStats currently holds for its latency distributions: the lifetime aggregate, the scratch merge target, and every ring bucket in whichever mode it is presently in.

This is an actual figure, not a projection, and it can sit far below the Rolling.Bytes of the config it was built from. A bucket starts as a sparse value-to-count store and only allocates a dense histogram once it holds enough distinct values to be worth one, so a run with few distinct latencies per bucket never pays the full price. Rolling.Bytes remains the ceiling: what this returns once every bucket has promoted.

It takes the lock, which the version that returned a constant did not need. This one walks the ring while Record may be promoting a bucket out from under it.

func (*RollingStats) Record added in v0.5.0

func (rs *RollingStats) Record(r Result)

Record adds one Result to both the lifetime aggregate and the current bucket. It is safe to call concurrently.

Which bucket a Result lands in is decided by the clock at the moment Record is called, not by Result.Start. A caller that buffers Results and drains them in batches therefore attributes a batch to the window it drained in rather than the window the work happened in. Record as you receive, which is what ranging over the Driver's channel already does.

func (*RollingStats) Snapshot added in v0.5.0

func (rs *RollingStats) Snapshot() Snapshot

Snapshot returns the cumulative aggregate over the whole run, with exactly the meaning Stats.Snapshot has: RPS inferred from the Result timestamps and Snapshot.Window zero. Use Window for the trailing view.

func (*RollingStats) Window added in v0.5.0

func (rs *RollingStats) Window() Snapshot

Window returns the aggregate over the trailing Rolling.Window — or over however much of it the run has covered so far, which Snapshot.Window reports.

It rotates the ring before reading, so a run that has stopped producing Results drains to zero instead of freezing at its last healthy numbers. That is the difference from Snapshot, and the reason to call this one from a control loop: Snapshot.MaxScheduleLag is a lifetime maximum that cannot tell "the generator is behind now" from "the generator was behind once".

Safe to call concurrently with Record. The returned Snapshot is a value and its Codes map is a copy, so it is the caller's to keep.

type Runner

type Runner interface {
	Do(ctx context.Context) Result
}

Runner is one unit of work. The integration seam for request executors.

func Mix

func Mix(ws ...Weighted) Runner

Mix returns a Runner that picks one sub-Runner per Do, weighted by Weight. It panics if no runners are given, a weight is negative, or all weights are zero - programmer errors and Runner has no error path to report them.

type RunnerFunc

type RunnerFunc func(ctx context.Context) Result

RunnerFunc adapts a function to a Runner.

func (RunnerFunc) Do

func (f RunnerFunc) Do(ctx context.Context) Result

Do calls f.

type Sine added in v0.8.0

type Sine struct {
	Min, Max float64
	Period   time.Duration
}

Sine oscillates smoothly between Min and Max over Period, starting at Min.

Rate(0) is Min, Max at Period/2 and Min again at Period. Starting at the trough rather than mid-curve means a run opens at its lowest load and rises, the way Ramp opens at Start — a curve that began at half load would put the cold-start cost of the run straight into the measurement.

Sine is already periodic, so it is not wrapped in Repeat. Repeat is for finite shapes such as Phased and Ramp.

Min need not be below Max: the curve runs Min to Max and back either way. Negative values are floored by the Driver and the Min/Max parameterisation is chosen so the literal a caller reaches for cannot drift negative the way an amplitude-and-offset one can.

The Driver samples the controller ten times a second, so a Period below about a second renders as visible steps rather than a curve and one near 200ms does not survive sampling at all. Keep Period at a second or more.

Example

A Sine gives a smooth periodic load rather than the steps a phase table produces. It starts at Min, peaks at Period/2, and returns to Min.

package main

import (
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	s := metronome.Sine{Min: 100, Max: 300, Period: 10 * time.Second}

	for _, at := range []time.Duration{0, 2500 * time.Millisecond, 5 * time.Second} {
		fmt.Printf("%v: %.0f rps\n", at, s.Rate(at))
	}
}
Output:
0s: 100 rps
2.5s: 200 rps
5s: 300 rps

func (Sine) Rate added in v0.8.0

func (s Sine) Rate(elapsed time.Duration) float64

Rate reports the point on the curve at elapsed. A non-positive Period reports Min and never oscillates: there is no cycle to place elapsed on.

type Skipper added in v0.6.1

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

Skipper is a Recorder that excludes some Results from the recorder it wraps and counts what it excluded.

It exists so that an exclusion is auditable: Snapshot().Count plus Skipped() is the whole population the Skipper was offered. A report that says "measured 4,500 of 5,000 — 500 excluded as warmup" is honest; one that says "4,500 requests" invites the reader to wonder where the rest went.

Skipped is not a Snapshot field: Stats, RollingStats and LabeledStats never skip anything, so it would be zero in every existing use and non-zero only when something upstream — which a Snapshot cannot see — happened to be a Skipper. Snapshot describes what was recorded; this describes what was not.

Safe for concurrent Record.

func After added in v0.6.1

func After(d time.Duration, rec Recorder) *Skipper

After returns a Skipper that excludes Results scheduled within d of the run's start, then admits the rest. It panics if d is negative or rec is nil.

Use it to keep a warmup out of a measurement: cold connection pools, TLS handshakes that will be reused, an empty page cache and a target that has not warmed up are not the system under test, and a percentile threshold asserted over them is asserted over the handshake. Phased can drive a gentler opening phase, but it changes the load rather than the measurement — those Results still land in the same Stats.

The boundary is anchored on the first Result's Scheduled stamp, falling back to Start, so it is fixed by the stream rather than by when this Skipper was constructed, and it is measured against the schedule rather than against when the generator got there. A Result carrying neither stamp is skipped and counted, and does not set the anchor; under a Driver that never happens, because the Driver stamps Start even when a Runner forgets.

Admission is decided per Result, not as a one-way switch, so a unit that was due inside the warmup is excluded even if it arrives after one that was due outside it — which happens routinely, because Results arrive off a channel fed by many workers.

The anchor can be up to one reordering window late, since the first Result seen need not be the earliest scheduled. That is negligible against a warmup measured in seconds; use AfterTime when the boundary must be exact.

After(0) admits every placeable Result and never anchors. A zero window has no boundary, so anchoring one on the first Result seen would exclude every later Result that was scheduled before it — a warmup the caller did not ask for. It is the natural value for a --warmup flag left unset, so it means exactly nothing rather than nearly nothing.

Example
package main

import (
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	// A hand-fed stream, so the schedule is exact and the example is
	// deterministic: ten units due one second apart, the first three of them
	// cold. After anchors on the first Result's Scheduled stamp, so the warmup
	// boundary comes from the run rather than from when this was constructed.
	origin := time.Unix(0, 0)
	stream := make([]metronome.Result, 0, 10)
	for i := range 10 {
		lat := 5 * time.Millisecond
		if i < 3 {
			lat = 200 * time.Millisecond // cold: pools, handshakes, an empty cache
		}
		at := origin.Add(time.Duration(i) * time.Second)
		stream = append(stream, metronome.Result{Scheduled: at, Start: at, Latency: lat})
	}

	warm := metronome.NewStats()
	measured := metronome.After(3*time.Second, warm)

	everything := metronome.NewStats()
	for _, r := range stream {
		everything.Record(r)
		measured.Record(r)
	}

	// Rounded, because the exact figures are HDR bucket boundaries and this
	// example is about the exclusion, not about histogram resolution.
	fmt.Println("whole run p99: ", everything.Snapshot().P99.Round(time.Millisecond))
	fmt.Println("warmed p99:    ", measured.Snapshot().P99.Round(time.Millisecond))
	fmt.Printf("measured %d of %d, excluded %d as warmup\n",
		measured.Snapshot().Count, everything.Snapshot().Count, measured.Skipped())
}
Output:
whole run p99:  200ms
warmed p99:     5ms
measured 7 of 10, excluded 3 as warmup

func AfterN added in v0.6.1

func AfterN(n int64, rec Recorder) *Skipper

AfterN returns a Skipper that excludes the first n Results it sees, then admits the rest. It panics if n is negative or rec is nil.

The count is in arrival order, not schedule order — use After when the boundary is a point on the run's timeline rather than a number of units. AfterN(0) admits everything.

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/RomanAgaltsev/metronome"
)

func main() {
	// AfterN counts units instead of time, which is the right shape when the
	// warmup is "the first N requests" rather than "the first N seconds".
	n := 0
	runner := metronome.RunnerFunc(func(context.Context) metronome.Result {
		n++
		lat := 5 * time.Millisecond
		if n <= 100 {
			lat = 200 * time.Millisecond
		}
		return metronome.Result{Latency: lat, Labels: map[string]string{"endpoint": "list"}}
	})

	d := metronome.Driver{
		Runner:      runner,
		Rate:        metronome.Constant(10000),
		Workers:     1, // one worker, so the cold units are the first ones
		MaxRequests: 500,
	}

	report := metronome.NewStats()
	byRoute := metronome.NewLabeledStats(metronome.Labeled[*metronome.Stats]{
		Key: "endpoint",
		New: metronome.NewStats,
	})
	// Warmup exclusion wraps the fan-out, so both recorders see the same
	// measured population. Multi(AfterN(100, report), byRoute) would be a
	// different, also-valid program — and the difference is in the expression.
	measured := metronome.AfterN(100, metronome.Multi(report, byRoute))

	metronome.Drain(d.Run(context.Background()), measured)

	fmt.Println("measured:", report.Snapshot().Count)
	fmt.Println("excluded:", measured.Skipped())
	fmt.Println("warm p99 under 10ms:", report.Snapshot().P99 < 10*time.Millisecond)
}
Output:
measured: 400
excluded: 100
warm p99 under 10ms: true

func AfterTime added in v0.6.1

func AfterTime(t time.Time, rec Recorder) *Skipper

AfterTime returns a Skipper that excludes Results scheduled before t, then admits the rest. It panics if rec is nil.

It is the exact form of After, for a caller who knows the run's origin: record clock.Now() immediately before Driver.Run and pass origin.Add(warmup). Unlike After it does not infer the boundary from the stream, so it is unaffected by which Result arrives first.

Results carrying neither a Scheduled nor a Start stamp are skipped and counted, as with After.

func (*Skipper) Record added in v0.6.1

func (s *Skipper) Record(r Result)

Record admits r to the wrapped recorder, or skips and counts it.

func (*Skipper) Skipped added in v0.6.1

func (s *Skipper) Skipped() int64

Skipped reports how many Results were excluded so far.

func (*Skipper) Snapshot added in v0.6.1

func (s *Skipper) Snapshot() Snapshot

Snapshot returns the wrapped recorder's aggregate: the admitted Results only.

type Snapshot

type Snapshot struct {
	// Window is the trailing duration these numbers cover. Zero means they are
	// cumulative over the whole run — the lifetime view, and what Stats.Snapshot
	// always returns. Non-zero comes from RollingStats.Window and qualifies every
	// other field here: they describe only the last Window of the run.
	//
	// During a run's first window it is less than the configured window, and
	// during steady state it sawtooths across [window-interval, window] because
	// the newest bucket is always partial. The numbers are correct for the period
	// they cover; this field is what that period is, so a reader never has to
	// guess whether the window had filled.
	Window time.Duration

	Count         int64
	Errors        int64
	RPS           float64
	ErrorRate     float64
	P50, P95, P99 time.Duration
	Max           time.Duration

	// Clamped counts Results whose Latency fell outside the histogram's range
	// and was clamped to a bound — at most one per Result. Non-zero means P50,
	// P95 and P99 understate reality at one end; widen the range with
	// NewStatsRange. Max always reports the true maximum regardless.
	Clamped int64

	// CorrectedClamped is the same count for the corrected histogram, which
	// records Latency plus queueing delay and therefore overflows a bound the
	// raw latency never reaches. It is counted separately because it says
	// something about CorrectedP50/P95/P99 only — a run can have
	// CorrectedClamped > 0 with Clamped == 0 and percentiles that are fine.
	CorrectedClamped int64

	// MaxScheduleLag is the largest gap between when a unit of work was due and
	// when it actually started. It is the generator's own lateness: non-zero
	// means metronome did not keep the schedule it offered, whatever the target
	// did. Read it against Saturated — a large lag with Saturated == 0 says the
	// generator, not the target, is the bottleneck.
	MaxScheduleLag time.Duration

	// Saturated counts Results carrying ErrSaturated: open-loop units that found
	// no free worker at their scheduled time. They are included in Errors and
	// ErrorRate, and this field is what separates "my generator ran out of
	// workers" from "the target failed". Always zero in ClosedLoop.
	Saturated int64

	// Bytes is the sum of Result.Bytes.
	Bytes int64

	// Throughput is bytes/sec: RPS multiplied by the mean Bytes per Result, so
	// it is the same kind of estimate as RPS rather than a raw total over the
	// observed span. (Bytes/span would spend all N samples' bytes over the N-1
	// intervals N timestamps actually bound — the bias RPS avoids.)
	Throughput float64

	// Codes counts Results by Result.Code. Results with an empty Code are
	// counted in Count but not here. The map is a copy owned by the caller.
	Codes map[string]int64

	// CorrectedP* are the coordinated-omission-corrected percentiles: each
	// Result's latency plus the time it spent waiting past its Scheduled send
	// time. They answer "what would a client that kept to the schedule have
	// seen?", which is the honest number when the target stalls. They are zero
	// when no Result carried a Scheduled stamp.
	//
	// Read them against the raw P* above: a large gap means the generator
	// queued, so the raw numbers understate what a real client would suffer.
	CorrectedP50, CorrectedP95, CorrectedP99 time.Duration

	// CorrectedCount is how many Results carried a Scheduled stamp and are
	// therefore represented in CorrectedP*.
	CorrectedCount int64
}

Snapshot is an aggregated view of many Results over a window.

func (Snapshot) String added in v0.4.0

func (s Snapshot) String() string

String renders the numbers a run is usually judged on, in the order they should be read: what was achieved, what it cost, what a schedule-faithful client would have seen, and whether the generator itself kept up.

It is a summary, not a serialisation — Codes, Bytes, Throughput and the clamping counters are omitted. Reach for the fields directly when you need them.

A windowed Snapshot is prefixed "last <duration>: " so the two views are never confused in a log.

type Stats

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

Stats aggregates Results into percentile Snapshots. Safe for concurrent Record.

func NewStats

func NewStats() *Stats

NewStats returns Stats recording latencies from 1µs to 60s with 3 significant digits — a sensible default for HTTP and gRPC. Use NewStatsRange when your work is slower or faster than that.

func NewStatsRange added in v0.2.0

func NewStatsRange(lo, hi time.Duration, sigfigs int) *Stats

NewStatsRange returns Stats recording latencies in [lo, hi] with sigfigs significant digits. Latencies outside the range are clamped to the nearest bound and counted in Snapshot.Clamped rather than dropped; Snapshot.Max always reports the true maximum.

It panics if lo <= 0, hi <= lo, or sigfigs is outside [1, 5] — programmer errors, and there is no error path on a constructor two consumers call at startup.

func (*Stats) Bytes added in v0.6.0

func (s *Stats) Bytes() int64

Bytes reports the memory this Stats holds for its latency distributions: two count arrays of int64. The surrounding struct, the codes map and the recorded Results are not included — the count arrays dominate and are the term that scales with the range and significant figures.

Every Stats a caller can construct is dense, so this is fixed at construction and needs no lock. The sparse form exists only inside a RollingStats ring, where RollingStats.Bytes takes the lock and totals the buckets.

func (*Stats) Merge added in v0.6.0

func (s *Stats) Merge(src *Stats)

Merge folds src into s, so that s reports what it would have reported had it recorded every Result src did. Percentiles cannot be combined after the fact, so this merges the underlying histograms rather than the Snapshots.

Use it to roll a LabeledStats breakdown up into an ad-hoc subtotal, to combine per-worker aggregates recorded in parallel, or to combine separate runs.

Both Stats must have been built with the same range and significant figures; Merge panics otherwise, and panics if src is s. Range equality is a property of construction, so a mismatch fires on the first call rather than later and only for some values — it is a programmer error, and it is reported as one.

It is safe to call concurrently with Record on either Stats, and safe to call in both directions from different goroutines.

func (*Stats) Record

func (s *Stats) Record(r Result)

Record adds one Result to the aggregate. It is safe to call concurrently.

func (*Stats) Snapshot

func (s *Stats) Snapshot() Snapshot

Snapshot returns the cumulative aggregate so far. Safe to call concurrently with Record.

type Weighted

type Weighted struct {
	Runner Runner
	Weight int
}

Weighted pairs a Runner with a selection weight.

Jump to

Keyboard shortcuts

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