master-zapier-plan-draft/packages/zapier-decisions/scripts/build-nats-bench-report.py
George Lambert de20a42109
Some checks are pending
offline / test (push) Waiting to run
Add a full NATS cluster speed report (charts, HTML, PDF) to zapier-decisions.
Progress-repo write-up of run 20260912T045131Z with throughput and delay
charts, method, and product-capacity notes; catalog extras point at it.
2026-09-12 01:04:02 -04:00

247 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Charts + HTML + PDF for the NATS cluster speed report in zapier-decisions/reports."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
ROOT = Path(__file__).resolve().parents[3]
PKG = Path(__file__).resolve().parents[1]
REPORT_DIR = PKG / "reports"
CHARTS = REPORT_DIR / "charts"
MD = REPORT_DIR / "nats-cluster-bench.md"
HTML = REPORT_DIR / "nats-cluster-bench.html"
PDF = REPORT_DIR / "nats-cluster-bench.pdf"
CSS = ROOT / "scripts" / "docs-print.css"
LUA = ROOT / "scripts" / "pdf-links.lua"
INDIGO = "#4f46e5"
DEEP = "#312e81"
TEAL = "#047857"
AMBER = "#b45309"
LILAC = "#7c74f0"
INK = "#171a26"
MUTED = "#5b6178"
GRID = "#d9dce8"
# Run 20260912T045131Z — parsed from nats bench logs (Sub stats, not Pub/Sub aggregate).
CORE = {
"labels": ["1p1s\n50k×128 B", "4p4s\n100k×128 B", "8p8s\n200k×128 B", "4p4s\n50k×1 KiB"],
"agg": [1_200_836, 1_521_256, 2_007_937, 630_460],
"pub": [791_094, 316_312, 333_957, 247_747],
"sub": [747_461, 1_299_634, 1_790_736, 510_216],
"pub_mb": [96.57, 38.61, 40.77, 241.94],
"sub_mb": [91.24, 158.65, 218.60, 498.26],
}
JS = {
"labels": ["1p 20k×128 B", "4p 50k×128 B", "4p 20k×1 KiB", "2p2s pull 20k×128 B"],
"pub": [16_155, 16_607, 13_493, 10_965],
"sub": [None, None, None, 10_942],
}
DELAY = {
"labels": ["Ping\n1k×128 B", "Flood 1p\n5k×128 B", "Flood 4p\n5k×1 KiB", "Flood 4p\n10k×128 B", "Flood 8p\n20k×128 B"],
"kind": ["ping", "flood", "flood", "flood", "flood"],
"p50": [0.286, 248.752, 217.579, 266.672, 466.296],
"p90": [0.332, 274.314, 223.268, 299.073, 499.924],
"p99": [0.734, 279.398, 227.798, 304.233, 505.112],
"avg": [0.307, 238.626, 211.706, 263.186, 453.749],
}
def style() -> None:
plt.rcParams.update(
{
"font.family": "sans-serif",
"font.size": 10,
"axes.titlesize": 12,
"axes.titleweight": "semibold",
"axes.labelsize": 10,
"axes.edgecolor": GRID,
"axes.labelcolor": INK,
"text.color": INK,
"xtick.color": MUTED,
"ytick.color": MUTED,
"figure.facecolor": "white",
"axes.facecolor": "white",
"axes.grid": True,
"grid.color": GRID,
"grid.linewidth": 0.8,
"legend.frameon": False,
"savefig.bbox": "tight",
"savefig.dpi": 160,
"savefig.facecolor": "white",
}
)
def k_fmt(x: float, _pos: int | None = None) -> str:
if x >= 1_000_000:
return f" {x / 1_000_000:.2f}M".strip()
if x >= 1000:
return f"{x / 1000:.0f}k"
return f"{x:.0f}"
def save(fig: plt.Figure, name: str) -> None:
CHARTS.mkdir(parents=True, exist_ok=True)
path = CHARTS / name
fig.savefig(path, dpi=160)
plt.close(fig)
print(f"wrote {path}")
def chart_core_msgs() -> None:
fig, ax = plt.subplots(figsize=(9.2, 4.4))
x = range(len(CORE["labels"]))
w = 0.25
ax.bar([i - w for i in x], CORE["agg"], w, label="Aggregate", color=DEEP)
ax.bar(list(x), CORE["pub"], w, label="Publish", color=INDIGO)
ax.bar([i + w for i in x], CORE["sub"], w, label="Subscribe", color=TEAL)
ax.set_xticks(list(x), CORE["labels"])
ax.set_ylabel("messages / second")
ax.set_title("Core NATS throughput (fire-and-forget pub/sub)")
ax.yaxis.set_major_formatter(FuncFormatter(k_fmt))
ax.legend(loc="upper left")
ax.set_axisbelow(True)
save(fig, "core-throughput.png")
def chart_js_msgs() -> None:
fig, ax = plt.subplots(figsize=(9.2, 4.4))
colors = [INDIGO, INDIGO, AMBER, LILAC]
ax.bar(JS["labels"], JS["pub"], color=colors)
ax.set_ylabel("durable publish messages / second")
ax.set_title("JetStream file store, replicas=3 (product-stream settings)")
ax.yaxis.set_major_formatter(FuncFormatter(k_fmt))
ax.set_axisbelow(True)
for i, v in enumerate(JS["pub"]):
ax.text(i, v + 250, f"{v:,}", ha="center", va="bottom", fontsize=9, color=MUTED)
save(fig, "js-throughput.png")
def chart_core_vs_js() -> None:
fig, ax = plt.subplots(figsize=(9.2, 4.4))
labels = ["1 publisher\n128 B", "4 publishers\n128 B", "4 publishers\n1 KiB"]
core_pub = [791_094, 316_312, 247_747]
js_pub = [16_155, 16_607, 13_493]
x = range(len(labels))
w = 0.35
ax.bar([i - w / 2 for i in x], core_pub, w, label="Core NATS (no disk)", color=INDIGO)
ax.bar([i + w / 2 for i in x], js_pub, w, label="JetStream r=3 file", color=AMBER)
ax.set_xticks(list(x), labels)
ax.set_yscale("log")
ax.set_ylabel("publish messages / second (log)")
ax.set_title("Core vs JetStream: two different jobs")
ax.legend(loc="upper right")
ax.set_axisbelow(True)
save(fig, "core-vs-js.png")
def chart_bytes() -> None:
fig, axes = plt.subplots(1, 2, figsize=(9.2, 4.2))
labels = ["128 B\n4p4s", "1 KiB\n4p4s"]
msgs = [1_521_256, 630_460]
mb = [185.70, 615.68]
axes[0].bar(labels, msgs, color=[INDIGO, AMBER])
axes[0].set_title("Aggregate messages / second")
axes[0].yaxis.set_major_formatter(FuncFormatter(k_fmt))
axes[0].set_axisbelow(True)
axes[1].bar(labels, mb, color=[INDIGO, AMBER])
axes[1].set_title("Aggregate MB / second")
axes[1].set_ylabel("MB/s")
axes[1].set_axisbelow(True)
for ax, vals, fmt in (
(axes[0], msgs, lambda v: f"{v/1e6:.2f}M"),
(axes[1], mb, lambda v: f"{v:.0f}"),
):
for i, v in enumerate(vals):
ax.text(i, v * 1.02, fmt(v), ha="center", va="bottom", fontsize=9, color=MUTED)
fig.suptitle("Core NATS: bigger payloads move more bytes, fewer messages", fontsize=12, fontweight="semibold")
fig.tight_layout()
save(fig, "payload-size.png")
def chart_delay() -> None:
fig, ax = plt.subplots(figsize=(9.2, 4.6))
x = range(len(DELAY["labels"]))
w = 0.25
ax.bar([i - w for i in x], DELAY["p50"], w, label="p50", color=TEAL)
ax.bar(list(x), DELAY["p90"], w, label="p90", color=INDIGO)
ax.bar([i + w for i in x], DELAY["p99"], w, label="p99", color=AMBER)
ax.set_xticks(list(x), DELAY["labels"])
ax.set_yscale("log")
ax.set_ylabel("milliseconds (log)")
ax.set_title("Round-trip delay: sequential ping vs burst flood")
ax.axhline(1.0, color=GRID, linestyle="--", linewidth=1)
ax.legend(loc="upper left")
ax.set_axisbelow(True)
save(fig, "delay-percentiles.png")
def render() -> None:
if not MD.exists():
raise SystemExit(f"missing {MD}")
header = HTML.with_suffix(".hdr.html")
banner = HTML.with_suffix(".ban.html")
css_text = CSS.read_text(encoding="utf-8")
header.write_text(f"<style>{css_text}</style>\n", encoding="utf-8")
banner.write_text(
'<div class="doc-banner">'
'<nav class="site"><a href="/">zapier.georgelambert.org</a>'
' · <a href="/index-md.html">Markdown indexes</a></nav>'
'<div class="kicker">Verae Time × Zapier · progress report</div>'
"<h1>NATS cluster message speed</h1>"
'<div class="source-path">packages/zapier-decisions/reports/nats-cluster-bench.md</div>'
"</div>\n",
encoding="utf-8",
)
r = subprocess.run(
[
"pandoc",
str(MD),
"-o",
str(HTML),
"--standalone",
f"--resource-path={REPORT_DIR}",
"--syntax-highlighting=breezedark",
"--metadata=title=NATS cluster message speed",
f"--include-in-header={header}",
f"--include-before-body={banner}",
f"--lua-filter={LUA}",
],
capture_output=True,
text=True,
)
header.unlink(missing_ok=True)
banner.unlink(missing_ok=True)
if r.returncode != 0:
sys.stderr.write(r.stderr)
raise SystemExit(f"pandoc failed: {r.returncode}")
w = subprocess.run(["weasyprint", str(HTML), str(PDF)], capture_output=True, text=True)
if w.returncode != 0:
sys.stderr.write(w.stderr)
raise SystemExit(f"weasyprint failed: {w.returncode}")
print(f"wrote {HTML}")
print(f"wrote {PDF}")
def main() -> int:
style()
chart_core_msgs()
chart_js_msgs()
chart_core_vs_js()
chart_bytes()
chart_delay()
render()
return 0
if __name__ == "__main__":
raise SystemExit(main())