Make your test controllable mid-run
MaxoPerf can move the virtual user target and the requests per second target of a running test from the run’s Live tab. Whether those two controls are enabled is decided by the engine and by your script — not by your plan or your account.
This page is the recipe per engine: what your test has to contain, what MaxoPerf does for you, and what to do on an engine that cannot be steered at all.
Which engines can be steered
Section titled “Which engines can be steered”| Engine | Live virtual users | Live requests per second |
|---|---|---|
| JMeter | a Concurrency Thread Group and a duration-based run | a Constant Throughput Timer (any stop mode) |
| k6 | an externally-controlled scenario | same scenario — realised by scaling VUs, approximate |
| Locust | nothing — works as-is | not possible: Locust has no rate primitive |
| Gatling | the MaxoPerf helper opt-in | the same opt-in |
| the other 18 executors | not possible | not possible |
Everything else MaxoPerf can change mid-run — properties and config files on all 22 executors, and pause / resume on the four above — is covered by What each executor supports at runtime.
This is the surface all of it lands on:
JMeter
Section titled “JMeter”Nothing about the two JMeter controls needs __P() in your plan. MaxoPerf rewrites the JMX for
you when it builds the run bundle: it wraps the Concurrency Thread Group’s target and the
throughput timer’s rate in a ${__P(…)} reference to its own properties, keeping your number as
the default so an un-steered run behaves exactly as your plan says. Your job is only to use the two
elements that can follow a change.
Virtual users — use a Concurrency Thread Group
Section titled “Virtual users — use a Concurrency Thread Group”-
In your plan, drive the threads with a Concurrency Thread Group (the JMeter Plugins one,
jpgc-casutg) instead of the classic Thread Group. It is part of Taurus’s default JMeter plugin set, so the MaxoPerf runner already has it — you only need the plugin in your local JMeter to author the plan. -
Set Target Concurrency to the number you want the run to start at. That value becomes the default MaxoPerf steers away from.
-
Bound the run by duration, not iterations. This one is easy to miss and it is not about your plan: when a run is bounded by iterations, Taurus rebuilds it around a classic thread group before JMeter ever sees it, and a classic thread group resolves its thread count once, at plan load. So an iteration-bounded run has no live VU control even with a perfect Concurrency Thread Group. Set a duration (hold-for) instead.
-
Upload and run. Virtual users and Pause are enabled on the Live tab.
The rewritten element looks like this in the bundle — you never type it yourself:
<!-- your plan: <stringProp name="TargetLevel">10</stringProp> --><stringProp name="TargetLevel">${__P(maxoperf_vus_target,10)}</stringProp>Requests per second — add a Constant Throughput Timer
Section titled “Requests per second — add a Constant Throughput Timer”-
Add a Constant Throughput Timer (core JMeter — no plugin) inside the thread group.
-
Set Target throughput to your starting rate, in JMeter’s own unit: samples per minute. It becomes the default, exactly like the thread group’s target.
-
Set Calculate Throughput based on to all active threads (shared), so the number is the whole runner’s request rate rather than each thread’s. A per-thread mode multiplies the rate by your thread count, which then moves every time the VU control moves.
-
Upload and run. Requests per second is enabled on the Live tab.
The rewrite targets the timer’s value where it is stored as a string property, and binds it to the per-minute property so the plan’s own literal stays a valid default in JMeter’s unit:
<!-- your plan: 200 requests/second = <stringProp name="throughput">12000.0</stringProp> --><stringProp name="throughput">${__P(maxoperf_throughput_rpm_total,12000.0)}</stringProp>If you open your .jmx and the timer’s target is not a <stringProp name="throughput">, type the
reference into the Target throughput field yourself, keeping your current per-minute number as
the default:
${__P(maxoperf_throughput_rpm_total,12000)}MaxoPerf leaves a value that is already bound to one of its own properties alone, so writing it by
hand is safe and is never double-wrapped. A __P() function only evaluates inside a string property,
which is why the string form is the one that matters.
Your own __P() keys keep working alongside all of this — see
Live JMeter properties. The maxoperf_ prefix is reserved.
Live VU control on k6 requires the externally-controlled executor. Every other k6 executor
computes its own VU schedule for the whole run and ignores anything written to it afterwards.
import http from 'k6/http';import { sleep } from 'k6';
export const options = { scenarios: { live: { executor: 'externally-controlled', vus: 10, // VUs the run starts with maxVUs: 200, // pool k6 pre-allocates duration: '30m', }, },};
export default function () { http.get('https://example.com/'); sleep(1);}vusis the starting count — the number MaxoPerf steers away from.maxVUsis the pool k6 pre-allocates. k6 refuses avusabove the current pool, so when you ask for more, MaxoPerf raisesmaxVUsin the same request (it only ever raises it, never lowers it — lowering would tear down VUs the scenario may still be scheduling into). Declaring a generousmaxVUsup front means those VUs are allocated before the run starts rather than in the middle of it.
MaxoPerf starts k6 with its REST API bound to the container loopback (--address=127.0.0.1:6565)
for you; there is nothing to enable.
Requests per second on k6 is approximate
Section titled “Requests per second on k6 is approximate”k6 has no API to change an arrival rate on a running test — not on constant-arrival-rate, not
on any executor. MaxoPerf realises an RPS target by scaling the VU count in proportion to the rate
k6 is currently reporting, so:
- it carries the same
k6.externally-controlledprecondition as the VU control; - it is labelled approximate in the console, in the API response and in the capability matrix;
- it is only as good as the linear assumption behind it, and stops being true once the system under test saturates;
- until k6 has recorded its first
http_reqssamples there is nothing to scale from, so the change is reported not applied with that reason rather than being turned into a guessed VU count.
If you need an exact rate on k6, drive it with a constant-arrival-rate scenario and re-run when you
want to change it — that rate is fixed for the run, and MaxoPerf will tell you so rather than
pretending it can move it.
Pause / resume needs no precondition at all on k6: it works on every executor.
Locust
Section titled “Locust”Locust needs no script changes. Virtual users, properties and pause are live on an ordinary locustfile.
MaxoPerf drives the population in-process: a small greenlet loaded into the Locust process calls
runner.start(user_count, spawn_rate) — the exact function Locust’s own swarm endpoint calls — with
a spawn rate derived from the size of the change. It works in master/worker mode too: the master
applies the population change, and every worker applies live properties, because the workers are what
run your user code.
A locustfile can read live properties if it wants to:
from locust import HttpUser, task, between
try: import maxoperf # provided by MaxoPerf when the run startsexcept ImportError: # running locally, outside MaxoPerf maxoperf = None
def flag(key, default): return maxoperf.property(key, default) if maxoperf else default
class Shopper(HttpUser): wait_time = between(1, 2)
@task def browse(self): self.client.get(f"/search?variant={flag('search.variant', 'control')}")Read the value inside the task, as above. A value captured at import time is frozen for the life of the process.
Gatling
Section titled “Gatling”Gatling open source has no runtime injection API — overriding a running injection profile is a Gatling Enterprise feature — and Taurus hands your simulation its load profile as JVM system properties that are read once, when the simulation is constructed. Live load control therefore needs your simulation to opt in by reading the MaxoPerf helper that is already on the classpath of every Gatling run.
Properties and config files need no opt-in on Gatling: they land in the run’s runtime file like they do on every executor.
The four copy-pasteable patterns and a complete simulation are on Control a Gatling run while it is running.
The other 18 executors
Section titled “The other 18 executors”ab, apiritif, external, grinder, junit, mocha, molotov, pbench, playwright,
robot, scalable, selenium, siege, taurus, testng, tsung, vegeta and wdio take their
load parameters when the process starts and expose no channel to change them afterwards. There is no
setup that unlocks live virtual users, requests per second or pause on them: the controls
are absent from the capability registry and the API answers 422 CONTROL_UNSUPPORTED_FOR_EXECUTOR.
There is no workaround for the load itself. There are two things you can do.
1. Change values your script reads — properties and config files
Section titled “1. Change values your script reads — properties and config files”Properties and config files are live on all 22 executors, because they are delivered as files
rather than through an engine API. On every applied change MaxoPerf writes, atomically, into the
run’s runtime directory (/work/artifacts/maxoperf-runtime, also exported as
$MAXOPERF_RUNTIME_DIR):
| Path | Contents |
|---|---|
runtime.json | the merged desired state — revision first, so a poller can detect a change without parsing |
runtime.properties | the same state as a Java properties document, including maxoperf_vus_target and maxoperf_throughput_rps_total (requests per second) |
files/<name> | the payloads of the config-file control |
// runtime.json — the numbers are THIS runner's share of the fleet-wide targets{ "revision": 7, "updatedAtUnixMs": 1754160000000, "properties": { "search.variant": "b" }, "virtualUsers": 100, "throughputRps": 25, "paused": false, "files": ["catalog.csv"]}Every write is a temp file plus a rename, so a reader never sees a half-written document. The file does not exist until the run’s first live change, which is the normal state for most of a run — so treat “missing” as “keep what I have”, never as zero:
import json, os
_RUNTIME = os.path.join( os.environ.get("MAXOPERF_RUNTIME_DIR", "/work/artifacts/maxoperf-runtime"), "runtime.json",)_state = {"revision": 0, "properties": {}}
def live(key, default): """Latest value of a live property. Falls back to `default` until the first change.""" try: with open(_RUNTIME, encoding="utf-8") as fh: snapshot = json.load(fh) if snapshot.get("revision", 0) > _state["revision"]: _state.update(snapshot) except (OSError, ValueError): pass # no change yet, or a torn read — keep what is in force return _state["properties"].get(key, default)Read it per iteration, not once at startup. A config-file payload is read the same way, from
$MAXOPERF_RUNTIME_DIR/files/<name> — up to 20 files, 256 KiB each, data extensions only
(.csv, .json, .yaml, .properties, .txt, …).
This works for any engine whose script can read a file while it runs. A k6 script cannot: k6 only opens files in its init context, so on k6 the runtime file is reachable by nothing inside the script, and live properties there are best delivered as the VU/RPS controls or a re-run.
2. Stop and re-run with a new load profile
Section titled “2. Stop and re-run with a new load profile”For the load itself on these engines, the honest lever is to stop the run and start a new one with the profile you want. MaxoPerf will not offer a control that would accept your change and do nothing.
Why is my control disabled?
Section titled “Why is my control disabled?”The console names the precondition verbatim, and the API returns the same id in a
422 CONTROL_PRECONDITION_UNMET body. Each one has exactly one fix:
| Hint | What it means | Fix |
|---|---|---|
jmeter.concurrency-thread-group | either your plan’s threads come from a Classic, Stepping or Ultimate Thread Group (which resolve their count once at plan load), or your run is bounded by iterations — Taurus rebuilds an iteration-bounded run around a classic thread group | use a Concurrency Thread Group and bound the run by duration, then start a new run |
jmeter.throughput-timer | the plan has no throughput timer, so there is nothing holding the rate | add a Constant Throughput Timer to the thread group |
k6.externally-controlled | the run’s script declares no externally-controlled scenario — including when concurrency set on the test overrode the one it had | declare the scenario in the script, and leave the test’s load-profile fields empty |
gatling.maxoperf-helper | the simulation does not reference com.maxoperf.gatling.MaxoPerf in real code (a mention in a comment or a string does not count) | add the opt-in and start a new run |
Two other reasons a control is not writable, neither of which is a precondition:
- Not supported for this executor — the engine has no channel for it (Locust throughput, and every load control on the other 18). Nothing about your script can change this.
- Run not running — controls exist only for a live run. A finished run’s Live tab is a read-only record of what was changed.