surgesurge

surge — design & analysis

Status: original design record · 2026-11-06 · owner: luann (agentic-fx)

This is the analysis the project started from. Several things listed below as deferred have since shipped; where that is so, it is marked. The record is kept because the reasoning still explains why the engine is shaped the way it is.

1. Problem

A load test is run to answer three questions — how much can this take, is it slower than last time, and why — and the usual tooling answers none of them directly. Four structural limits, none of them accidental:

LimitCauseEffect
A few thousand requests per second per coreAn interpreted runtime with a global lockSeveral processes are needed on one machine just to use its cores, which turns a single-box test into a distributed one
Closed model onlyVirtual users wait for their own responses, so pacing is per user rather than a true arrival rateWhen the target slows, the offered load drops with it and the problem hides — coordinated omission
Pass/fail added on afterwardsNo threshold language in the engineGating logic lives in scripts beside the test, and drifts from it
A language runtime on every generatorInterpreter modelA heavier fleet to provision and keep in step

A compiled language removes the first and the fourth outright. The second and third are design decisions, not performance ones, and are the reason this is a new engine rather than a faster client for an old one.

2. Goals (v1.0)

  1. One static binary, no runtime deps; cross-compile for the generator fleet.
  2. Two ways to write scenarios
    • Library mode: User types with weighted Tasks, OnStart/OnStop, Wait(min,max) — a main.go that compiles to the test itself.
    • CLI mode: YAML scenarios, zero code — flows, extract/check, CSV feeders, ${...} templating.
  3. Open and closed workload models, both with ramping stages, warmup exclusion and graceful stop.
  4. CI-first: threshold expressions (p95 < 500ms, error_rate < 1%, p99(login) < 800ms) → exit code; JSON summary for machines; self-contained HTML report for humans; per-second CSV.
  5. Honest metrics: HDR-style log-linear histogram (bounded memory, ~3% error), per-request-name stats, status distribution, top error classes, per-second timeline.
  6. Throughput: ≥ 50k req/s on a developer laptop against a local target (small responses), from one process using every core — so a single machine stays a single process.

Non-goals in v1.0 (explicit, with a path)

3. Why Go (vs Rust)

GoRust
Raw ceiling~hundreds of k req/s/machine (net/http)Higher (hyper/tokio), matters past ~1M rps
TeamPrimary tooling language (verdict)Nobody maintains it here
Library mode UXgoroutine-per-VU reads like ordinary blocking codeasync/await + lifetimes leak into every user scenario
Deliverydaysweeks

The bottleneck in practice is the target system, not the generator. Go's ceiling is far above what we can usefully apply; Rust buys nothing we need and costs maintainability. Decision: Go.

4. Architecture

text
cmd/surge        CLI: run | quick | validate | version
surge (root)     public library: Scenario, User, Task, Ctx
internal/
  config         YAML schema, defaults, validation (strict keys, line numbers)
  engine         scheduler (closed/open/iterations), HTTP executor,
                 flow VU (YAML interpreter), feeders, ${...} interpolation
  metrics        log-linear histogram, sample collector, summary
  threshold      "p95 < 500ms" parser + evaluator
  report         console live + summary, JSON, CSV, HTML

Data flow: VU goroutines → buffered chan (16k) → single collector goroutine → histograms/timeline → summary → thresholds → reporters. The hot path takes no locks; aggregation is serialized in one consumer.

5. Engine model

The scheduler knows nothing about HTTP. It drives:

go
type VU interface {
	Start(ctx context.Context)     // once per spawned VU (library: User.OnStart)
	Iteration(ctx context.Context) // one scenario pass   (flow once / one weighted Task)
	Stop()                         // on deschedule       (library: User.OnStop)
}

type Spawner func(id int64) VU

HTTP executor: one shared http.Transport (HTTP/2 on by default, tuned idle pool), per-request timing, bounded body read (max_body_bytes, default 10MB), error classification (timeout / refused / reset / dns / HTTP 4xx-5xx) so the report shows why requests failed, not just counts.

6. Metrics

7. Distributed — the original sketch (since built, as described)

8. Docs plan

9. Decision log

#DecisionWhy
D1Go, not Rust§3
D2Library + YAML dual modeEngineers want code and a compiler; CI and non-programmers want declarative
D3Open model in v1Coordinated omission is the #1 lie in load tests
D4Thresholds built-inCI gating without plugins
D5No distributed in v1Single-process multi-core removes the main need; sketch kept mergeable (§7). Built in 0.3.0
D6Protocol-agnostic core + ctx.Time()gRPC/WS measurable day one; native clients followed
D7Own histogram, own threshold parser2 external deps total (yaml, gjson); supply-chain lean