zapier-decisions/reports/nats-cluster-bench.html

1278 lines
38 KiB
HTML
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.

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title>NATS cluster message speed</title>
<style>
/* Default styles provided by pandoc.
** See https://pandoc.org/MANUAL.html#variables-for-html for config info.
*/
html {
color: #1a1a1a;
background-color: #fdfdfd;
}
body {
margin: 0 auto;
max-width: 36em;
padding-left: 50px;
padding-right: 50px;
padding-top: 50px;
padding-bottom: 50px;
hyphens: auto;
overflow-wrap: break-word;
text-rendering: optimizeLegibility;
font-kerning: normal;
}
@media (max-width: 600px) {
body {
font-size: 0.9em;
padding: 12px;
}
h1 {
font-size: 1.8em;
}
}
@media print {
html {
background-color: white;
}
body {
background-color: transparent;
color: black;
font-size: 12pt;
}
p, h2, h3 {
orphans: 3;
widows: 3;
}
h2, h3, h4 {
page-break-after: avoid;
}
}
p {
margin: 1em 0;
}
a {
color: #1a1a1a;
}
a:visited {
color: #1a1a1a;
}
img {
max-width: 100%;
}
svg {
height: auto;
max-width: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.4em;
}
h5, h6 {
font-size: 1em;
font-style: italic;
}
h6 {
font-weight: normal;
}
ol, ul {
padding-left: 1.7em;
margin-top: 1em;
}
li > ol, li > ul {
margin-top: 0;
}
blockquote {
margin: 1em 0 1em 1.7em;
padding-left: 1em;
border-left: 2px solid #e6e6e6;
color: #606060;
}
code {
font-family: Menlo, Monaco, Consolas, 'Lucida Console', monospace;
font-size: 85%;
margin: 0;
hyphens: manual;
}
pre {
margin: 1em 0;
overflow: auto;
}
pre code {
padding: 0;
overflow: visible;
overflow-wrap: normal;
}
.sourceCode {
background-color: transparent;
overflow: visible;
}
hr {
border: none;
border-top: 1px solid #1a1a1a;
height: 1px;
margin: 1em 0;
}
table {
margin: 1em 0;
border-collapse: collapse;
width: 100%;
overflow-x: auto;
display: block;
font-variant-numeric: lining-nums tabular-nums;
}
table caption {
margin-bottom: 0.75em;
}
tbody {
margin-top: 0.5em;
border-top: 1px solid #1a1a1a;
border-bottom: 1px solid #1a1a1a;
}
th {
border-top: 1px solid #1a1a1a;
padding: 0.25em 0.5em 0.25em 0.5em;
}
td {
padding: 0.125em 0.5em 0.25em 0.5em;
}
header {
margin-bottom: 4em;
text-align: center;
}
#TOC li {
list-style: none;
}
#TOC ul {
padding-left: 1.3em;
}
#TOC > ul {
padding-left: 0;
}
#TOC a:not(:hover) {
text-decoration: none;
}
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
div.columns{display: flex; gap: min(4vw, 1.5em);}
div.column{flex: auto; overflow-x: auto;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
/* The extra [class] is a hack that increases specificity enough to
override a similar rule in reveal.js */
ul.task-list[class]{list-style: none;}
ul.task-list li input[type="checkbox"] {
font-size: inherit;
width: 0.8em;
margin: 0 0.8em 0.2em -1.6em;
vertical-align: middle;
}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
/* CSS for syntax highlighting */
html { -webkit-text-size-adjust: 100%; }
pre > code.sourceCode { white-space: pre; position: relative; }
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
pre > code.sourceCode > span:empty { height: 1.2em; }
.sourceCode { overflow: visible; }
code.sourceCode > span { color: inherit; text-decoration: inherit; }
div.sourceCode { margin: 1em 0; }
pre.sourceCode { margin: 0; }
@media screen {
div.sourceCode { overflow: auto; }
}
@media print {
pre > code.sourceCode { white-space: pre-wrap; }
pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
}
pre.numberSource code
{ counter-reset: source-line 0; }
pre.numberSource code > span
{ position: relative; left: -4em; counter-increment: source-line; }
pre.numberSource code > span > a:first-child::before
{ content: counter(source-line);
position: relative; left: -1em; text-align: right; vertical-align: baseline;
border: none; display: inline-block;
-webkit-touch-callout: none; -webkit-user-select: none;
-khtml-user-select: none; -moz-user-select: none;
-ms-user-select: none; user-select: none;
padding: 0 4px; width: 4em;
background-color: #232629;
color: #7a7c7d;
}
pre.numberSource { margin-left: 3em; border-left: 1px solid #7a7c7d; padding-left: 4px; }
div.sourceCode
{ color: #cfcfc2; background-color: #232629; }
@media screen {
pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; }
}
code span { color: #cfcfc2; } /* Normal */
code span.al { color: #95da4c; background-color: #4d1f24; font-weight: bold; } /* Alert */
code span.an { color: #3f8058; } /* Annotation */
code span.at { color: #2980b9; } /* Attribute */
code span.bn { color: #f67400; } /* BaseN */
code span.bu { color: #7f8c8d; } /* BuiltIn */
code span.cf { color: #fdbc4b; font-weight: bold; } /* ControlFlow */
code span.ch { color: #3daee9; } /* Char */
code span.cn { color: #27aeae; font-weight: bold; } /* Constant */
code span.co { color: #7a7c7d; } /* Comment */
code span.cv { color: #7f8c8d; } /* CommentVar */
code span.do { color: #a43340; } /* Documentation */
code span.dt { color: #2980b9; } /* DataType */
code span.dv { color: #f67400; } /* DecVal */
code span.er { color: #da4453; text-decoration: underline; } /* Error */
code span.ex { color: #0099ff; font-weight: bold; } /* Extension */
code span.fl { color: #f67400; } /* Float */
code span.fu { color: #8e44ad; } /* Function */
code span.im { color: #27ae60; } /* Import */
code span.in { color: #c45b00; } /* Information */
code span.kw { color: #cfcfc2; font-weight: bold; } /* Keyword */
code span.op { color: #cfcfc2; } /* Operator */
code span.ot { color: #27ae60; } /* Other */
code span.pp { color: #27ae60; } /* Preprocessor */
code span.re { color: #2980b9; background-color: #153042; } /* RegionMarker */
code span.sc { color: #3daee9; } /* SpecialChar */
code span.ss { color: #da4453; } /* SpecialString */
code span.st { color: #f44f4f; } /* String */
code span.va { color: #27aeae; } /* Variable */
code span.vs { color: #da4453; } /* VerbatimString */
code span.wa { color: #da4453; } /* Warning */
</style>
<style>/* Colored print + screen stylesheet for zapier.georgelambert.org */
:root {
--ink: #171a26;
--muted: #5b6178;
--line: #d9dce8;
--bg: #f4f5fb;
--paper: #ffffff;
--accent: #4f46e5;
--accent-deep: #312e81;
--accent-soft: #eef0fe;
--ok: #047857;
--warn: #8a5a00;
--code-bg: #1b1f33;
--code-fg: #e8ecff;
}
html { background: var(--bg); }
body {
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
max-width: 48rem;
font: 15px/1.55 -apple-system, "Segoe UI", Georgia, serif;
color: var(--ink);
background: var(--paper);
}
.doc-banner {
background: linear-gradient(160deg, #312e81 0%, #4f46e5 60%, #7c74f0 100%);
color: #eef0fe;
margin: -1.5rem -1.25rem 1.5rem;
padding: 1.1rem 1.25rem 1rem;
}
.doc-banner a { color: #fff; }
.doc-banner .kicker {
letter-spacing: 0.12em;
text-transform: uppercase;
font: 700 10px system-ui, sans-serif;
opacity: 0.8;
}
.doc-banner h1 { margin: 0.25rem 0 0; font-size: 1.45rem; color: #fff; }
h1, h2, h3, h4 { color: var(--accent-deep); page-break-after: avoid; }
h1 { font-size: 1.7rem; }
h2 {
font-size: 1.2rem;
border-bottom: 2px solid var(--accent);
padding-bottom: 0.2rem;
margin-top: 1.6rem;
}
h3 { font-size: 1.05rem; color: var(--accent); }
a { color: var(--accent); }
p, li { orphans: 3; widows: 3; }
code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.86em;
background: var(--accent-soft);
color: var(--accent-deep);
padding: 0.08em 0.28em;
border-radius: 4px;
}
pre, div.sourceCode, div.sourceCode pre {
background: var(--code-bg) !important;
color: var(--code-fg) !important;
padding: 0.85rem 1rem;
border-radius: 10px;
overflow: auto;
font-size: 0.78rem;
line-height: 1.4;
page-break-inside: avoid;
}
pre code { background: transparent; color: inherit; padding: 0; }
#title-block-header, header#title-block-header, h1.title { display: none; }
.doc-banner + h1 { display: none; }
table {
border-collapse: collapse;
width: 100%;
margin: 0.8rem 0 1.2rem;
font-size: 0.9rem;
page-break-inside: avoid;
}
th, td { border: 1px solid var(--line); padding: 0.38rem 0.55rem; text-align: left; vertical-align: top; }
th {
background: var(--accent);
color: #fff;
font: 650 12px system-ui, sans-serif;
}
tr:nth-child(even) td { background: var(--accent-soft); }
blockquote {
margin: 1rem 0;
padding: 0.4rem 0.9rem;
border-left: 4px solid var(--accent);
background: var(--accent-soft);
color: var(--accent-deep);
}
img { max-width: 100%; height: auto; border-radius: 8px; page-break-inside: avoid; }
hr { border: 0; border-top: 1px solid var(--line); }
ul, ol { padding-left: 1.25rem; }
nav.site { font: 13px system-ui, sans-serif; margin-bottom: 0.4rem; }
.source-path { font: 11px ui-monospace, Menlo, monospace; color: var(--muted); }
@page {
size: letter;
margin: 0.65in 0.7in 0.8in 0.7in;
@top-left {
content: "Verae Time × Zapier";
font: 700 8pt system-ui, sans-serif;
color: #4f46e5;
}
@top-right {
content: "zapier.georgelambert.org";
font: 8pt system-ui, sans-serif;
color: #6b7186;
}
@bottom-center {
content: counter(page) " / " counter(pages);
font: 8pt system-ui, sans-serif;
color: #6b7186;
}
}
@media print {
html, body { background: #fff; max-width: none; padding: 0; }
.doc-banner { margin: 0 0 1rem; border-radius: 8px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
a { text-decoration: none; }
th, tr:nth-child(even) td, pre, blockquote, code { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<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>
<header id="title-block-header">
<h1 class="title">NATS cluster message speed</h1>
</header>
<p><strong>Progress report</strong> · 2026-09-12 · run
<code>20260912T045131Z</code> (UTC)</p>
<p>This is the full write-up of the test-environment NATS cluster bench:
what was measured, how, the numbers, the charts, and what they mean for
Verae Time × Zapier. Short tables also live in <a
href="https://git.georgelambert.org/marchon/verae-nats-cluster/src/branch/main/BENCH.md"><code>verae-nats-cluster/BENCH.md</code></a>.
Raw logs and CSVs are in that repo under
<code>results/20260912T045131Z/</code>.</p>
<hr />
<h2 id="executive-summary">1. Executive summary</h2>
<p>The test cluster is three JetStream nodes on private
<code>vmbr1</code> (LXC 511513). The bench client is a
<strong>fourth</strong> guest (LXC 510), so the numbers are
cluster-plus-network, not a process talking to itself on loopback.</p>
<p>Two different systems were measured, on purpose:</p>
<table>
<colgroup>
<col style="width: 24%" />
<col style="width: 36%" />
<col style="width: 39%" />
</colgroup>
<thead>
<tr>
<th>System</th>
<th>What it is</th>
<th>What we got</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Core NATS</strong></td>
<td>Fire-and-forget pub/sub. No disk, no replica ack.</td>
<td>About <strong>0.752.0 million msgs/s</strong> at 128 B, depending
on fan-out. At 1 KiB, about <strong>630k msgs/s</strong> and
<strong>~616 MB/s</strong> aggregate.</td>
</tr>
<tr>
<td><strong>JetStream file, replicas=3</strong></td>
<td>Durable, replicated — <strong>this is what product streams
use</strong>.</td>
<td>About <strong>16k durable 128 B pubs/s</strong>, about
<strong>13.5k</strong> at 1 KiB. Pull consume keeps up with publish at
~11k msgs/s each side.</td>
</tr>
<tr>
<td><strong>Ping delay</strong></td>
<td>One message at a time, publish then wait.</td>
<td><strong>avg 0.307 ms</strong>, <strong>p99 0.734 ms</strong>, max
2.76 ms (1k × 128 B).</td>
</tr>
<tr>
<td><strong>Flood delay</strong></td>
<td>Publishers dump a batch; subscriber drains.</td>
<td><strong>150505 ms</strong>. That is <strong>queueing under
burst</strong>, not wire time.</td>
</tr>
</tbody>
</table>
<p><strong>For this product:</strong> timestamp jobs, job events,
webhooks, and archive puts go through JetStream r=3. Plan capacity
against <strong>~16k durable msgs/s</strong> on this stand, not the
million-msg core numbers. A quiet job-event hop is a fraction of a
millisecond. If a mailbox falls behind, delay jumps into hundreds of
milliseconds — that is the flood column.</p>
<p>Core NATS is still useful: it is the ceiling for non-durable fan-out
on this host, and it shows <code>vmbr1</code> and the nats-server
processes are not the JetStream bottleneck. JetStream is.</p>
<hr />
<h2 id="why-this-test-exists">2. Why this test exists</h2>
<p>The lab cut the test environment over to the three-node cluster.
Before treating that cluster as the message fabric for keep, fleet,
middleware, billing, and archive workers, we needed:</p>
<ol type="1">
<li><strong>Throughput at several loads</strong> — one publisher vs
many, 128 B vs 1 KiB, core vs durable.</li>
<li><strong>Delay characteristics</strong> — both the quiet path (one
message RTT) and the overloaded path (burst into a mailbox).</li>
<li><strong>A client that is not a nats-* server</strong> — otherwise we
would be measuring loopback on the broker.</li>
</ol>
<p>This is a <strong>lab stand on one Proxmox host</strong>, not three
metal boxes. It answers “is this cluster in the right order of magnitude
for our traffic?” It does not replace a soak test on dedicated
disks.</p>
<hr />
<h2 id="topology">3. Topology</h2>
<pre class="text"><code> vmbr1 10.10.10.0/24 (not on vmbr0, not public)
-----------------------------------------------
LXC 510 LXC 511 LXC 512 LXC 513
verae-px-worker nats-a nats-b nats-c
10.10.10.20 10.10.10.21 10.10.10.22 10.10.10.23
bench client :4222 client :4222 :4222
:6222 routes :6222 :6222
:8222 loopback :8222 :8222</code></pre>
<ul>
<li>Cluster name: <code>verae</code>. Each server has two routes to the
other two.</li>
<li>Client URL:
<code>nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222</code></li>
<li>HTTP monitor is <strong>loopback :8222</strong> inside each guest.
Zapier cloud never talks to NATS.</li>
<li>Product streams already on this cluster (<code>ZAPIER_JOBS</code>,
<code>ZAPIER_EVENTS</code>, <code>ZAPIER_WEBHOOKS</code>,
<code>ZAPIER_USAGE</code>, <code>VERAE_ARCHIVE</code>) use
<strong>file</strong> storage and <strong>replicas=3</strong>. The
JetStream bench used the same settings on a throwaway stream
<code>benchstream</code>.</li>
<li>Host <code>127.0.0.1:4222</code> is still listening on NS1;
<strong>clients no longer use it</strong>.</li>
</ul>
<p>Credits for the stack: Scott Lindsey, George Lambert, NATS.IO,
Grok-Code.</p>
<hr />
<h2 id="method">4. Method</h2>
<h3 id="tools">4.1 Tools</h3>
<table>
<colgroup>
<col style="width: 53%" />
<col style="width: 46%" />
</colgroup>
<thead>
<tr>
<th>Piece</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>nats</code> CLI <strong>0.1.6</strong></td>
<td>Throughput (<code>nats bench --no-progress --csv</code>). Its
min/avg/max are <strong>publisher rate spread</strong>, not delay.</td>
</tr>
<tr>
<td><code>scripts/latency.mjs</code></td>
<td>Two connections, header timestamp <code>t</code>, delay = receive
time send time.</td>
</tr>
<tr>
<td><code>scripts/bench.sh</code></td>
<td>Runs the ladder from NS1 via <code>pct exec</code> on VMID 510.</td>
</tr>
<tr>
<td><code>scripts/bench-report.py</code></td>
<td>Turns logs into the short <code>BENCH.md</code> table.</td>
</tr>
</tbody>
</table>
<p>Re-run on NS1, from <code>verae-nats-cluster</code>:</p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="fu">bash</span> scripts/bench.sh</span></code></pre></div>
<h3 id="load-ladder">4.2 Load ladder</h3>
<p><strong>Core NATS</strong> (subject <code>bench.core.*</code>):</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Publishers</th>
<th>Subscribers</th>
<th>Messages</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>core-1p1s-50k-128</code></td>
<td>1</td>
<td>1</td>
<td>50,000</td>
<td>128 B</td>
</tr>
<tr>
<td><code>core-4p4s-100k-128</code></td>
<td>4</td>
<td>4</td>
<td>100,000</td>
<td>128 B</td>
</tr>
<tr>
<td><code>core-8p8s-200k-128</code></td>
<td>8</td>
<td>8</td>
<td>200,000</td>
<td>128 B</td>
</tr>
<tr>
<td><code>core-4p4s-50k-1k</code></td>
<td>4</td>
<td>4</td>
<td>50,000</td>
<td>1024 B</td>
</tr>
</tbody>
</table>
<p><strong>JetStream</strong>
(<code>--js --storage file --replicas 3 --stream benchstream</code>).
The stream is deleted between loads so the name never collides:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Shape</th>
<th>Messages</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>js-1p-20k-128-r3</code></td>
<td>1 publisher</td>
<td>20,000</td>
<td>128 B</td>
</tr>
<tr>
<td><code>js-4p-50k-128-r3</code></td>
<td>4 publishers</td>
<td>50,000</td>
<td>128 B</td>
</tr>
<tr>
<td><code>js-4p-20k-1k-r3</code></td>
<td>4 publishers</td>
<td>20,000</td>
<td>1024 B</td>
</tr>
<tr>
<td><code>js-2p2s-20k-128-r3</code></td>
<td>2 pub + 2 pull sub</td>
<td>20,000</td>
<td>128 B</td>
</tr>
</tbody>
</table>
<p><strong>Delay</strong> (core subjects, two connections):</p>
<table>
<colgroup>
<col style="width: 15%" />
<col style="width: 18%" />
<col style="width: 21%" />
<col style="width: 18%" />
<col style="width: 27%" />
</colgroup>
<thead>
<tr>
<th>Run</th>
<th>Mode</th>
<th>Count</th>
<th>Pubs</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>lat-ping-1k-128</code></td>
<td><strong>ping</strong> — publish, wait for that message, repeat</td>
<td>1,000</td>
<td>1</td>
<td>128 B</td>
</tr>
<tr>
<td><code>lat-1p-5k-128</code></td>
<td><strong>flood</strong> — publish all, then drain</td>
<td>5,000</td>
<td>1</td>
<td>128 B</td>
</tr>
<tr>
<td><code>lat-4p-10k-128</code></td>
<td>flood</td>
<td>10,000</td>
<td>4</td>
<td>128 B</td>
</tr>
<tr>
<td><code>lat-8p-20k-128</code></td>
<td>flood</td>
<td>20,000</td>
<td>8</td>
<td>128 B</td>
</tr>
<tr>
<td><code>lat-4p-5k-1k</code></td>
<td>flood</td>
<td>5,000</td>
<td>4</td>
<td>1024 B</td>
</tr>
</tbody>
</table>
<p>Ping answers “how long does one quiet hop take?” Flood answers “what
happens to the last message if we burst N messages into a mailbox?”
Those are different questions. Mixing them is how 0.3 ms and 400 ms get
confused.</p>
<h3 id="how-to-read-nats-bench-columns">4.3 How to read nats bench
columns</h3>
<ul>
<li><strong>Pub msgs/s</strong> — rate at which publishers finished
their share.</li>
<li><strong>Sub msgs/s</strong> — rate at which subscribers finished.
With several subscribers on the same subject, core NATS <strong>fans
out</strong>, so sub rate can exceed pub rate.</li>
<li><strong>Aggregate msgs/s</strong> — nats CLI
<code>NATS Pub/Sub stats</code> line (pub+sub work in one number).
Useful as a headline; do not treat it as “the network carried this many
unique messages.”</li>
<li>Empty JetStream sub cells mean that run was publish-only (durable
write, no consumer in the same process).</li>
</ul>
<hr />
<h2 id="throughput-results">5. Throughput results</h2>
<h3 id="core-nats">5.1 Core NATS</h3>
<figure>
<img src="charts/core-throughput.png"
alt="Core NATS throughput at four loads" />
<figcaption aria-hidden="true">Core NATS throughput at four
loads</figcaption>
</figure>
<table style="width:100%;">
<colgroup>
<col style="width: 7%" />
<col style="width: 26%" />
<col style="width: 17%" />
<col style="width: 14%" />
<col style="width: 17%" />
<col style="width: 14%" />
</colgroup>
<thead>
<tr>
<th>Run</th>
<th>Aggregate msgs/s</th>
<th>Pub msgs/s</th>
<th>Pub MB/s</th>
<th>Sub msgs/s</th>
<th>Sub MB/s</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>core-1p1s-50k-128</code></td>
<td>1,200,836</td>
<td>791,094</td>
<td>96.57</td>
<td>747,461</td>
<td>91.24</td>
</tr>
<tr>
<td><code>core-4p4s-100k-128</code></td>
<td>1,521,256</td>
<td>316,312</td>
<td>38.61</td>
<td>1,299,634</td>
<td>158.65</td>
</tr>
<tr>
<td><code>core-8p8s-200k-128</code></td>
<td>2,007,937</td>
<td>333,957</td>
<td>40.77</td>
<td>1,790,736</td>
<td>218.60</td>
</tr>
<tr>
<td><code>core-4p4s-50k-1k</code></td>
<td>630,460</td>
<td>247,747</td>
<td>241.94</td>
<td>510,216</td>
<td>498.26</td>
</tr>
</tbody>
</table>
<p><strong>What this chart is saying.</strong> Adding subscribers raises
<strong>aggregate</strong> and <strong>sub</strong> rates because each
published message is delivered to every subscriber. Publish rate does
<strong>not</strong> climb the same way: 1 publisher at 128 B already
pushes ~791k msgs/s; 4 and 8 publishers sit around 310335k msgs/s
<strong>each process slower</strong>, while fan-out on the sub side goes
to 1.3M then 1.8M.</p>
<p>That publisher slowdown is expected on this stand. The four/eight
publisher processes and the four/eight subscribers all run
<strong>inside one LXC</strong> (510) against three broker LXCs on the
<strong>same Proxmox CPU and <code>vmbr1</code></strong>. Per-publisher
logs show a wide spread (example, 4p core 128 B: 79k524k msgs/s among
the four pubs). That is CPU scheduling and client-side contention, not a
NATS cluster that only has one fast node.</p>
<p><strong>1:1 at 128 B</strong> is the cleanest core number:
<strong>~791k pub, ~747k sub, ~1.20M aggregate</strong>. The cluster and
the bridge can move three-quarter-million small messages per second
fire-and-forget from a single client pair.</p>
<h3 id="payload-size-core">5.2 Payload size (core)</h3>
<figure>
<img src="charts/payload-size.png" alt="Core NATS 128 B vs 1 KiB" />
<figcaption aria-hidden="true">Core NATS 128 B vs 1 KiB</figcaption>
</figure>
<p>Same 4p4s shape, two sizes:</p>
<table style="width:100%;">
<colgroup>
<col style="width: 13%" />
<col style="width: 26%" />
<col style="width: 23%" />
<col style="width: 17%" />
<col style="width: 17%" />
</colgroup>
<thead>
<tr>
<th>Payload</th>
<th>Aggregate msgs/s</th>
<th>Aggregate MB/s</th>
<th>Pub msgs/s</th>
<th>Sub msgs/s</th>
</tr>
</thead>
<tbody>
<tr>
<td>128 B</td>
<td>1,521,256</td>
<td>185.70</td>
<td>316,312</td>
<td>1,299,634</td>
</tr>
<tr>
<td>1 KiB</td>
<td>630,460</td>
<td>615.68</td>
<td>247,747</td>
<td>510,216</td>
</tr>
</tbody>
</table>
<p>Message rate falls; <strong>byte rate rises</strong> (186 MB/s → 616
MB/s aggregate). We are leaving the “tiny message, CPU/syscall bound”
region and entering “copying bytes across <code>vmbr1</code>.” Job JSON
and archive metadata sit nearer 128 B1 KiB than megabyte blobs (blobs
are HTTP/WORM, not NATS payloads).</p>
<h3 id="jetstream-r3-file">5.3 JetStream r=3 file</h3>
<figure>
<img src="charts/js-throughput.png"
alt="JetStream durable publish rate" />
<figcaption aria-hidden="true">JetStream durable publish
rate</figcaption>
</figure>
<table>
<colgroup>
<col style="width: 10%" />
<col style="width: 26%" />
<col style="width: 21%" />
<col style="width: 26%" />
<col style="width: 15%" />
</colgroup>
<thead>
<tr>
<th>Run</th>
<th>Pub msgs/s</th>
<th>Pub MB/s</th>
<th>Sub msgs/s</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>js-1p-20k-128-r3</code></td>
<td><strong>16,155</strong></td>
<td>1.97</td>
<td></td>
<td>publish-only</td>
</tr>
<tr>
<td><code>js-4p-50k-128-r3</code></td>
<td><strong>16,607</strong></td>
<td>2.03</td>
<td></td>
<td>four pubs, same ceiling</td>
</tr>
<tr>
<td><code>js-4p-20k-1k-r3</code></td>
<td><strong>13,493</strong></td>
<td>13.18</td>
<td></td>
<td>1 KiB still disk/replica bound</td>
</tr>
<tr>
<td><code>js-2p2s-20k-128-r3</code></td>
<td>10,965</td>
<td>1.34</td>
<td>10,942</td>
<td>pull consumers keep up</td>
</tr>
</tbody>
</table>
<p><strong>Four publishers do not make JetStream four times
faster.</strong> 1p and 4p at 128 B are both ~16k msgs/s. The limiter is
<strong>synchronous replication to three file-backed replicas</strong>,
not client parallelism. That is the result we wanted to see: the bench
stream is behaving like a replicated log, not like core fan-out.</p>
<p>Pull consume (<code>js-2p2s</code>) is slightly slower on publish
(~11k) because the same run is also reading. Pub and sub stay matched
(10,965 vs 10,942): the consumer is not the straggler.</p>
<p>1 KiB durable write is ~13.5k msgs/s (~13.2 MB/s). Bytes go up;
message rate dips only a little. JetStream here is
<strong>ack/fdatasync/replica</strong> bound, not payload-copy bound, in
this size range.</p>
<h3 id="core-vs-jetstream-same-client-same-cluster">5.4 Core vs
JetStream (same client, same cluster)</h3>
<figure>
<img src="charts/core-vs-js.png"
alt="Core vs JetStream publish rate, log scale" />
<figcaption aria-hidden="true">Core vs JetStream publish rate, log
scale</figcaption>
</figure>
<p>The log scale is required: core publish is <strong>~1550×</strong>
JetStream publish on this stand.</p>
<table>
<thead>
<tr>
<th>Shape</th>
<th>Core pub msgs/s</th>
<th>JS r=3 file pub msgs/s</th>
<th>Ratio</th>
</tr>
</thead>
<tbody>
<tr>
<td>1 publisher, 128 B</td>
<td>791,094</td>
<td>16,155</td>
<td>~49×</td>
</tr>
<tr>
<td>4 publishers, 128 B</td>
<td>316,312</td>
<td>16,607</td>
<td>~19×</td>
</tr>
<tr>
<td>4 publishers, 1 KiB</td>
<td>247,747</td>
<td>13,493</td>
<td>~18×</td>
</tr>
</tbody>
</table>
<p>This is not JetStream “losing.” Core is allowed to forget a message
the instant the server accepts it. JetStream on file with replicas=3
must <strong>record it on a majority</strong> before the publish acks.
Our product streams (<code>ZAPIER_*</code>, <code>VERAE_ARCHIVE</code>)
chose that trade on purpose: a job event that survives one LXC dying is
worth ~16k msgs/s instead of ~800k.</p>
<p>If we ever need core-like rates for a signal that may drop, that
signal should not be on a replicated file stream.</p>
<hr />
<h2 id="delay-results">6. Delay results</h2>
<figure>
<img src="charts/delay-percentiles.png"
alt="Delay percentiles, log scale" />
<figcaption aria-hidden="true">Delay percentiles, log scale</figcaption>
</figure>
<table style="width:100%;">
<colgroup>
<col style="width: 10%" />
<col style="width: 12%" />
<col style="width: 14%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
</colgroup>
<thead>
<tr>
<th>Run</th>
<th>Kind</th>
<th>Count</th>
<th>min</th>
<th>avg</th>
<th>p50</th>
<th>p90</th>
<th>p99</th>
<th>max</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>lat-ping-1k-128</code></td>
<td>ping (sequential RTT)</td>
<td>1000</td>
<td>0.254 ms</td>
<td><strong>0.307 ms</strong></td>
<td>0.286 ms</td>
<td>0.332 ms</td>
<td><strong>0.734 ms</strong></td>
<td>2.763 ms</td>
</tr>
<tr>
<td><code>lat-1p-5k-128</code></td>
<td>flood</td>
<td>5000</td>
<td>149.3 ms</td>
<td>238.6 ms</td>
<td>248.8 ms</td>
<td>274.3 ms</td>
<td>279.4 ms</td>
<td>279.7 ms</td>
</tr>
<tr>
<td><code>lat-4p-5k-1k</code></td>
<td>flood</td>
<td>5000</td>
<td>155.1 ms</td>
<td>211.7 ms</td>
<td>217.6 ms</td>
<td>223.3 ms</td>
<td>227.8 ms</td>
<td>228.4 ms</td>
</tr>
<tr>
<td><code>lat-4p-10k-128</code></td>
<td>flood</td>
<td>10000</td>
<td>174.2 ms</td>
<td>263.2 ms</td>
<td>266.7 ms</td>
<td>299.1 ms</td>
<td>304.2 ms</td>
<td>304.5 ms</td>
</tr>
<tr>
<td><code>lat-8p-20k-128</code></td>
<td>flood</td>
<td>20000</td>
<td>304.6 ms</td>
<td>453.7 ms</td>
<td>466.3 ms</td>
<td>499.9 ms</td>
<td>505.1 ms</td>
<td>505.6 ms</td>
</tr>
</tbody>
</table>
<p>The dashed line on the chart is 1 ms. Only <strong>ping</strong>
lives there.</p>
<h3 id="ping-the-quiet-hop">6.1 Ping — the quiet hop</h3>
<p>One publisher, one subscriber, two connections, wait for each message
before sending the next.</p>
<ul>
<li><strong>min 0.254 ms</strong> — guest → <code>vmbr1</code> → a
nats-server → <code>vmbr1</code> → guest.</li>
<li><strong>p50 0.286 ms / avg 0.307 ms</strong> — typical.</li>
<li><strong>p99 0.734 ms</strong> — still under a millisecond.</li>
<li><strong>max 2.763 ms</strong> — one outlier in 1,000 samples (GC,
scheduler, or a slow route). Not the tail we design for.</li>
</ul>
<p>A middleware <code>jobs.watch</code> publish followed by a waiter on
<code>jobs.events</code> is this shape when the poller is keeping up.
Compared with HTTPS to Zapier (tens to hundreds of milliseconds) or a
live Verae <code>GET /api/status/{jobId}</code>, NATS RTT is noise.</p>
<h3 id="flood-queueing-under-burst">6.2 Flood — queueing under
burst</h3>
<p>Publishers write the whole batch as fast as they can, then the
subscriber drains. Each messages delay is “how long was I in the buffer
before the subscriber got to me?”</p>
<p>That is why:</p>
<ul>
<li><strong>min is already ~150300 ms</strong> — even the first
messages wait behind a burst that filled the socket/client queue.</li>
<li><strong>p50 ≈ p99 ≈ max</strong> — a queue drain has a tight
distribution: everyone waits for roughly the same backlog.</li>
<li><strong>8p × 20k is ~450 ms avg</strong> — twice the messages of 4p
× 10k, roughly twice the wait. Linear in backlog, not in cluster
diameter.</li>
</ul>
<p>Flood is <strong>not</strong> a measurement of NATS being slow. The
ping column proves the hop is ~0.3 ms. Flood is a measurement of
<strong>what operators will see if a consumer stalls</strong>
(job-events mailbox, webhook deliver, archive reply). Backlog time ≈
<code>queued_messages / consume_rate</code>.</p>
<h3 id="kib-flood-vs-128-b-flood">6.3 1 KiB flood vs 128 B flood</h3>
<p>4 publishers, 5k messages at 1 KiB: avg <strong>212 ms</strong>,
slightly <strong>faster</strong> than 4p 10k × 128 B (263 ms) because
the <strong>count is half</strong>, even though each message is 8×
larger. Again: delay here tracks <strong>how many messages are
queued</strong>, not payload size, in this range.</p>
<hr />
<h2 id="what-this-means-for-verae-zapier">7. What this means for Verae ×
Zapier</h2>
<p>Product subjects on this cluster:</p>
<table>
<colgroup>
<col style="width: 29%" />
<col style="width: 19%" />
<col style="width: 51%" />
</colgroup>
<thead>
<tr>
<th>Address</th>
<th>Kind</th>
<th>Bench analogue</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>verae.zapier.jobs.watch</code></td>
<td>work queue (JetStream)</td>
<td>JS durable pub ~16k/s</td>
</tr>
<tr>
<td><code>verae.zapier.jobs.events</code></td>
<td>events</td>
<td>JS + ping if waiters keep up; flood if they do not</td>
</tr>
<tr>
<td><code>verae.zapier.webhooks.deliver</code></td>
<td>work queue</td>
<td>JS durable</td>
</tr>
<tr>
<td><code>verae.zapier.usage</code></td>
<td>optional</td>
<td>JS durable</td>
</tr>
<tr>
<td><code>verae.billing.*</code></td>
<td>request-reply</td>
<td>ping (quiet RTT)</td>
</tr>
<tr>
<td><code>verae.archive.put</code> / <code>query</code> /
<code>reply.*</code></td>
<td>JetStream + broadcast query</td>
<td>JS durable; query fan-out is closer to core but still JS-backed
puts</td>
</tr>
</tbody>
</table>
<p><strong>Capacity.</strong> 16k durable 128 B pubs/s is
<strong>~1.4×10⁹ messages/day</strong> if you could fill the pipe. We
will not. Zapier HTTPS, live <code>api.veraetime.net</code>, WORM bloom
checks, and human Zap runs sit far below that. This cluster is not the
product bottleneck on NS1.</p>
<p><strong>Latency budget.</strong> A timestamp wait is: HTTP in → NATS
watch → poll Verae → NATS event → HTTP out (or REST Hook). The NATS
pieces are <strong>sub-millisecond</strong> when caught up. Do not spend
time “optimizing NATS RTT” until Zapier/Verae HTTP is in the same
band.</p>
<p><strong>Backlogs.</strong> The failure mode that <em>does</em> show
up in these numbers is flood delay. If webhook-deliver or job-events
consumers pause (keep stopped, replica floor, a blocked HTTPS post to
<code>hooks.zapier.com</code>), waiters will see <strong>hundreds of
milliseconds to seconds</strong> of queue time. Fleet replica floors and
keep exist to prevent that, not because 0.3 ms is too slow.</p>
<p><strong>Hardware move.</strong> Same three configs, three boxes,
private NIC. Expect:</p>
<ul>
<li>Core numbers to change with NIC and CPU (maybe up, maybe down).</li>
<li>JetStream numbers to change <strong>more</strong>, because they are
disk + fsync + replica RTT. Distinct SSDs should help; a slow shared
datastore would hurt.</li>
<li>Ping RTT to grow by whatever the real NIC and switch add (still
likely low milliseconds on a LAN).</li>
</ul>
<hr />
<h2 id="limits-of-this-measurement">8. Limits of this measurement</h2>
<ol type="1">
<li><strong>One Proxmox host.</strong> LXC 510513 share cores, memory,
and the hosts disk. Replica=3 on file is <strong>three files on the
same underlying storage</strong>, not three failure domains. HA of “one
disk dies” is <strong>not</strong> proven. HA of “one LXC process dies”
is the actual claim.</li>
<li><strong>Short runs.</strong> Tens of thousands of messages, seconds
of wall time. No compaction, no multi-hour page-cache eviction, no
snapshot/restore during load.</li>
<li><strong>No TLS, no nkeys.</strong> <code>verae-nats-accounts</code>
is still a sketch. Auth would add CPU; it would not turn 16k into
800k.</li>
<li><strong>One bench client.</strong> All publishers live in 510. A
fleet of workers on several CTs might publish more into JetStream until
disk/replicas saturate — the 1p vs 4p JS result says that saturation is
already ~16k from one CT.</li>
<li><strong>nats 0.1.6</strong> does not report delay. Anyone reading
<code>min | avg | max msgs</code> on a bench log as microseconds will
get the wrong story. Delay is only <code>latency.mjs</code>.</li>
<li><strong>Core aggregate ≠ unique messages.</strong> Fan-out
double-counts. Use pub or sub columns when comparing to JetStream.</li>
<li><strong>Not a Zapier or Verae API bench.</strong> Those are still
blocked on operator login / live credentials.</li>
</ol>
<hr />
<h2 id="how-to-reproduce">9. How to reproduce</h2>
<p>On NS1 (Proxmox), from the <code>verae-nats-cluster</code>
checkout:</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="fu">bash</span> scripts/status.sh <span class="co"># 3/3 JetStream</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="fu">bash</span> scripts/bench.sh <span class="co"># writes results/&lt;utc&gt;/ and BENCH.md</span></span></code></pre></div>
<p>The client VMID defaults to <strong>510</strong>. Override with
<code>CLIENT_VMID=…</code>. <code>NATS_URL</code> comes from
<code>client.env</code>.</p>
<p>Rebuild this progress report (charts + HTML + PDF) from the
monorepo:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="ex">python3</span> packages/zapier-decisions/scripts/build-nats-bench-report.py</span></code></pre></div>
<hr />
<h2 id="appendix-environment-and-files">10. Appendix — environment and
files</h2>
<table>
<colgroup>
<col style="width: 46%" />
<col style="width: 53%" />
</colgroup>
<thead>
<tr>
<th>Item</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Run stamp</td>
<td><code>20260912T045131Z</code></td>
</tr>
<tr>
<td>Client</td>
<td>LXC 510 <code>verae-px-worker</code> <code>10.10.10.20</code></td>
</tr>
<tr>
<td>Servers</td>
<td>511/512/513 <code>nats-a/b/c</code> <code>10.10.10.2123</code></td>
</tr>
<tr>
<td>nats CLI</td>
<td>0.1.6 linux-amd64</td>
</tr>
<tr>
<td>JS storage</td>
<td>file, replicas=3, stream <code>benchstream</code> (deleted between
loads)</td>
</tr>
<tr>
<td>Isolation</td>
<td><code>vmbr1</code> only; no <code>0.0.0.0</code> client bind</td>
</tr>
<tr>
<td>Short tables</td>
<td><a
href="https://git.georgelambert.org/marchon/verae-nats-cluster/src/branch/main/BENCH.md">BENCH.md</a></td>
</tr>
<tr>
<td>Raw logs</td>
<td><code>packages/verae-nats-cluster/results/20260912T045131Z/</code></td>
</tr>
<tr>
<td>This report</td>
<td><code>packages/zapier-decisions/reports/nats-cluster-bench.{md,html,pdf}</code></td>
</tr>
</tbody>
</table>
<p>Publisher rate spread (nats CLI, msgs/s, <strong>not</strong>
delay):</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>min</th>
<th>avg</th>
<th>max</th>
</tr>
</thead>
<tbody>
<tr>
<td>core-4p4s-100k-128 pub</td>
<td>79,260</td>
<td>257,805</td>
<td>524,453</td>
</tr>
<tr>
<td>core-8p8s-200k-128 pub</td>
<td>41,744</td>
<td>71,488</td>
<td>152,536</td>
</tr>
<tr>
<td>core-4p4s-50k-1k pub</td>
<td>61,936</td>
<td>110,271</td>
<td>176,262</td>
</tr>
<tr>
<td>js-4p-50k-128-r3 pub</td>
<td>4,154</td>
<td>5,176</td>
<td>6,628</td>
</tr>
<tr>
<td>js-4p-20k-1k-r3 pub</td>
<td>3,373</td>
<td>4,063</td>
<td>5,121</td>
</tr>
<tr>
<td>js-2p2s-20k-128-r3 pub</td>
<td>5,485</td>
<td>7,081</td>
<td>8,678</td>
</tr>
</tbody>
</table>
<p>Wide core spreads are the single-client-CT effect described in §5.1.
JetStream spreads are narrow and low — every publisher is waiting on the
same replicated write path.</p>
</body>
</html>