Files
buzz/perf/LOADGEN.md
Luke Tornquist aed4be8592 Add a scenario-driven relay load generator (#8636)
## Summary

Buzz has no way to load-test the relay as a whole. `wamp_bench` sends
kind:9 messages from one identity to one channel, and
`perf/relay_bus_scaling.py` only measures Redis fan-out. Neither can
show what happens to a relay with many more users, channels, or deep
threads.

This PR adds `buzz-loadgen`, a load generator in
`crates/buzz-test-client`:

- `buzz-loadgen seed <scenario.toml>` creates communities (through `POST
/operator/communities`), channels, channel history, and deep threads.
- `buzz-loadgen run <scenario.toml>` drives simulated humans and agents
through ramp steps (1x, 2x, ...) and prints p50/p90/p99 latency, errors,
and rate-limit rejections per operation for each step.
- Humans hold a WebSocket with live subscriptions, fetch history on
connect, and send presence, typing, messages, thread replies, and
reactions. Agents authenticate with a NIP-OA tag, poll HTTP `/query`,
post through HTTP `/events`, and send encrypted observer frames to their
owner.
- Per-client rates come from the scenario. Set them to model a
deployment, and a step of `users = 10` means 10x that deployment's
population.
- Every identity, host, and channel id comes from the scenario's
`run_id`, so `--shard i/n` splits a population across machines with no
coordination.
- Each step measures from ramp start to hold end, so connect failures
during the ramp count. A step aborts the run when an operation's error
rate or p99 (delivery included) crosses the scenario's `[abort]` limits,
when a relay `CLOSED`s a live subscription often enough, or when a
client exits unexpectedly. The report names the limit that tripped.
- Each connect plus authentication has a deadline
(`target.connect_timeout_secs`), and shutdown is bounded, so a relay
that stalls connections still produces a report and a non-zero exit.
- `seed` exits non-zero unless the relay accepted every planned post.
- `NostrWsConnection::connect_to_addr` opens the TCP connection to a
given address while the URL still names the host. This lets a client
inside the relay's cluster reach a community host through the relay's
service address.
- `wamp_bench` is deleted. Its paced single-channel load is a subset of
a scenario.

What got simpler: one load tool instead of two partial ones. The change
touches only test tooling, the unit-test lane, and one additive method
in `buzz-ws-client`. Relay code is unchanged.

### Related issue

None found.

### Testing

Smoke run on a Linux workstation against a local relay built from this
branch, with its own Postgres, Redis, and MinIO containers:

```bash
cargo run --release -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-smoke.toml
cargo run --release -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-smoke.toml
```

- `seed`: 710 of 710 posts accepted (10 channels, 200 history messages,
10 thread roots, 500 thread replies).
- `run`: both steps (1x = 50 humans + 5 agents, then 2x) completed with
0 errors and 0 rate-limit rejections. Every operation recorded traffic:
55/55 connect/auth, 205/205 live subscriptions, 150 history fetches,
presence/typing/observer frames, messages and reactions, HTTP `/query`,
HTTP `/events`, and publish→delivery. Delivery p99 was 45 ms at 1x and
42 ms at 2x.
- Failure paths are covered by unit tests: a listener that accepts TCP
but never answers (connect deadline, then `run` aborts and returns), a
fake `/events` server that rejects posts (`seed` fails), `CLOSED`
subscriptions, and invalid scenarios.

`just test-unit` (and its `scripts/run-tests.sh` fallback) now runs the
new `loadgen::` unit tests. Before this change, no CI lane ran
`buzz-test-client` lib tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:49:37 -04:00

6.4 KiB

Relay load generator

buzz-loadgen (in crates/buzz-test-client) seeds a data shape and then drives a population of simulated humans and agents through a ramp of user multipliers, reporting latency percentiles, errors and rate-limit rejections per operation for each step.

cargo run --release -p buzz-test-client --bin buzz-loadgen -- seed perf/scenarios/local-smoke.toml
cargo run --release -p buzz-test-client --bin buzz-loadgen -- run perf/scenarios/local-smoke.toml

Scale vectors

Vector Scenario knob
Communities shape.communities (each provisioned through POST /operator/communities)
Channels shape.channels_per_community, population.channels_per_member
History depth shape.history_messages_per_channel, human.history_limit
Deep threads shape.thread_roots_per_channel, shape.thread_depth, human.thread_reply_fraction
Humans / agents population.humans, population.agents, multiplied by each [[ramp]] step's users
Throughput per client [human] and [agent] rates
Reconnect churn human.mean_session_secs, population.connect_rate_per_sec

shape.thread_depth seeds each thread as a chain: reply k replies to reply k-1, so the thread's deepest reply sits at depth thread_depth. The relay rejects depths above 100, so scenarios do too. Threads are seeded in parallel with each other.

A scenario that cannot be scheduled is rejected when it is parsed: for example seed_concurrency of 0, human.presence_interval_secs below 1, or a community_host_template with a port while target.connect_addr is set (the override maps hostnames, so HTTP would ignore it).

What each client does

  • Human: authenticates over WebSocket and subscribes to its channels' messages, its mentions, typing, and ten peers' presence. On connect it fetches history_limit messages per channel. Then it sends presence heartbeats, typing indicators, messages (some as replies into seeded deep threads) and reactions on Poisson schedules.
  • Agent: authenticates with a NIP-OA tag from its owner, polls HTTP /query (channel window plus mentions), posts messages through HTTP /events, and sends encrypted kind:24200 observer frames to its owner.

Messages carry their send time, so every subscriber records publish→delivery latency (deliver). Across machines this depends on clock sync.

Modeling a deployment

The [human] and [agent] defaults are round placeholders. To model a known deployment, divide its hourly event counts by its client counts and set the results as the scenario's rates. Then a ramp step's users reads as a multiple of that deployment's population. Keep such scenarios with that deployment's own configuration.

Targeting and membership

target.connect_addr (host:port) applies only together with target.community_host_template: it then replaces host resolution for the community hosts, over both WebSocket and HTTP, while TLS names, Host headers and the NIP-42 relay tag still name the community. Without a host template, the generator connects to ws_url and http_url as written and connect_addr has no effect.

Seeded identities do not join communities or channels. A relay that requires membership rejects both seed and run.

Sharding

run --shard i/n runs the clients whose global index g satisfies g % n == i. Identities, community hosts and channel ids derive from run_id, so shards need no coordination: run seed once, then start n processes, such as a Kubernetes Indexed Job, with the same scenario. Each shard reports its own clients; add counts and take the worst percentiles across shards.

Abort

Each step reports every operation from the start of its ramp to the end of its hold, so connection failures during the ramp count. At the end of the hold, the run stops if any operation's p99 (publish→delivery included) exceeds abort.max_p99_ms, if any operation other than delivery has an error rate above abort.max_error_rate, or if a client exited unexpectedly. An operation still in flight when the hold ends is reported as pending but not judged: it has not failed yet. A relay CLOSED on a live subscription counts as a subscribe_eose error and ends that client's session. Each connect plus authentication must finish within target.connect_timeout_secs (default 10). Quota rejections, meaning an HTTP 429 or the relay's rate-limited: quota exceeded text, are reported as throttled, not errors. Every other rejection, including rate-limited: shared admission unavailable (the relay's admission store is down), counts as an error. Ramp steps must increase users, since clients are only ever added.

When the run stops, clients get 15 seconds to settle what they have in flight and disconnect. An operation already under way runs to its own deadline instead of being abandoned: a connect attempt finishes within target.connect_timeout_secs, and a publish waits at most 30 seconds for its OK. A stalled connect therefore ends as a connect timeout error and a healthy one as a success. What settles after the last step is reported in the shutdown object (ops, failures, abort_reason) and judged by the same limits as a step, so a connect that never completes fails the run there. A client still busy when the grace period ends is cancelled; its in-flight operations are recorded as unfinished at stop errors, and the cancellation fails the run whatever the limits. Keep target.connect_timeout_secs below 15, or a stalled connect is cancelled instead of timing out. Publishes, history fetches and HTTP requests can outlast the grace period (30 s, 30 s and 60 s deadlines), in which case the client is cancelled and the run fails.

The process exits non-zero when a step aborts, a client panics, a client had to be cancelled, or shutdown.abort_reason is set.

seed exits non-zero unless the relay accepted every planned post.

seed provisions each community with create_only, so it never rotates the owner of an existing host. A host that already exists is reused only when the fixture owner derived from run_id owns it (re-running seed for the same scenario); otherwise seed fails. Use a fresh run_id or a host template with {run} to avoid colliding with another community.

Safety

The generator creates real communities, channels and events. Point it only at environments built for load tests, never at a production relay. Use a fresh run_id per fixture.