Skip to content

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.

EngineLive virtual usersLive requests per second
JMetera Concurrency Thread Group and a duration-based runa Constant Throughput Timer (any stop mode)
k6an externally-controlled scenariosame scenario — realised by scaling VUs, approximate
Locustnothing — works as-isnot possible: Locust has no rate primitive
Gatlingthe MaxoPerf helper opt-inthe same opt-in
the other 18 executorsnot possiblenot 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:

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”
  1. 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.

  2. Set Target Concurrency to the number you want the run to start at. That value becomes the default MaxoPerf steers away from.

  3. 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.

  4. 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”
  1. Add a Constant Throughput Timer (core JMeter — no plugin) inside the thread group.

  2. 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.

  3. 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.

  4. 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);
}
  • vus is the starting count — the number MaxoPerf steers away from.
  • maxVUs is the pool k6 pre-allocates. k6 refuses a vus above the current pool, so when you ask for more, MaxoPerf raises maxVUs in 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 generous maxVUs up 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.

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-controlled precondition 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_reqs samples 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 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 starts
except 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 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.

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):

PathContents
runtime.jsonthe merged desired state — revision first, so a poller can detect a change without parsing
runtime.propertiesthe 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.

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:

HintWhat it meansFix
jmeter.concurrency-thread-groupeither 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 groupuse a Concurrency Thread Group and bound the run by duration, then start a new run
jmeter.throughput-timerthe plan has no throughput timer, so there is nothing holding the rateadd a Constant Throughput Timer to the thread group
k6.externally-controlledthe run’s script declares no externally-controlled scenario — including when concurrency set on the test overrode the one it haddeclare the scenario in the script, and leave the test’s load-profile fields empty
gatling.maxoperf-helperthe 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.