The workload file
YAML or JSON, chosen by the file extension. YAML is the better choice for anything a person maintains, because a load test's configuration wants a comment explaining why the rate is what it is, and JSON has none.
# Peak hour is 38,000/s measured over the last quarter.
# 50,000 gives headroom for a bad Monday.
name: orders-peak
broker: amqp://guest:${BROKER_PASSWORD}@localhost:5672
management: http://localhost:15672
topology:
exchange: orders
exchangeType: topic
queue: orders.new
routingKey: order.created
queueType: classic
arguments:
x-message-ttl: 60000
publishers:
threads: 4
rate: 50000
messageSize: 1024
confirms: true
maxInFlight: 1000
consumers:
concurrency: 8
prefetch: 100
handlerTime: 1ms
warmup: 10s
runFor: 2m
expect:
throughputAtLeast: 45000
p99Below: 50ms
noMessagesLost: trueTop level
| Setting | ||
|---|---|---|
name |
required | identifies the run in the report |
broker |
AMQP URL. Required unless --broker
supplies it, which also overrides it |
|
management |
management URL, for the queue depth at the end | |
managementUser / managementPassword |
default guest |
|
warmup |
10s |
run this long before measuring anything |
runFor |
60s |
the measured window, or until-stopped
(forever is accepted too) for a run that ends only when
something stops it |
A run with no fixed end
runFor: until-stoppedFor a workload that exists to occupy a broker rather than to answer a question with a number: a queue that has to have consumers on it for as long as somebody else's experiment lasts, a generator kept beside a cluster being deliberately broken. A run like that has no natural length, and making it guess one is how it ends early — an experiment that outlasts the guess spends its last minutes measuring a broker nobody is using, which is the condition the workload was there to prevent.
It measures everything an ordinary run measures, and the report
describes the window that actually happened rather than the one that was
asked for. The only way it ends is being stopped: SIGTERM
on the command line, or RunHandle.stop() in the DSL. Both
write the report.
topology
| Setting | ||
|---|---|---|
exchange |
"" |
the default exchange routes by queue name and skips exchange routing — the fastest path, and not what most systems do |
exchangeType |
topic |
topic, direct, fanout,
headers |
queue |
acemq.workload |
|
routingKey |
the publish key, and the binding key | |
queueType |
classic |
or quorum |
arguments |
queue arguments | |
declare |
true |
set false to use the topology as it already exists |
queueType and arguments are what the queue
is declared with, so a suite of two workloads differing only in
queueType is a real comparison — see a
suite below. The broker is still the authority, and checking it
costs one command:
curl -su guest:guest http://localhost:15672/api/queues/%2F/orders.newA workload is one queue, and classic and
quorum are the two kinds worth comparing along a single
path. stream is refused here rather than quietly
downgraded: a stream is read by offset and deletes nothing, so its
consumers, its depth and its backlog rules all mean something else.
Write a scenario for one — its queues
take a type, and examples/06-stream.yaml is
what asking for one looks like.
exchangeType without exchange is refused
for the same reason. With no exchange the workload publishes through the
default one, which routes by queue name and has no type, so there would
be nowhere for the value to go.
declare: false is the right choice when measuring a real
environment. Declaring would either be refused for mismatched arguments
or, worse, create something subtly different from what production runs
and measure that.
publishers
| Setting | ||
|---|---|---|
threads |
1 |
how many threads share the offered rate |
rate |
offered messages per second, across all threads | |
unthrottled |
false |
publish as fast as possible — see below |
messageSize |
1024 |
bytes, including a 16-byte header |
randomPayload |
false |
random bytes rather than zeroes |
confirms |
true |
wait for the broker to confirm |
maxInFlight |
1000 |
unconfirmed publishes a thread may hold |
maxMessages |
stop after this many |
threads and rate are separate on
purpose. "Ten producers" is not a load. The rate is the
property of the experiment; the threads are how much parallelism the
client uses to keep up with it.
maxInFlight is the setting that decides whether
you can generate a serious rate. A publish that waits for its
own confirm costs a network round trip, so a thread doing that is capped
at about 550 messages a second against a broker 1.8ms away — whatever
the broker can take. Measured on the same broker:
| offered 2,000/s | one at a time | windowed |
|---|---|---|
| achieved | 1,048/s | 2,000/s |
| send lag p50 | 4,209ms | 201µs |
unthrottled: true measures throughput and
invalidates latency. With no schedule there is no "due" time,
so a broker that stalls also stalls the generator, and the recorded
latency improves as the stall worsens. Use it to find the ceiling, then
set rate below the ceiling and measure latency there. The
report says so when you do.
confirms: false changes what the throughput
means. A publish without confirms is a message handed to a
socket, not one the broker accepted. The rate goes up and some of those
messages were never durably anywhere — not a number to promise a
customer. The report raises the confirms-were-on warning
whenever it happened, because a rate produced this way needs the
sentence above attached to it. Two runs differing only in this field,
4,000/s of 1KB messages:
| publish latency | confirms: true |
confirms: false |
|---|---|---|
| p50 | 777µs | 23µs |
| p99 | 1.6ms | 77µs |
That gap is the round trip to the broker and back, which is exactly what the setting buys and exactly what it costs.
randomPayload: true may be written on its own; it keeps
whatever messageSize is in force. Worth using when anything
downstream compresses or deduplicates — a run of zeroes compresses to
nothing, and a broker or network that compresses will report a
throughput no real payload reproduces.
consumers
| Setting | ||
|---|---|---|
concurrency |
1 |
how many consumers. 0 for a publish-only run |
prefetch |
100 |
unacknowledged messages a consumer holds. 0 is
unlimited |
handlerTime |
0s |
simulated work per message |
failureRate |
0 |
fraction of messages the handler rejects |
handlerTime is the setting people leave out and
then wonder why the results do not resemble production. A
consumer that does nothing measures the broker; a consumer that takes
two milliseconds measures the broker and what happens when the
application is the slow part — which is where almost every real system
lives.
It is implemented as a sleep, which models waiting on a database or an HTTP call. It does not model a CPU-bound handler, because a sleeping thread does not compete for a core the way a busy one does.
expect
Objectives. For thresholds on anything else in the report, see rules.
| Setting | |
|---|---|
throughputAtLeast |
messages per second, measured end to end when there are consumers |
p50Below / p99Below /
p999Below |
a latency budget |
noMessagesLost |
every confirmed message must arrive |
throughputAtLeast counts what was consumed, not
what was published. A publish rate the consumers never matched is a
queue filling up rather than a system running.
rules
Thresholds on any number in the report, carried by the file. See rules in the file for the metrics.
rules:
- name: queue-ends-shallow
metric: queueDepthAtEnd
above: 5000
severity: failed
because: a queue that ends this deep was filling for the whole runramp
Turns the workload into a ramp: the rate stepped up until a rule fires, to find the highest rate that meets every objective.
| Setting | |
|---|---|
start |
the first step's rate. Default: publishers.rate |
step |
required. An absolute rate, 1000, or a
share of the step before, 25% |
stepFor |
each step's measured window, at least 5s. Default:
runFor |
warmup |
before each step. Default: warmup |
maxRate |
the highest rate offered |
maxErrorRate |
failed publishes allowed per step: 0.1%. Default 0 |
tolerance |
how far the delivered rate may fall short of the offered one.
Default 5% |
refine |
bisection steps between the last good and first bad rate. Default 0 |
ceilingAtLeast |
the gate: exit 1 if the highest sustained rate is lower |
Durations
500ms, 30s, 5m,
2h, 250us, 100ns.
A bare number is refused. runFor: 60 is a minute to one
reader and a second to another, and a run of the wrong length produces
numbers that look entirely plausible.
Environment variables
${VAR} and ${VAR:-default} are substituted
before parsing.
An unset variable with no default is an error, not an empty string. A workload file is meant to be committed and reviewed; a literal password is a password in the git history.
Unknown settings are refused
unknown setting 'consumers.prefech'. Known settings here: concurrency,
prefetch, handlerTime, failureRate. A misspelled setting that was ignored would
run with the default and report a result that looks entirely normal.
That is the whole argument. A typo that is silently ignored produces a full report for a configuration nobody chose.
A suite
Several workloads in one file. Each entry inherits the top level, so the shared parts are written once and cannot drift apart:
broker: amqp://guest:guest@localhost:5672
publishers: { threads: 4, rate: 20000, messageSize: 1024 }
consumers: { concurrency: 8, prefetch: 100 }
warmup: 10s
runFor: 1m
workloads:
- name: classic-queue
topology: { exchange: bench, queue: bench.classic, routingKey: k, queueType: classic }
expect: { p99Below: 20ms }
- name: quorum-queue
topology: { exchange: bench, queue: bench.quorum, routingKey: k, queueType: quorum }
expect: { p99Below: 20ms }They run in order against the same broker and are reported side by side. This is how to answer "what does a quorum queue cost us" with a number rather than an opinion — and why the shared settings are inherited rather than copied, since two copies that disagree by one field make the comparison meaningless.
On the laptop this was written on, at 3,000/s of 1KB messages, that
suite came back with an end-to-end p99 of 2.4ms for the classic queue
and 38.3ms for the quorum one. Both figures are a single node's, and a
single node is the least flattering case for a quorum queue in one
respect and the most flattering in another: there is no network hop, and
there is also no second node to absorb the Raft log write while this one
serves consumers. Take the shape, measure your own numbers.
examples/05-queue-types.yaml runs both queues at once
instead, off one fanout exchange, which is a different and equally
honest experiment — the same messages in the same second, on a broker
doing both jobs.
Inheritance replaces a block; it does not merge into
it. An entry writing
publishers: { confirms: false } gets exactly that
publishers block, and threads, rate and
messageSize go back to their defaults. The run is valid,
the report looks entirely normal, and it is not the experiment you
wrote. When a suite varies one field inside a block, write the
whole block out in each entry and let the two be read side by side.