Rules and objectives
Every run is checked. Findings come back worst first, so code printing one line prints the thing that matters.
Validity outranks results
Severity.INVALID sits above FAILED, and an
invalid run never passes whatever its objectives
say.
A run whose generator never offered its load has not measured the broker. It has no result to judge, and reporting "300,000/s: FAILED" when the client could only produce 90,000 blames the wrong machine — sending somebody to look at broker capacity when the problem is the test harness.
| Severity | |
|---|---|
INVALID |
the run did not measure what it claims. No conclusion may be drawn |
FAILED |
an objective was not met, and the measurement is sound |
WARNING |
changes how the result should be read |
INFO |
worth knowing, not a problem |
The rules that always run
generator-kept-up —
INVALID
Fires when publishes ran more than 100ms behind their own schedule at p99.
The most important check in the set, and the one homemade load tools do not have. If message n was due at t and went out at t+4s, the offered rate was not the configured rate, and every throughput figure describes what the client managed.
[INVALID] generator-kept-up
observed: publishes ran 6220.2ms behind their own schedule at p99 (max 6295.7ms);
the configured rate was 2,000/s and the achieved rate was 1,048/s
means: the configured load was never offered, so this run does not show what
the broker can take. Raise publisher threads, or run the generator on
a machine that is not also the broker, and repeat
detail: publisher threads=2, send lag p50=4209.0ms
Usually the client: too few publisher threads, a
maxInFlight too small, a saturated CPU, or a generator
sharing a machine with the broker. It can also be the broker applying
back-pressure, which is why the rule reports the observation rather than
naming a cause.
broker-not-blocked —
INVALID
Fires when the connection was blocked by a broker resource alarm during the run.
A memory or disk alarm blocks publishing connections. The broker keeps running, the consumers keep working, the publishers stop — so the run measures how long the alarm lasted rather than how fast the broker is.
The messaging library refuses a publish into a blocked broker at
once, without writing it (acemq-java-amqp 0.7.15 and later; before that
it waited up to 30 seconds). The load counts each one as
refused in its readings, not as failed:
nothing was sent, so nothing was lost. The message that was already
written when the alarm started is different. It fails within the confirm
timeout as possibly published, and that one is counted as
failed.
publishes-succeeded —
FAILED
Fires when any publish was refused or errored. Different from being slow: a nacked publish is a message the broker did not take responsibility for.
consumers-kept-up —
WARNING
Fires when more than a tenth of what was published was still queued at the end.
Not a failure — a run that deliberately fills a queue is doing this on purpose. It is a warning because the end-to-end latency of a growing queue is a function of how long the run lasted: run it twice as long and the p99 doubles, from the same system.
confirms-were-on —
WARNING
Fires when publisher confirms were off. The throughput then counts messages handed to a socket rather than messages the broker accepted — an upper bound a durable configuration will not reproduce.
tail-is-not-extreme —
WARNING
Fires when p99 is more than ten times the median. Most messages fast, a small fraction far slower — and a mean or median from that run would describe almost nobody's experience.
run-was-long-enough —
WARNING
Fires when the measured window was under thirty seconds. Short runs measure JIT compilation, connection setup and the first collection as much as the broker.
Objectives
expect:
throughputAtLeast: 45000
p99Below: 50ms
noMessagesLost: true.expect(Objective.throughputAtLeast(45_000))
.expect(Objective.p99Below(Duration.ofMillis(50)))
.expect(Objective.percentileBelow(99.9, Duration.ofMillis(200)))
.expect(Objective.noMessagesLost())throughputAtLeast measures what was
consumed when there are consumers, not what was published. A
publish rate the consumers never matched is a queue filling up rather
than a system running. It reports the shortfall as a percentage:
[FAILED] throughput>=300000
observed: sustained 180,000 msg/s end-to-end, against an objective of 300,000
means: this configuration delivers 60% of the required rate
detail: published=10,800,000 consumed=10,800,000 over 1m0s
percentileBelow is judged on the
percentile, never the mean. A run with 980 messages at 1ms and 20 at
200ms has a mean under 5ms and a p99 of 200ms — a tool reporting the
mean would call it healthy.
noMessagesLost compares confirmed
against consumed. It detects loss and does not detect
duplication, which needs the sequence numbers and is a different
measurement. When it fires, the queue depth in the report distinguishes
"still draining" from "gone".
The comparison carries no tolerance, and at the end of a run that matters: a confirm landing inside the measured window whose delivery is counted just after it reads exactly like loss. At 5,000/s a healthy run reports two or three messages unaccounted for beside a queue depth of zero often enough to be a coin toss. Read the finding, not only the verdict — a shortfall the queue depth cannot account for is the thing this objective exists to catch, and a shortfall of a handful beside an empty queue is delivery that finished late. Until the boundary is handled, it is an objective for runs where losing messages is the thing under test rather than one to put in an unattended gate.
Rules in the file
A workload file can carry its own rules, in the shape of the admin
library's alerts: a name, one metric, above or
below, a severity, and what crossing it means. No Java
needed.
rules:
- name: queue-ends-shallow
metric: queueDepthAtEnd
above: 5000
severity: failed # invalid, failed, warning or info. Default failed
because: a queue that ends this deep was filling for the whole run
- name: tail-stays-reasonable
metric: endToEnd.p999
above: 250ms
severity: warning
- name: almost-no-errors
metric: errorRate
above: 0.1%They run beside the defaults and the expect: objectives,
and their findings read like every other:
[FAILED] queue-ends-shallow
observed: queueDepthAtEnd was 41,200, above 5,000
means: a queue that ends this deep was filling for the whole run
| Metric | |
|---|---|
offeredRate publishRate
consumeRate |
messages a second over the measured window |
published confirmed consumed
failed |
counts |
errorRate |
failed publishes as a share of attempts: 0.1% or
0.001 |
backlog |
published minus consumed |
queueDepthAtEnd |
the depth when the run finished, if it could be read |
blockedSeconds |
time spent blocked by a resource alarm |
endToEnd., publish., sendLag.
+ p50 p90 p99 p999
max |
latencies, compared as durations: 50ms, never a bare
number |
A metric the run did not produce never fires — a latency with no consumers, a depth that could not be read. There is nothing to compare, and a rule that fired on a missing number would be reporting the configuration, not the run.
Validated on load. A misspelled metric, a bare
number for a latency, a share over 100%, a rule with both
above and below or neither, an unknown key:
each stops the file with exit 3 and names the rule by position —
'rules[2].metric' is 'p99', which is not a metric. Known metrics: ....
A rule that silently never ran would be a gate that always passes.
In a suite, top-level
rules: apply to every entry; an entry's own
rules: replaces them. In a ramp, a
rule that fires with failed or invalid stops
the climb.
From Java, the same rule is a Threshold:
Workload.named("x")
.rule(Threshold.named("queue-ends-shallow")
.on("queueDepthAtEnd").above(5_000)
.severity(Severity.FAILED)
.because("a queue that ends this deep was filling for the whole run"))
/* ... */;Writing your own
Rule slowStart = report -> {
if (report.endToEnd().max().toMillis() < 500) {
return Optional.empty();
}
return Optional.of(Finding.of("slow-start", Severity.WARNING,
"the slowest message took " + report.endToEnd().max().toMillis() + "ms",
"a single outlier this far from p99.9 is usually a garbage collection"
+ " or a connection being re-established"));
};
Workload.named("x").rule(slowStart) /* ... */;A rule sees the whole WorkloadReport and returns a
Finding or nothing. Returning nothing is the normal case —
a rule that fires on every run is one nobody reads, which is the same
failure mode as an alert that fires on every burst.
Put numbers in the observation. A finding without a measurement is an opinion, and the constraint is what keeps this from becoming a tool that guesses.
Why nothing here recommends a setting
No rule says "increase prefetch to 250".
A tool cannot know your handler's processing time distribution, your message sizes, your network, your disk, or what else shares the broker. And a confident recommendation from a tool gets followed — which makes a wrong one worse than saying nothing.
What a tool can say honestly is what it measured and what that rules
in or out. So a Finding carries an
observation() and an implication(), and
neither is advice.