New posts

This commit is contained in:
2026-11-06 21:43:41 +05:30
parent 11782a7ec3
commit c92977b23d
38 changed files with 2006 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Turns the raw load-test artefacts into the tables and chart quoted in the post.
analyze.py gatling <report-dir> -> output/10-gatling-steps-table.txt, output/knee-gatling.json
analyze.py k6 <k6-json> -> output/21-k6-steps-table.txt, output/knee-k6.json
analyze.py chart -> output/30-knee-chart.svg
Gatling's open-source report has no stats.json, so the per-step rows are read from the
statistics table in index.html (columns: total, ok, ko, ko%, req/s, min, p50, p75, p95, p99, max, mean, stddev).
Standard library only.
"""
import html, json, math, re, sys
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "output"
KEYS = ["step", "requests", "ok", "ko", "ko_pct", "p50", "p95", "p99", "max"]
def gatling(report_dir):
page = (Path(report_dir) / "index.html").read_text(encoding="utf-8")
rows = []
for tr in re.findall(r'<tr id="req_[^"]+" data-parent="ROOT">(.*?)</tr>', page, re.S):
name = html.unescape(re.search(r'class="ellipsed-name">([^<]+)<', tr).group(1)).replace("quote ", "")
c = {int(n): v for n, v in re.findall(r'class="value [a-z ]*col-(\d+)">([^<]*)<', tr)}
rows.append(dict(step=name, requests=int(c[2]), ok=int(c[3]), ko=int(c[4]), ko_pct=float(c[5]),
p50=int(c[8]), p95=int(c[10]), p99=int(c[11]), max=int(c[12])))
return rows
def pct(values, p):
"""k6's own method: linear interpolation between closest ranks."""
if not values:
return 0
s = sorted(values)
idx = p / 100 * (len(s) - 1)
lo, hi = math.floor(idx), math.ceil(idx)
return s[lo] + (s[hi] - s[lo]) * (idx - lo)
def k6(json_file):
dur, failed = {}, {}
with open(json_file, encoding="utf-8") as f:
for line in f:
if '"http_req_duration"' not in line and '"http_req_failed"' not in line:
continue
o = json.loads(line)
if o.get("type") != "Point":
continue
tags = o["data"]["tags"]
if "step" not in tags:
continue
if o["metric"] == "http_req_duration":
dur.setdefault(tags["step"], []).append(o["data"]["value"])
elif o["metric"] == "http_req_failed":
failed.setdefault(tags["step"], []).append(o["data"]["value"])
rows = []
for step in sorted(dur, key=lambda s: (s == "ramp", s)):
n = len(dur[step]); ko = int(sum(failed.get(step, [])))
rows.append(dict(step=step, requests=n, ok=n - ko, ko=ko, ko_pct=round(100 * ko / n, 1),
p50=round(pct(dur[step], 50)), p95=round(pct(dur[step], 95)),
p99=round(pct(dur[step], 99)), max=round(max(dur[step]))))
return rows
def table(rows):
# Gatling lists the ramp row where it was first seen; show steps in rate order, ramp last.
rows = sorted(rows, key=lambda r: (r["step"] == "ramp", r["step"]))
lines = [f'{"step":<13}{"requests":>9}{"ok":>7}{"ko":>6}{"ko %":>7}{"p50":>6}{"p95":>6}{"p99":>6}{"max":>6} (times in ms)']
for r in rows:
lines.append(f'{r["step"]:<13}{r["requests"]:>9}{r["ok"]:>7}{r["ko"]:>6}{r["ko_pct"]:>7.1f}'
f'{r["p50"]:>6}{r["p95"]:>6}{r["p99"]:>6}{r["max"]:>6}')
return "\n".join(lines) + "\n"
def chart():
g = {r["step"]: r for r in json.loads((OUT / "knee-gatling.json").read_text())}
k = {}
if (OUT / "knee-k6.json").exists():
k = {r["step"]: r for r in json.loads((OUT / "knee-k6.json").read_text())}
steps = [f"rate {r:03d}/s" for r in range(30, 241, 30)]
X0, X1, Y0, Y1 = 70, 700, 210, 40 # plot box (y grows downwards)
xs = {s: X0 + (X1 - X0) * i / (len(steps) - 1) for i, s in enumerate(steps)}
ymax = 900
y = lambda ms: Y0 - (Y0 - Y1) * min(ms, ymax) / ymax
def line(series, key, cls):
pts = " ".join(f"{xs[s]:.0f},{y(series[s][key]):.0f}" for s in steps if s in series)
dots = "".join(f'<circle class="{cls}d" cx="{xs[s]:.0f}" cy="{y(series[s][key]):.0f}" r="3"/>' for s in steps if s in series)
return f'<polyline class="{cls}" points="{pts}"/>{dots}'
o = ['<svg viewBox="0 0 740 490" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two panels over eight injected request rates from 30 to 240 per second. Top: Gatling p50 and p95 response time, flat near 50 to 60 milliseconds at low rates and climbing steeply from 150 per second, with k6 p95 shown for comparison. Bottom: percentage of requests rejected with HTTP 503, zero up to 150 per second and rising from 180 per second.">',
'<style>.t{font:600 14px sans-serif;fill:#222}.c{font:12px sans-serif;fill:#444}.m{font:11px monospace;fill:#555}.ax{stroke:#b7bec9;stroke-width:1}.gr{stroke:#e2e5ea;stroke-width:1}'
'.p95{fill:none;stroke:#5b7fc7;stroke-width:2.5}.p95d{fill:#5b7fc7}.p50{fill:none;stroke:#4a9d63;stroke-width:2.5}.p50d{fill:#4a9d63}'
'.k95{fill:none;stroke:#c9973f;stroke-width:2;stroke-dasharray:5 3}.k95d{fill:#c9973f}.kneeline{stroke:#c56a54;stroke-width:1.5;stroke-dasharray:3 3}'
'.gb{fill:#5b7fc7}.kb{fill:#c9973f}</style>',
'<text class="t" x="70" y="22">Response time (ms) vs injected rate (users/s), Gatling 3.16.0 and k6 v2.3.0</text>']
for ms in range(0, ymax + 1, 300):
o.append(f'<line class="gr" x1="{X0}" y1="{y(ms):.0f}" x2="{X1}" y2="{y(ms):.0f}"/><text class="m" x="{X0-8}" y="{y(ms)+4:.0f}" text-anchor="end">{ms}</text>')
o.append(f'<line class="ax" x1="{X0}" y1="{Y0}" x2="{X1}" y2="{Y0}"/><line class="ax" x1="{X0}" y1="{Y1}" x2="{X0}" y2="{Y0}"/>')
o.append(line(g, "p50", "p50")); o.append(line(g, "p95", "p95"))
if k: o.append(line(k, "p95", "k95"))
for s in steps:
o.append(f'<text class="m" x="{xs[s]:.0f}" y="{Y0+16}" text-anchor="middle">{int(s[5:8])}</text>')
# the capacity worked out from the app's settings: 8 workers / 0.050 s = 160 per second
kx = xs["rate 150/s"] + (xs["rate 180/s"] - xs["rate 150/s"]) / 3
o.append(f'<line class="kneeline" x1="{kx:.0f}" y1="{Y1}" x2="{kx:.0f}" y2="{Y0}"/><text class="m" x="{kx-6:.0f}" y="{Y1+110}" text-anchor="end">calculated capacity: 8 workers / 0.050 s = 160 per second</text>')
# legend
o.append('<line class="p50" x1="90" y1="58" x2="120" y2="58"/><text class="c" x="126" y="62">Gatling p50</text>'
'<line class="p95" x1="90" y1="76" x2="120" y2="76"/><text class="c" x="126" y="80">Gatling p95</text>')
if k: o.append('<line class="k95" x1="90" y1="94" x2="120" y2="94"/><text class="c" x="126" y="98">k6 p95</text>')
# bottom panel: KO %
B0, B1 = 440, 300
kmax = max([r["ko_pct"] for r in g.values()] + [r["ko_pct"] for r in k.values()] + [1])
top = math.ceil(kmax / 10) * 10
by = lambda v: B0 - (B0 - B1) * v / top
o.append(f'<text class="t" x="70" y="276">Requests rejected with HTTP 503 (%)</text>')
for v in range(0, top + 1, 10):
o.append(f'<line class="gr" x1="{X0}" y1="{by(v):.0f}" x2="{X1}" y2="{by(v):.0f}"/><text class="m" x="{X0-8}" y="{by(v)+4:.0f}" text-anchor="end">{v}</text>')
for s in steps:
for off, series, cls in ((-9, g, "gb"), (3, k, "kb")):
if s in series:
h = B0 - by(series[s]["ko_pct"])
o.append(f'<rect class="{cls}" x="{xs[s]+off:.0f}" y="{by(series[s]["ko_pct"]):.1f}" width="8" height="{h:.1f}"/>')
o.append(f'<text class="m" x="{xs[s]:.0f}" y="{B0+16}" text-anchor="middle">{int(s[5:8])}</text>')
o.append(f'<line class="ax" x1="{X0}" y1="{B0}" x2="{X1}" y2="{B0}"/>')
o.append(f'<text class="c" x="{(X0+X1)//2}" y="{B0+34}" text-anchor="middle">injected rate, users per second (blue = Gatling, amber = k6)</text>')
o.append('</svg>')
(OUT / "30-knee-chart.svg").write_text("\n".join(o) + "\n", encoding="utf-8")
if __name__ == "__main__":
OUT.mkdir(exist_ok=True)
cmd = sys.argv[1]
if cmd == "gatling":
rows = gatling(sys.argv[2])
(OUT / "knee-gatling.json").write_text(json.dumps(rows, indent=1) + "\n")
(OUT / "10-gatling-steps-table.txt").write_text(table(rows))
elif cmd == "k6":
rows = k6(sys.argv[2])
(OUT / "knee-k6.json").write_text(json.dumps(rows, indent=1) + "\n")
(OUT / "21-k6-steps-table.txt").write_text(table(rows))
elif cmd == "chart":
chart()
else:
sys.exit(__doc__)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Regenerates every file in gatling-spring-boot/output/.
# Needs JDK 25 and Maven on the PATH and python3 (standard library only).
# k6 is optional: set K6=/path/to/k6 or have it on the PATH; without it the k6 steps are skipped.
# Takes about 6 minutes: no single load run is longer than ~70 seconds.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${PORT:-18300}"
export APP_URL="http://localhost:${PORT}"
K6="${K6:-$(command -v k6 || true)}"
OUT=output
JAR=target/gatling-spring-boot-lab-1.0.0.jar
# keep transcripts free of JVM launcher noise that varies per machine
filter() { grep -vE '^(Picked up JAVA_TOOL_OPTIONS|WARNING: )' || true; }
rm -f "$OUT"/*.txt "$OUT"/*.json "$OUT"/*.svg
mkdir -p "$OUT"
# 1. unit test: the capacity limit the whole post relies on (writes output/01-saturation-test.txt)
mvn -B -q clean test 2>&1 | filter
# 2. build and start the app under test
mvn -B -q package -DskipTests 2>&1 | filter
mkdir -p target/k6
PORT="$PORT" java -jar "$JAR" > target/app.log 2>&1 &
APP_PID=$!
trap 'kill $APP_PID 2>/dev/null || true' EXIT
for _ in $(seq 1 60); do
curl -fs "$APP_URL/api/stats" > /dev/null 2>&1 && break
sleep 1
done
curl -fs "$APP_URL/api/quote/ORCL" > "$OUT/02-single-request.txt"; echo >> "$OUT/02-single-request.txt"
# 3. Gatling runs, one simulation per mvn invocation
gatling() { # <output file> <simulation class>
mvn -B test-compile gatling:test -Dgatling.simulationClass="lab.gatling.$2" 2>&1 | filter > "$OUT/$1"
}
gatling 03-basic-console.txt BasicSimulation
gatling 04-knee-steps-console.txt KneeStepsSimulation
python3 scripts/analyze.py gatling "$(ls -dt target/gatling/kneestepssimulation-* | head -1)"
gatling 06-knee-ramp-console.txt KneeRampSimulation
gatling 07-closed-model-console.txt ClosedModelSimulation
# 4. the run that must fail the build; keep going, record Maven's exit code
set +e
gatling 05-failing-assertion-console.txt FailingAssertionSimulation
echo "maven exit code: ${PIPESTATUS[0]}" > "$OUT/05b-failing-assertion-exit.txt"
set -e
# 5. k6, same app, same profile
if [ -n "$K6" ]; then
"$K6" version > "$OUT/20-k6-version.txt"
"$K6" run --quiet --out json=target/k6/knee.json k6/knee.js > "$OUT/22-k6-knee-console.txt" 2>&1
python3 scripts/analyze.py k6 target/k6/knee.json
set +e
"$K6" run --quiet k6/threshold-fail.js > "$OUT/23-k6-threshold-fail-console.txt" 2>&1
echo "k6 exit code: $?" > "$OUT/23b-k6-threshold-fail-exit.txt"
set -e
else
echo "k6 not found: skipping k6 steps" >&2
fi
python3 scripts/analyze.py chart
echo "done: $(ls "$OUT" | wc -l) files in $OUT/"