mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
## Summary
The load generator models every community as many small open channels,
and every human as a bare WebSocket client: four REQs, a little history,
then nothing until the run ends. Large deployments don't look like that.
They have thousands of people in a few communities, a handful of very
wide channels, and some people in hundreds of channels. Their desktop
clients also make most of their relay requests over HTTP and on timers.
This PR adds opt-in scenario knobs for both, so a run can reproduce that
shape and compare the desktop client before and after its recent
request-volume fixes.
Every new key defaults to the previous behavior. Existing scenarios
parse, assign members and run exactly as before.
### Shape
| Key | Default | Meaning |
|---|---|---|
| `shape.private_channels` | `false` | `seed` creates channels private
and adds every member with kind:9000 before posting history. Members are
the humans and agents assigned to each channel up to the largest ramp
step, plus the hot thread's root and authors. Events carry up to 100
members each and run `seed_concurrency` at a time. A rerun adds nobody
new, and any member the relay refuses fails the seed. |
| `shape.wide_channels` | `0` | The first K channels of each community
are wide channels. |
| `population.wide_member_fraction` | `0.0` | Share of humans that join
every wide channel on top of their own. |
| `population.wide_activity_share` | unset | Share of its typing,
messages and reactions a member of the wide channels sends there; the
rest goes to its regular channels. Unset keeps the uniform pick, which
sends half of a 3-wide/3-regular member's traffic to the wide channels
and makes fan-out, not reads, the bottleneck. |
| `population.tiers` | `[]` | `[{ fraction, channels }]`: that share of
humans joins `channels` channels instead of `channels_per_member`. |
Assignment is deterministic by global index, so `seed` and every
`--shard` agree without coordination. With no wide channels and no tiers
it is the old assignment, and a test pins that.
### Desktop clients (`[desktop]`)
| Key | Default | Meaning |
|---|---|---|
| `fraction` | `0.0` | Share of humans that run the desktop model
instead of the basic human. |
| `mode` | `"current"` | `legacy` is the client before its volume fixes;
`current` is after them. |
| `windows`, `threads` | `3`, `1` | Open channel windows and threads,
which are refreshed on connect. |
| `tick_secs` | `60` | Recovery tick period. |
| `backstop_secs` | `900` | `current` only: each tick item (subscription
replacement, roster, unread repair) waits this long since it last ran. |
| `renewal_secs` | `0` | Identity renewal period. `0` disables renewal,
and the first renewal is jittered. |
A desktop human keeps the basic human's presence, typing, messages and
reactions. What it adds:
- **Cold start.** It reads NIP-11 and opens its profile and membership
subscriptions. Its channel subscriptions are batched per mode:
- up to 10 channels: one replaying batch;
- `legacy` with more than 10: one per-channel batch per 10 channels;
- `current` with more than 10: a live-only subscription per 2048
channels, plus replays of 10 that are closed at EOSE.
It also makes the app's HTTP reads: roster, channel names, sidebar,
profiles, statuses, emoji, read state, unread repair, reminders, window
catch-up plus notices for each window, and root plus thread page for
each thread. A window catch-up follows the cursor in the relay's window
bounds (kind 39006) for up to five older pages, until the bounds say
history is exhausted.
- **Route establishment.** `legacy` makes every read as it connects.
`current` reads NIP-11, names and sidebar up front, and the rest as each
subscription reaches EOSE, by its replay outcome. A replay of 500
events, a missed deadline, or the live-only batch over 10 channels is a
cut, and so is a route the previous connection never caught up. Only an
EOSE catches a route up: one recovered after a missed deadline cannot be
resumed or carried by the next connection.
- A continuous profile route skips its read (unless one is owed).
- A continuous membership route only repairs unreads, without the
read-state snapshot, unless a snapshot read failed since; then it runs
the full repair.
- A cut global reads in full, and channel routes always recover windows
and threads.
- **Ticks and renewals.** These follow each mode:
- `legacy`: every tick replaces subscriptions and rereads roster and
unreads, and a renewal is a full reconnect;
- `current`: tick items wait for the backstop, measured from the last
replacement, the last successful roster read, and the last full unread
repair. A failed roster read, unread repair or read-state snapshot is
owed and retries on the next tick; snapshot debt is tracked apart from
repair debt, so a repair without the snapshot never settles it. A
renewal carries its routes: nothing is read while every replay keeps
continuity, and once one is cut every route establishes as on a
reconnect. With more than 10 channels the live-only batch is always cut,
so the channels recover and unreads are repaired, but globals are not
reread.
- **Reporting.** Each request type is its own op (`desktop_roster`,
`desktop_unread`, `desktop_replay_eose`, …).
- **Bounds.** At most 4 REQs set up at once, with a 10 s EOSE timeout. A
tick replacement that misses it is closed, the batch it would have
replaced stays, and the next tick retries. At most 6 HTTP reads in
flight, with a 30 s timeout. A read that comes due while the same read
is running or queued is not added again, and each read leaves the
running set as soon as it settles, so a failed read can retry while a
slow one is still under way. Connects and failed sessions back off. A
stop abandons pending EOSE waits without counting them as failures.
- **Not modeled.** The app also establishes channel routes when a tick
replacement reaches EOSE; the generator does not.
`perf/LOADGEN.md` documents the knobs and the model.
`perf/scenarios/local-desktop.toml` is a short local example with every
knob on.
## What got simpler
- `human()`'s schedule loop (presence, typing, messages, reactions,
session end) moved into one `Activity`. The basic and desktop humans
share it, so neither client duplicates the schedule. The basic human's
random draw order is unchanged.
- Membership stays in one place. `member_channels` remains the single
assignment function, and `channel_members` derives from it rather than
from a second formula, so seeding and the clients can't drift apart.
The desktop model is a new concept: about 1,420 lines of model in its
own module, plus about 1,300 lines of tests. Everything else goes
through existing seams: `Stats`/`Op`, `connect`, `Backoff`, `observe`,
`roots` and `Net`.
Identified debt, not fixed here: CI's `test-unit` runs `buzz-test-client
--lib` with the filter `loadgen::`, so the five crate-root `tests::` in
`lib.rs` (relay message parsing, auth and text event building,
`collect_until_eose`) never run in CI. That gap predates this PR, which
doesn't touch `lib.rs`.
## Testing
- `cargo fmt --check`, `cargo clippy -p buzz-test-client --all-targets
-- -D warnings`, and `cargo test -p buzz-test-client` (plain parallel):
84 lib tests pass.
- Wide activity: a seeded `Rng` over 100k picks gives each share within
1%, and an unset share makes the identical draws to before. A run
against a loopback relay checks that a human's typing lands only in its
regular channels at share 0, and only in its wide channels at share 1.
- Scenario: validation of every new key; deterministic wide-channel and
tier assignment that honors the fractions; `channel_members` inverts the
assignment up to the peak; the default assignment is unchanged; the
shipped scenarios parse.
- Seed: with private channels, a fake relay sees the following, in
order:
- every 9007 before any 9000, and every 9000 before any post;
- owner signatures;
- ≤100 members per event;
- exactly the expected members per channel;
- posts authored by channel members.
A refused member fails the seed, and open channels add nobody.
- Desktop: request planning, window bounds validation, route
establishment, and tick and owed-read rules are unit-tested. End-to-end
tests drive clients against loopback WS and HTTP fakes. The WS fake
replays a planned number of events per subscription and can delay an
EOSE or drop a connection. The HTTP fake answers window pages with
relay-signed bounds, an older aux event and equal-timestamp rows,
refuses unknown cursors, and can fail a read once. They check:
- exact cold-start counts per op for `legacy` and `current` at 5 and 25
channels;
- a window catch-up sends the head, then exactly the bounds' cursor
(`until` + `before_id` of the last row, not the older aux event), and
stops when the bounds say history is exhausted even though that page
came back full;
- after a `current` renewal, 499 replayed events everywhere read nothing
over HTTP; 500 on the channel batch adds channel recovery plus a covered
unread repair; 500 on membership adds roster, read state, unread and
reminders plus channel recovery; 500 on profiles adds profiles, statuses
and emoji plus channel recovery and a covered repair;
- after a dropped connection, complete global replays read only NIP-11,
channel recovery and a covered repair; truncated ones reread both
globals in full;
- with renewals shorter than the backstop, read-state snapshots still
recur at the backstop;
- a tick replacement past its EOSE deadline is closed, the old batch
stays, the next tick replaces it, and one batch wire stays open;
- a failed roster and unread read retry on the next tick, and successful
reads do not run again;
- a failed roster read retries on the next tick while an unrelated slow
read is still running;
- after a failed snapshot, a continuous membership establishment before
any tick still reads the snapshot;
- a route whose EOSE missed its deadline is recovered, and after a
renewal with a complete replay it is recovered in full again rather than
carried;
- `legacy` ticks replace batches (new REQ before the CLOSE of the old
one) and reread;
- `current` ticks do nothing until the backstop, then replace live-only;
- `legacy` renewals are full reconnects;
- `current` renewals carry small sessions without rereading globals or
channels, and recover channels in large ones.
Each of the nine review-fix tests was checked against a mutant that
restores the old behavior, and every mutant fails its test.
- Smoke: a local relay with Postgres and Redis. I ran `seed` twice, then
`run` of `local-desktop.toml` (60 s) once per mode.
- Both seeds were accepted (780 of 780 posts each), including private
channels and memberships for 340 people. Neither run aborted, no client
was cancelled, and no op recorded an error.
- Each run used 18 desktop clients:
| op | `current` | `legacy` |
|---|---|---|
| `desktop_live_eose` | 266 | 483 |
| `desktop_replay_eose` | 24 | 0 |
| `desktop_roster` | 72 | 289 |
| `desktop_read_state` / `desktop_unread` | 72 / 99 | 289 / 289 |
| `desktop_profiles` | 18 | 73 |
| `desktop_nip11` | 18 | 73 |
| `desktop_window` | 372 | 596 |
Window reads are below the first smoke (406 and 1086) because catch-ups
stop where the relay's bounds say history is exhausted, rather than
paging while pages came back full. They are above the previous revision
(179 and 470) because each read now leaves the running set as soon as it
settles, so a channel recovery is no longer dropped as a duplicate of a
read that already finished inside a still-running batch.
- The first smoke caught two relay contract mismatches that the fakes
had missed:
- top-level window pages need `before_id` with `until`;
- the read-state snapshot answers with an object.
Both are fixed, and the fakes now enforce both.
🤖 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>
291 lines
16 KiB
Markdown
291 lines
16 KiB
Markdown
# 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.
|
|
|
|
```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
|
|
cargo run --release -p buzz-test-client --bin buzz-loadgen -- teardown perf/scenarios/<templated>.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` |
|
|
| One hot thread | `[hot_thread]`: `seed_replies`, `authors`, `replies_per_hour`, `fetches_per_hour` |
|
|
| 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` |
|
|
| Private membership | `shape.private_channels` |
|
|
| Large channels | `shape.wide_channels`, `population.wide_member_fraction`, `population.wide_activity_share` |
|
|
| Uneven membership | `population.tiers` |
|
|
| Desktop clients | `[desktop]`: `fraction`, `mode`, `windows`, `threads`, `tick_secs`, `backstop_secs`, `renewal_secs` |
|
|
|
|
`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).
|
|
|
|
`[hot_thread]` models one thread that keeps growing, such as a conversation
|
|
between busy agents. `seed` posts its root in community 0, channel 0 and
|
|
then `seed_replies` replies directly under it, concurrently. During `run`,
|
|
`authors` clients (multiplied by each ramp step's `users`, like humans and
|
|
agents) reply to that root and read its newest 100 replies over HTTP. Their
|
|
replies are timed, so humans subscribed to that channel record delivery.
|
|
Leave `seed_replies` and `authors` at 0 to disable it.
|
|
|
|
Members are assigned deterministically by global index, so every shard and
|
|
`seed` agree on them without coordination:
|
|
|
|
- `shape.wide_channels` reserves the first K channels of each community as
|
|
wide channels. Each human joins them with probability
|
|
`population.wide_member_fraction`, on top of its own channels. Agents never
|
|
do.
|
|
- `population.wide_activity_share`, when set, is the share of its typing,
|
|
messages and reactions a member of the wide channels sends into them; the
|
|
rest goes to its regular channels. Unset, typing and messages pick any of
|
|
a member's channels equally, so a member of three wide and three regular
|
|
channels sends half of them into the wide ones, and reactions go to the
|
|
latest message the member saw. It has no effect on members outside the wide
|
|
channels.
|
|
- `population.tiers` is a list of `{ fraction, channels }`. That fraction of
|
|
humans joins `channels` channels instead of `channels_per_member`. Fractions
|
|
sum to at most 1; the rest keep `channels_per_member`.
|
|
- A member's own channels are a run of consecutive channels after the wide
|
|
ones, starting at its index. With no wide channels and no tiers this is the
|
|
assignment of earlier versions.
|
|
|
|
## 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.
|
|
- **Desktop human**: the `desktop.fraction` of humans that model the desktop
|
|
app instead of the basic human above. They keep the basic human's presence,
|
|
typing, messages and reactions; the subscriptions and HTTP reads are the
|
|
app's, described under [Desktop clients](#desktop-clients).
|
|
- **Hot-thread author**: finds the seeded hot root, then posts replies to it
|
|
through HTTP `/events` (`hot_reply`) and reads the thread through HTTP
|
|
`/query` (`thread_fetch`). Until the root is found every attempt counts as
|
|
a `thread_fetch` error, so a run without the seeded thread fails.
|
|
|
|
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`: the generator then dials the community
|
|
hosts there in plaintext, over both WebSocket and HTTP, such as a relay's
|
|
in-cluster service. Set `ws_url` and `http_url` to the community's public
|
|
scheme anyway (`wss://` and `https://` for a relay behind TLS): `Host`
|
|
headers, NIP-98 `u` tags and the NIP-42 relay tag keep that scheme, which the
|
|
relay checks against its own relay URL. Without a host template, the
|
|
generator connects to `ws_url` and `http_url` as written and `connect_addr`
|
|
has no effect.
|
|
|
|
By default channels are open and seeded identities join nothing, so a relay
|
|
that requires membership rejects both `seed` and `run`. With
|
|
`shape.private_channels = true`, `seed` creates the channels private and adds
|
|
every member: the humans and agents assigned to each channel up to the
|
|
largest ramp step, and the hot-thread root and authors in its channel. It
|
|
sends kind:9000 events of up to 100 members, `seed_concurrency` at a time,
|
|
signed by the community owner, before posting any history. A rerun adds
|
|
nobody new, and any member the relay refuses fails the seed. Seeded history
|
|
in a private channel is posted by its own members.
|
|
|
|
## Desktop clients
|
|
|
|
A desktop client models the subscriptions and reads the desktop app makes,
|
|
in two modes: `legacy`, the app before its client volume fixes, and
|
|
`current` (default), after them. Each request type is its own operation in
|
|
the report.
|
|
|
|
On a **cold start** it:
|
|
|
|
1. reads the relay's NIP-11 document (`desktop_nip11`);
|
|
2. opens its profile and membership subscriptions, and its channel
|
|
subscriptions (`desktop_live_eose`). With up to ten channels both modes
|
|
open one per-channel batch that replays the newest 500 events. With more,
|
|
`legacy` opens one such batch per ten channels; `current` opens one
|
|
live-only subscription per 2048 channels and replays the channels ten at a
|
|
time in separate subscriptions it closes at EOSE (`desktop_replay_eose`).
|
|
At most four are set up at once, each with a 10 s EOSE timeout;
|
|
3. reads over HTTP, at most six at a time with a 30 s timeout: its roster
|
|
(`desktop_roster`, paged), channel names (`desktop_metadata`), sidebar
|
|
preferences (`desktop_sidebar`), co-member profiles (`desktop_profiles`)
|
|
and statuses (`desktop_statuses`), the custom emoji set
|
|
(`desktop_emoji`), read state (`desktop_read_state`), unread repair in
|
|
chunks of 128 channels (`desktop_unread`), reminders
|
|
(`desktop_reminders`), and for each of `windows` open channels a window
|
|
catch-up (`desktop_window`) and its notices (`desktop_notices`), and for
|
|
each of `threads` open threads a seeded root lookup and its first thread
|
|
page (`desktop_thread_root`, `desktop_thread_window`). A window catch-up
|
|
reads the head page, then follows the cursor in the relay's window
|
|
bounds (kind 39006) for up to five older pages, until the bounds say
|
|
history is exhausted.
|
|
|
|
`legacy` makes all of these reads as it connects. `current` reads only
|
|
NIP-11, names and sidebar preferences then, and the rest as each
|
|
subscription reaches its EOSE ("establishes"): profiles, statuses and emoji
|
|
with the profile subscription; roster, read state, unread repair and
|
|
reminders with the membership subscription; windows and threads with the
|
|
channel subscriptions.
|
|
|
|
A subscription's replay is cut when it replays 500 events, or, for the
|
|
live-only subscription, always: it replays nothing itself. A subscription
|
|
that misses its EOSE deadline counts as cut, and so does one the previous
|
|
connection never caught up. Only an EOSE catches a subscription up: one
|
|
that missed its deadline is recovered, but the next connection cannot
|
|
resume or carry it. When a `current` client reconnects, a profile or
|
|
membership subscription whose replay was not cut resumed every change, so
|
|
it skips its full read: profiles read nothing unless an earlier read is
|
|
still owed, and membership only repairs unreads, without the read-state
|
|
snapshot, unless a snapshot read failed since, which a full repair retries.
|
|
A cut one reads as on a cold start. The channel subscriptions always
|
|
recover the windows and threads.
|
|
|
|
Every `tick_secs` the client runs a recovery tick, on its own clock across
|
|
connections. In `legacy` each tick replaces its channel subscriptions and
|
|
rereads roster, read state and unread. In `current` each of the three waits
|
|
`backstop_secs` since it last ran: since the batches were last replaced,
|
|
since the roster was last read in full, and since a full unread repair (with
|
|
its read-state snapshot) was last asked for. A failed roster read, unread
|
|
repair or read-state snapshot is owed and runs again on the next tick; a
|
|
repair without the snapshot does not settle an owed snapshot. An owed
|
|
profile read runs again when the profile subscription next establishes. A
|
|
replacement is live-only and is opened before the subscription it replaces
|
|
is closed. A replacement that misses its EOSE deadline is closed, the
|
|
subscription it would have replaced stays, and the next tick tries again.
|
|
|
|
Every `renewal_secs` (0 disables it; the first is jittered) the client renews
|
|
its identity and reconnects. In `legacy` that is an ordinary reconnect, which
|
|
rereads everything but channel names and sidebar preferences. In `current`
|
|
the subscriptions are carried over: none establishes while their replays
|
|
keep continuity, so nothing is read. Once any replay is cut, every
|
|
subscription establishes as on a reconnect. With more than ten channels the
|
|
live-only subscription is always cut, so the replays, windows, threads and
|
|
an unread repair run again. A human's session end
|
|
(`human.mean_session_secs`) is an ordinary reconnect in both modes. Resumed
|
|
subscriptions start 960 s before the previous session ended, but not before
|
|
300 s before the client started.
|
|
|
|
Each client runs its reads in one task, at most six at a time. A read that
|
|
comes due while the same read is running or queued is not added again, so a
|
|
slow relay does not pile reads up behind it. A read leaves the running set
|
|
as soon as it settles, so a failed one can retry while a slow one is still
|
|
under way.
|
|
|
|
The app also establishes the channel subscriptions when a tick's replacement
|
|
reaches its EOSE; the generator does not model that.
|
|
|
|
## 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.
|
|
|
|
`ghcr.io/block/buzz-loadgen:main` (and `:sha-<7>`) packages the binary as the
|
|
image entrypoint, published by `.github/workflows/loadgen-image.yml` from
|
|
`Dockerfile.loadgen`. Mount the scenario and pass the subcommand as the
|
|
container arguments, for example `run /etc/loadgen/scenario.toml --shard
|
|
$(JOB_COMPLETION_INDEX)/30`. Pin `:sha-<7>` or a digest for runs you intend
|
|
to compare; `:main` moves. The container runs as UID 10001 with `/` as its
|
|
working directory and no home, so point `--report` at a writable mount.
|
|
|
|
## 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.
|
|
|
|
## Teardown
|
|
|
|
`teardown <scenario>` removes the communities `seed` provisioned from
|
|
`target.community_host_template`, using the operator key `seed` used. For each
|
|
host it archives the community as its fixture owner, then requests owner
|
|
deletion with a request id derived from `run_id` and the host. The
|
|
relay's deletion drain does the deletion asynchronously; `teardown` reports
|
|
the requests by the stage the relay returned (`requested`), hosts this run's
|
|
fixture owner does not own (`absent`), and failures. It never touches a host
|
|
owned by anyone else, and refuses a scenario without a host template.
|
|
|
|
Re-running `teardown` replays the same request ids, so it retries failures
|
|
and reports progress without conflicting. It exits non-zero while any
|
|
community failed.
|
|
|
|
Deleted hosts stay reserved as tombstones, so a torn-down `run_id` cannot be
|
|
seeded again: `seed` fails on the first host. Give each fixture a new
|
|
`run_id`.
|
|
|
|
## 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.
|