Chapter 12Lesson 02~220 minutes

Controllers: Simple, Loop, Transaction, If, While, Switch, and Throughput: Guided Hands-On Workflow

This workflow uses a disposable loopback service whose endpoints make controller choices visible. The JMeter plan predicts exact target request counts, then an independent server log verifies them so Transaction Controller reporting cannot be confused with actual traffic.

Controller treeSample mathParent transactionBranch mixJTL labels

Learning objectives

  • Create a multi-step local journey with every Chapter 12 controller.
  • Drive If/Switch logic from explicit thread variables.
  • Use a response-derived boolean as the While condition.
  • Predict target request counts before running.
  • Compare additional-mode versus parent-mode Transaction Controller CSV JTL.
  • Compare Throughput Controller total-execution and percentage modes without calling either an RPS target.

1. Safety envelope

Mandatory target: only http://127.0.0.1:8000. Main workflow uses 1 thread × 2 outer iterations. Percentage demonstration uses at most 1 thread × 10 iterations. Failure reproductions add an explicit Thread Group duration ≤4 seconds and a 200 ms poll timer. No public or production target is permitted.

2. Start the controller fixture

Save as fixtures/controller_fixture.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from pathlib import Path
import argparse
import json
import threading
import time

lock = threading.Lock()
total = 0
by_path = {}
poll_count = {}
event_log = None

def record(event):
    if event_log is None:
        return
    with lock:
        with event_log.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(event, sort_keys=True) + "\n")

def encode(obj):
    return json.dumps(obj, sort_keys=True).encode("utf-8")

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _send(self, status, payload):
        raw = encode(payload)
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_GET(self):
        global total
        parsed = urlparse(self.path)
        path = parsed.path
        q = {k: v[-1] for k, v in parse_qs(parsed.query).items()}

        if path == "/health":
            self._send(200, {"status": "ok"})
            return

        if path == "/reset":
            with lock:
                by_path.clear()
                poll_count.clear()
            self._send(200, {"status": "reset"})
            return

        if path == "/stats":
            with lock:
                snap = {
                    "total": total,
                    "by_path": dict(by_path),
                    "poll_count": dict(poll_count),
                }
            self._send(200, snap)
            return

        started_ms = int(time.time() * 1000)
        with lock:
            total += 1
            request_no = total
            by_path[path] = by_path.get(path, 0) + 1

        status = 200
        extra = {}
        time.sleep(0.02)

        if path == "/browse":
            payload = {"status": "ok", "page": "catalog", "request_no": request_no}

        elif path == "/search":
            idx = q.get("idx", "")
            payload = {"status": "ok", "search_index": idx, "results": 3}

        elif path == "/poll-reset":
            thread_id = q.get("thread", "unknown")
            with lock:
                poll_count[thread_id] = 0
            extra["thread"] = thread_id
            payload = {"status": "ok", "poll_more": "true", "thread": thread_id}

        elif path == "/poll":
            thread_id = q.get("thread", "unknown")
            with lock:
                n = poll_count.get(thread_id, 0) + 1
                poll_count[thread_id] = n
            more = n < 2
            extra.update({"thread": thread_id, "poll_number": n})
            payload = {
                "status": "ok",
                "poll_number": n,
                "poll_more": "true" if more else "false",
                "thread": thread_id,
            }

        elif path == "/checkout":
            mode = q.get("mode", "")
            extra["mode"] = mode
            if mode not in {"standard", "express"}:
                status = 400
                payload = {"status": "bad_mode", "mode": mode}
            else:
                payload = {"status": "accepted", "mode": mode}

        elif path == "/upsell":
            payload = {"status": "shown", "offer": "synthetic-addon"}

        elif path == "/error":
            status = 500
            payload = {"status": "synthetic_error"}

        else:
            status = 404
            payload = {"status": "not_found", "path": path}

        finished_ms = int(time.time() * 1000)
        event = {
            "path": path,
            "status": status,
            "request_no": request_no,
            "started_ms": started_ms,
            "finished_ms": finished_ms,
            "service_wall_ms": finished_ms - started_ms,
        }
        event.update(extra)
        record(event)
        self._send(status, payload)

    def log_message(self, format, *args):
        return

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--log", default="results/server-events.jsonl")
    args = parser.parse_args()
    event_log = Path(args.log).resolve()
    event_log.parent.mkdir(parents=True, exist_ok=True)
    event_log.write_text("", encoding="utf-8")
    print("fixture=http://127.0.0.1:8000")
    print(f"event_log={event_log}")
    ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

Start and preflight:

python fixtures/controller_fixture.py --log results/server-events.jsonl
curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8000/reset

3. Define explicit branch data

At Test Plan/Thread Group scope define:

DO_CHECKOUT = true
CHECKOUT_MODE = standard
POLL_MORE = true

DO_CHECKOUT is control data, CHECKOUT_MODE selects one business branch, and POLL_MORE is refreshed from the synthetic poll response. They are thread-local JMeter variables during execution.

4. Build the progressive controller tree

Thread Group — 1 user × 2 outer iterations
└── Transaction Controller — "Journey Transaction"
    Generate Parent Sample: OFF (first run)
    Include timers/pre-post processors: OFF
    ├── Simple Controller — "Browse Phase"
    │   └── Browse — GET /browse
    │
    ├── Loop Controller — "SearchLoop" × 2
    │   └── Search — GET /search?idx=${__jm__SearchLoop__idx}
    │
    ├── Poll Reset — GET /poll-reset?thread=${__threadNum}
    │   └── JSON JMESPath Extractor: poll_more -> POLL_MORE (default false)
    │
    ├── While Controller — "WaitForReady"
    │   Condition: ${POLL_MORE}
    │   └── Poll — GET /poll?thread=${__threadNum}
    │       └── JSON JMESPath Extractor: poll_more -> POLL_MORE (default false)
    │
    ├── If Controller — "Do Checkout"
    │   Interpret as Variable Expression: ON
    │   Condition: ${DO_CHECKOUT}
    │   └── Switch Controller — "Checkout Mode"
    │       Switch value: ${CHECKOUT_MODE}
    │       ├── Simple Controller named "standard"
    │       │   └── Checkout Standard — GET /checkout?mode=standard
    │       ├── Simple Controller named "express"
    │       │   └── Checkout Express — GET /checkout?mode=express
    │       └── Simple Controller named "default"
    │           └── Checkout Default — GET /checkout?mode=standard
    │
    └── Throughput Controller — "Upsell Population"
        Mode: Total Executions
        Throughput: 1
        Per User: OFF
        └── Upsell — GET /upsell

5. Why the While Controller terminates safely

/poll-reset returns poll_more=true and resets the server's per-thread poll count. The first /poll returns true; the second returns false. The extractor overwrites POLL_MORE after each Poll sample. Because Post-Processors run before the next controller evaluation, the While loop exits after two Poll requests.

The extractor default is false, so a malformed poll response fails closed instead of turning into an infinite loop.

6. Predict exact target requests

Per outer iteration before the Throughput branch:

Step Requests per outer iteration
Browse 1
Search Loop ×2 2
Poll Reset 1
While Polls 2
Checkout branch 1
Subtotal 7

Two outer iterations produce 14 deterministic target requests. Throughput Controller Total Executions=1, Per User off adds exactly one Upsell execution across the plan. Therefore:

expected target requests = 14 + 1 = 15

Transaction Controller is not a target request.

7. Predict CSV JTL rows in additional-sample mode

The additional Transaction Controller generates one transaction result per outer iteration. With two outer iterations:

15 child HTTP samples + 2 "Journey Transaction" samples = 17 CSV JTL rows

The server event log should still contain only 15 target requests.

8. Run additional-sample mode in CLI

mkdir -p results/additional
jmeter -n   -t plans/controllers-additional.jmx   -l results/additional/results.jtl   -j results/additional/jmeter.log   -Jjmeter.save.saveservice.print_field_names=true   -Jjmeter.save.saveservice.thread_counts=true

python tools/analyze_jtl_labels.py results/additional/results.jtl
python tools/analyze_server_events.py results/server-events.jsonl
curl --fail --silent http://127.0.0.1:8000/stats

PowerShell uses jmeter.bat, backtick continuation, and Invoke-WebRequest for stats.

9. Save the evidence analyzers

tools/analyze_jtl_labels.py:

import csv
import sys
from collections import Counter, defaultdict
from pathlib import Path

path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/run/results.jtl")
rows = list(csv.DictReader(path.open(encoding="utf-8")))
if not rows:
    raise SystemExit("No JTL rows found")

required = {"label", "success", "responseCode", "elapsed"}
missing = required.difference(rows[0])
if missing:
    raise SystemExit(f"Missing JTL columns: {sorted(missing)}")

labels = Counter(r["label"] for r in rows)
codes = Counter(r["responseCode"] for r in rows)
failures = [r for r in rows if r["success"].strip().lower() != "true"]

print(f"jtl_rows={len(rows)}")
print(f"failures={len(failures)}")
print(f"response_codes={dict(codes)}")
print("labels:")
for label, count in sorted(labels.items()):
    print(f"  {label}: {count}")

child_like = [
    r for r in rows
    if r["label"] not in {"Journey Transaction"}
]
print(f"non_transaction_rows={len(child_like)}")
print(
    "Interpretation warning: a Transaction Controller sample is a reporting sample, "
    "not an extra target request. In Generate Parent Sample mode child samples do not "
    "appear as separate CSV JTL rows, so compare against independent target/server counts."
)

tools/analyze_server_events.py:

import json
import sys
from collections import Counter
from pathlib import Path

path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/server-events.jsonl")
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]

paths = Counter(e["path"] for e in events)
codes = Counter(e["status"] for e in events)
print(f"target_requests={len(events)}")
print(f"target_statuses={dict(codes)}")
print("target_paths:")
for path, count in sorted(paths.items()):
    print(f"  {path}: {count}")

print(
    "Target request count excludes JMeter Transaction Controller samples because "
    "those samples are generated by JMeter rather than sent to the server."
)

10. Compare Generate Parent Sample

Reset fixture state, copy the plan, and enable Generate Parent Sample on Journey Transaction. Keep all other controller settings identical.

Expected target traffic remains 15 HTTP requests. But CSV JTL changes: child samples are sub-samples of the two transaction parents and are not separate CSV rows. Expect approximately:

2 CSV JTL rows: one "Journey Transaction" parent per outer iteration

View Results Tree may show child sub-samples during tiny GUI authoring, but do not use it in the load run.

11. Compare transaction timing option

With a small 100 ms Constant Timer under Browse, compare Transaction Controller:

  • Include timer/pre-post processing OFF: transaction timing excludes those waits by default.
  • ON: transaction timing includes processing/timer delays in its scope.

Endpoint sampler elapsed remains protocol timing. Label the transaction metric clearly so a business-journey duration is not misreported as endpoint latency.

12. If Controller branch proof

Change only DO_CHECKOUT=false for a one-iteration debug run. Expected target requests drop by one checkout request for that iteration. The Switch Controller is never entered.

Do not delete the checkout branch from the tree; the difference is control data, not test structure.

13. Switch Controller branch proof

Restore DO_CHECKOUT=true, set CHECKOUT_MODE=express, and run one iteration. Only Checkout Express should execute. Then set an unmatched name such as unknown; the child named default should run.

14. Throughput Controller total-execution proof

With 1 thread × 5 outer iterations, Throughput Controller Total Executions=2 and Per User off, Upsell executes exactly twice across the plan. This controls branch occurrence count. It does not specify how many requests/second those two executions occur at.

15. Throughput Controller percentage proof

Change to Percent Executions=30, 1 thread × 10 outer iterations. Predict roughly three Upsell branch executions across the ten controller opportunities, then measure the actual label/target count. The percentage semantics are about execution opportunities, not a timing schedule.

For a tiny iteration count, do not infer production traffic distribution from one run; branch-mix statistics need enough opportunities to become representative.

16. Challenge

Requirement: 20% of completed journeys should attempt optional warranty lookup, while the total overall scenario start rate remains 2 journeys/s. Which components own those requirements?

Use a Throughput Controller (or explicit branch data) for the ~20% branch population and the Chapter 7 workload/timer model for 2 journey starts/s. Do not try to satisfy both requirements with Throughput Controller alone.

Knowledge check

Why are there 17 CSV JTL rows but only 15 target requests in additional transaction mode?

What changes when Generate Parent Sample is enabled?

Why does WaitForReady terminate after two polls?

What does Throughput Controller Total Executions=2 mean?

What happens when CHECKOUT_MODE names no child but a child named default exists?

Next lesson

Choose controller structure from business semantics

Lesson 3 compares outer versus nested loops, flat versus nested trees, condition styles, transaction parent reporting, Throughput execution modes, and branch data versus embedded logic.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was checked against current Apache JMeter primary documentation on 2026-09-05. The course baseline remains Apache JMeter 5.6.3 with a Java 17 JDK for labs and no third-party plugins; JMeter 5.6.3 requires Java 8+. Simple Controller is organizational only. Loop Controller multiplies its loop count by the enclosing Thread Group iterations and exposes an index variable named __jm__<controller-name>__idx. If Controller should normally use Interpret Condition as Variable Expression with a boolean variable or __jexl3/__groovy; JavaScript condition mode has a potentially large performance penalty. While Controller evaluates its condition before and after its children, so non-idempotent functions such as counters in the condition can produce surprising behavior. Switch Controller selects one child by numeric index or name and has explicit fallback semantics. Throughput Controller is intentionally documented as badly named: it controls branch execution count/percentage, not request throughput; use a throughput Timer for rate control. Transaction Controller creates an additional transaction SampleResult unless Generate Parent Sample is enabled. In parent mode, child samples do not appear as separate CSV JTL rows. By default transaction elapsed excludes timers and pre/post-processor processing; the optional include-duration setting changes that measurement.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.