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

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, done by scaling VUs, so 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

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.

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”
  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. MaxoPerf steers away from that default.

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

  4. 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”
  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, 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 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.

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

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-controlled precondition 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_reqs samples, 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 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 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 stays 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. 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.

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

PathContents
runtime.jsonthe merged desired state, with 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"]
}

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.

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:

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 iterations bound your run, and 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 nothing holds 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

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.