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. The engine and your script decide whether those two controls are enabled. Your plan and your account do not.
This page gives 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, done by scaling VUs, so 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 |
MaxoPerf can also change properties and config files mid-run on all 22 executors, and pause / resume on the four above. See What each executor supports at runtime.
JMeter
Section titled “JMeter”You do not need __P() in your plan for the two JMeter controls. 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. Your number stays as
the default, so a run nobody steers behaves exactly as your plan says. Your only job is 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. MaxoPerf steers away from that default.
-
Bound the run by duration, not iterations. This step is easy to miss, and it has nothing to do with your plan. When iterations bound a run, Taurus rebuilds it around a classic thread group before JMeter sees it. 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 correct Concurrency Thread Group. Set a duration (hold-for) instead.
-
Upload and run. The Live tab enables Virtual users and Pause.
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>A plan with several Concurrency Thread Groups still has one live VU number: the run’s total. Each group keeps its share of that total, in the proportion of its own Target Concurrency. A change to 30 VUs moves the whole plan to 30, not every group to 30. MaxoPerf writes the share as a JMeter expression. For two groups set to 30 and 10, a run of 100 VUs starts them at 75 and 25:
<!-- group 1 (30): 3/4 of the total --><stringProp name="TargetLevel">${__jexl3((${__P(maxoperf_vus_target,100)} * 3) / 4)}</stringProp><!-- group 2 (10): the rest --><stringProp name="TargetLevel">${__jexl3((${__P(maxoperf_vus_target,100)} * 4) / 4 - (${__P(maxoperf_vus_target,100)} * 3) / 4)}</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, 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 and not each thread’s. A per-thread mode multiplies the rate by your thread count, which then changes every time the VU control moves.
-
Upload and run. The Live tab enables Requests per second.
The rewrite targets the timer’s value where JMeter stores it as a string property. It binds the value 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. Keep your current per-minute number as the
default:
${__P(maxoperf_throughput_rpm_total,12000)}MaxoPerf leaves alone any value already bound to one of its own properties. Writing it by hand is
safe, and MaxoPerf never wraps it twice. A __P() function only evaluates inside a string property,
so 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 later.
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. MaxoPerf steers away from that number.maxVUsis the pool k6 pre-allocates. k6 refuses avusabove the current pool. When you ask for more, MaxoPerf raisesmaxVUsin the same request. It only ever raises it and never lowers it, because lowering would tear down VUs the scenario may still be scheduling into. Declare a largemaxVUsup front and the whole pool exists before the run starts, not 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. You have 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. That holds for constant-arrival-rate
and for every other executor. MaxoPerf reaches an RPS target by scaling the VU count in proportion to
the rate k6 reports right now. As a result:
- it has the same
k6.externally-controlledprecondition as the VU control; - the console, the API response and the capability matrix label it approximate;
- it is only as accurate as the linear assumption behind it, which stops holding once the system under test saturates;
- until k6 has recorded its first
http_reqssamples, MaxoPerf has nothing to scale from. It reports the change as not applied, with that reason, instead of guessing a VU count.
If you need an exact rate on k6, use a constant-arrival-rate scenario and re-run when you want to
change it. That rate is fixed for the run, and MaxoPerf tells you so instead of pretending it can move
it.
Pause / resume on k6 needs the same externally-controlled executor as virtual users. k6
refuses to pause any other executor once the test has started, so MaxoPerf shows pause as
unavailable on those scripts instead of sending a change k6 would reject.
Locust
Section titled “Locust”Locust needs no script changes. Virtual users, properties and pause work 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 same function Locust’s own swarm endpoint calls. It
derives the spawn rate from the size of the change. Master/worker mode works too: the master
applies the population change, and every worker applies live properties, because the workers run your
user code.
A locustfile can read live properties if you want it 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 stays 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. Taurus also hands your simulation its load profile as JVM system properties, which Gatling reads once, when it constructs the simulation. Live load control therefore needs your simulation to opt in by reading the MaxoPerf helper. That helper 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, as they do on every executor.
Control a Gatling run while it is running has the four copy-paste patterns and a complete simulation.
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 have no channel to change them later. No setup enables
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.
You cannot work around this for the load itself. You can do two other things.
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 MaxoPerf delivers them as
files and not 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, with 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"]}Each 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, and that is the normal state for most of a run. 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 on each iteration, not once at startup. Read a config-file payload the same way, from
$MAXOPERF_RUNTIME_DIR/files/<name>. The limits are 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 nothing inside a k6 script can reach the runtime file. There, use the VU/RPS controls or a re-run instead of live properties.
2. Stop and re-run with a new load profile
Section titled “2. Stop and re-run with a new load profile”To change the load itself on these engines, stop the run and start a new one with the profile you want. MaxoPerf does 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 word for word, 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 iterations bound your run, and 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 nothing holds 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 |
A control can also be read-only for two reasons that are not preconditions:
- Not supported for this executor: the engine has no channel for it (Locust throughput, and every load control on the other 18). No change to your script can fix this.
- Run not running: controls exist only for a live run. A finished run’s Live tab is a read-only record of what changed.