Skip to content

Control a Gatling run while it is running

MaxoPerf can change virtual users, request rate and properties on a running test, and pause it, from the run’s Live tab. On JMeter, k6 and Locust that works with no changes to your script.

On Gatling, properties and config files work with no changes — they land in the run’s runtime file like they do on every other executor. It is the load controls — virtual users, request rate and pause — that require four lines in your simulation, and this page is those four lines.

Gatling open source has no runtime injection API. Overriding a running injection profile is a feature of Gatling Enterprise, not of the OSS engine MaxoPerf runs. On top of that, MaxoPerf (through Taurus) hands your simulation its load profile as JVM system properties — peakLoad, rampUp, durationSeconds — and those are read once, when the simulation object is constructed.

So there is no way for anything outside the JVM to move a Gatling simulation’s user count or rate after it starts. What can be done is hand the simulation a live value to read, and let the simulation decide what to do with it. That is the MaxoPerf helper: it is already on the classpath of every Gatling run, and your simulation opts in by reading it.

com.maxoperf.gatling.MaxoPerf is a plain Java class with no dependencies, so the Scala, Java and Kotlin DSLs all use it the same way.

CallReturnsMeaning
MaxoPerf.vus()intThis runner’s share of the live virtual-user target.
MaxoPerf.paceMillis() / MaxoPerf.pace()long / DurationHow long one admitted user’s iteration must take to hit the live rate target. 0 means unlimited.
MaxoPerf.property(key) / MaxoPerf.property(key, default)Optional<String> / StringA property set from the console.
MaxoPerf.paused()booleanTrue while the run is paused.
MaxoPerf.admitted(userId)booleanThe per-user admission gate — pass session.userId.
MaxoPerf.admitted()booleanRun-level gate: not paused and the target is not zero.
MaxoPerf.revision()longRevision of the last applied change (0 = none yet).
MaxoPerf.describe()StringOne-line diagnostic — log it to see exactly what the helper reads.

The virtual-user and rate numbers are this runner’s share. MaxoPerf divides the fleet-global number you type in the console by the number of live runners before it reaches the simulation, so your simulation must not divide again.

Each pattern lights up one control on the run’s Live tab:

Concurrency — change virtual users mid-run

Section titled “Concurrency — change virtual users mid-run”

Inject a fixed maximum pool once and let the live target decide how many of those users are actually working. Users above the target idle in the else branch; raising the target re-admits them on their next loop, so concurrency moves in both directions without restarting the run.

// Concurrency — inject a fixed max pool ONCE, then let the live VU target decide how many of
// those users are working. Users above the target idle in the else-branch and are re-admitted the
// moment you raise it, so the run never has to be restarted to change concurrency.
val maxPool: Int = Integer.getInteger("peakLoad", 50)
val scn = scenario("checkout").during(durationSec.seconds) {
doIfOrElse(session => MaxoPerf.admitted(session.userId)) {
exec(http("GET /").get("/"))
} {
pause(1.second)
}
}
setUp(scn.inject(atOnceUsers(maxPool))).protocols(httpConf)

Throughput — change the request rate mid-run

Section titled “Throughput — change the request rate mid-run”
// Throughput — pace() holds each admitted user's iteration to vus/rps seconds, so the runner's
// request rate follows the live RPS target. The lambda is load-bearing: pace(MaxoPerf.pace())
// without it is evaluated once, at construction, and can never move again.
val scn = scenario("checkout").during(durationSec.seconds) {
pace(_ => MaxoPerf.paceMillis().millis)
.exec(http("GET /").get("/"))
}
// Properties — read per iteration. A value captured into a val at construction can never change.
exec(
http("GET /search")
.get("/search")
.queryParam("variant", _ => MaxoPerf.property("search.variant", "control"))
)

Pause — idle the users without ending the run

Section titled “Pause — idle the users without ending the run”
// Pause — the gate closes and users idle; the run does NOT end and resumes at the same VU target.
// MaxoPerf.admitted(...) already folds in paused(), so the concurrency pattern gets pause for free.
// Read it directly only when a paused user should behave differently.
doIfOrElse(_ => !MaxoPerf.paused()) {
exec(http("GET /").get("/"))
} {
pause(1.second)
}

All four patterns in one file. Upload this, start the run, and every control on the Live tab works.

import com.maxoperf.gatling.MaxoPerf
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class LiveControlledSimulation extends Simulation {
// Start-time profile, injected by Taurus as JVM system properties. MaxoPerf falls back to these
// until the first live change arrives, so an unchanged run behaves exactly as it does today.
val maxPool: Int = Integer.getInteger("peakLoad", 50)
val durationSec: Int = Integer.getInteger("durationSeconds", 300)
val httpConf = http.baseUrl(sys.env.getOrElse("TARGET_ORIGIN", "https://example.com"))
val scn = scenario("live-controlled").during(durationSec.seconds) {
// CONCURRENCY + PAUSE: the pool is fixed, the live VU target decides who works. Everyone else
// idles here — the run keeps going, which is what makes both changes reversible.
doIfOrElse(session => MaxoPerf.admitted(session.userId)) {
// THROUGHPUT: hold each admitted user's iteration to vus/rps seconds.
pace(_ => MaxoPerf.paceMillis().millis)
.exec(
http("GET /")
.get("/")
// PROPERTIES: read per iteration, so a live change lands on the next request.
.header("X-Feature-Flag", _ => MaxoPerf.property("feature.flag.x", "off"))
)
} {
pause(1.second)
}
}
setUp(scn.inject(atOnceUsers(maxPool))).protocols(httpConf)
}

MaxoPerf reads its state from a file the runner writes when a change is applied. That file does not exist until you make your first live change, which is the normal state for most of most runs.

Every accessor therefore falls back to the value the run started with — peakLoad / concurrency / users for virtual users, throughput for the rate — and where even that is unknown, to unbounded: every injected user admitted, no pacing. It never falls back to zero. An opted-in simulation with no live change behaves exactly like one without the helper.

  1. Add the import and one of the patterns above to your simulation and upload it as usual.
  2. Start the run. The Gatling controls on the Live tab become enabled once the run is running.
  3. Change virtual users, rate, a property, or pause. The change reaches the simulation within a second and applies on each user’s next iteration.
  4. Check the timeline. Every change is recorded with who made it, when, and how many runners applied it.
  • The controls are still disabled. The precondition is evaluated from the sources you uploaded, at run create. Check that the file is a .scala, .java or .kt source and that the reference is real code — a mention inside a comment or a string does not count, on purpose. Then start a new run; the fact is recorded when the run is created, not when you press the button.
  • The change is accepted but nothing moves. You are almost certainly reading the value beside a lambda rather than inside one — see the caution above. Log MaxoPerf.describe() from your simulation to see the revision and values it is actually reading.
  • Nothing changes after a pause. Pause closes the admission gate; users idle and the run keeps going. If your scenario does its work outside the gated branch, that work keeps running.