commit 8639ca27ee1556e31dc226890880247042441e16 Author: George Lambert Date: Sat Sep 12 02:06:10 2026 -0400 Snapshot of verae-nats-cluster (optimal NATS config study) diff --git a/BENCH.md b/BENCH.md new file mode 100644 index 0000000..a24b18b --- /dev/null +++ b/BENCH.md @@ -0,0 +1,49 @@ +# NATS cluster message speed + +Run **`20260912T045131Z`** (UTC). Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only). + +Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +## Method + +- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack. +- **JetStream** uses **file** storage and **replicas=3** (same as product streams). The unique stream `benchstream` is deleted between JS loads. +- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay. +- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster. +- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. This is **queueing under burst**, not wire RTT. +- Probe: `scripts/latency.mjs` (two connections, header timestamp). + +## Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 1,200,836 | 791,094 | 96.57 | 747,461 | 91.24 | +| `core-4p4s-100k-128` | core pub/sub | 1,521,256 | 316,312 | 38.61 | 1,299,634 | 158.65 | +| `core-4p4s-50k-1k` | core pub/sub | 630,460 | 247,747 | 241.94 | 510,216 | 498.26 | +| `core-8p8s-200k-128` | core pub/sub | 2,007,937 | 333,957 | 40.77 | 1,790,736 | 218.60 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 16,155 | 1.97 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 21,861 | 10,965 | 1.34 | 10,942 | 1.34 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 13,493 | 13.18 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 16,607 | 2.03 | — | — | + +## Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.254ms | 0.307ms | 0.286ms | 0.332ms | 0.734ms | 2.763ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 149.279ms | 238.626ms | 248.752ms | 274.314ms | 279.398ms | 279.740ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 155.101ms | 211.706ms | 217.579ms | 223.268ms | 227.798ms | 228.370ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 174.174ms | 263.186ms | 266.672ms | 299.073ms | 304.233ms | 304.475ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 304.586ms | 453.749ms | 466.296ms | 499.924ms | 505.112ms | 505.620ms | + +## What the numbers mean + +Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about **16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers. + +A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers. + +Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`. diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..b326ade --- /dev/null +++ b/NATS.md @@ -0,0 +1,7 @@ +# NATS — verae-nats-cluster + +This repo **is** the three-node JetStream cluster. Listeners are the guest private IPs on `vmbr1` (`10.10.10.21–23:4222` and `:6222`). HTTP monitor is loopback `:8222` inside each guest. + +Zapier cloud, browsers, and `vmbr0` never get a NATS socket. + +Throughput and delay at several loads: [BENCH.md](BENCH.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..a73d1fb --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# verae-nats-cluster + +Three **distinct Proxmox LXC** guests running **NATS JetStream** as a cluster, on the private bridge only. This is the test stand before the same layout moves to separate hardware. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-nats-cluster +**SSH:** `ssh://git@git.georgelambert.org:2223/marchon/verae-nats-cluster.git` + +| Guest | VMID | `vmbr1` IP | Client | Cluster | Monitor | +|-------|------|------------|--------|---------|---------| +| nats-a | 511 | 10.10.10.21 | `:4222` | `:6222` | `127.0.0.1:8222` | +| nats-b | 512 | 10.10.10.22 | `:4222` | `:6222` | `127.0.0.1:8222` | +| nats-c | 513 | 10.10.10.23 | `:4222` | `:6222` | `127.0.0.1:8222` | + +Cluster name: `verae`. Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +**Not** on `vmbr0`. **Not** `0.0.0.0`. Host lab NATS on `127.0.0.1:4222` is left running so current keep/fleet/middleware stay up until you cut over. + +```bash +# on NS1 (the Proxmox host) +git clone ssh://git@git.georgelambert.org:2223/marchon/verae-nats-cluster.git +cd verae-nats-cluster +bash scripts/create-cluster.sh +bash scripts/status.sh +bash scripts/test.sh +``` + +Test-env cut-over (keep/fleet/middleware on NS1): + +```bash +# 1) create product streams (replicas=3) +bash scripts/ensure-streams.sh +# 2) fleet overlay nats.url + services/*.json already list the three URLs +# 3) restart fleet and keep with NATS_URL from client.env +``` + +`JETSTREAM_REPLICAS=3` on middleware. Host `127.0.0.1:4222` can stay up unused. Zapier cloud never talks to NATS. + +nkeys/mTLS: `verae-nats-accounts` still has the INTERNAL/LEAF sketch. Do **not** enable accounts on this cluster until every client passes credentials in `NATS_URL`. Private `vmbr1` is the current isolation. + +## Message speed (throughput + delay) + +From a **client that is not a nats-* server** (LXC 510 `verae-px-worker`): + +```bash +# on NS1 (hypervisor). Bench client is LXC 510. +bash scripts/bench.sh + +# Full study that must run ON NS1 (orchestrator + charts + HTML/PDF): +bash scripts/study-on-ns1.sh + +# Maximize cores/RAM + tmpfs JetStream, same ladder, restore ZFS after: +bash scripts/maximize-ns1-study.sh + +# Exhaustive factorial (r=1/r=3, file/memory, reconnect, UDP, MQTT) + combined report: +bash scripts/exhaustive-ns1-study.sh +``` + +That writes `results//` and copies a summary to [BENCH.md](BENCH.md). Full write-up with charts (HTML + PDF) is in the progress repo: [nats-cluster-bench](https://zapier.georgelambert.org/packages/zapier-decisions/reports/nats-cluster-bench.pdf). Core NATS is fire-and-forget; JetStream rows use **file** storage and **replicas=3**. Ping delay is sequential RTT; flood delay is queueing under burst. + +Latest run (`20260912T045131Z`): + +| Kind | Load | Result | +|------|------|--------| +| Core 1p1s 50k×128 B | pub/sub | ~1.20M msgs/s aggregate | +| Core 8p8s 200k×128 B | pub/sub | ~2.01M msgs/s aggregate | +| Core 4p4s 50k×1 KiB | pub/sub | ~630k msgs/s (~616 MB/s aggregate) | +| JetStream r=3 file | 1–4 pubs, 128 B | ~16k durable pubs/s | +| JetStream r=3 file | 4 pubs, 1 KiB | ~13.5k durable pubs/s | +| Ping 1k×128 B | sequential RTT | avg 0.307 ms, p99 0.734 ms | +| Flood 8p 20k×128 B | burst queueing | avg 454 ms | + +Hardware move: same three configs, three boxes, private NIC only — change IPs in `cluster.env` and `conf/nats.conf.tmpl`. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..286cd8a --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,5 @@ +# verae-nats-cluster + +Proxmox LXC 511–513 (`nats-a/b/c`) form a JetStream cluster on `vmbr1` for HA testing before dedicated hardware. + +Message speed (client LXC 510 vs the three nodes): see [BENCH.md](BENCH.md). Product streams are JetStream **file + replicas=3** (~16k durable 128 B pubs/s on this stand). Core NATS is ~1–2M msgs/s at 128 B. Sequential RTT ~0.3 ms; flood queueing is hundreds of ms. diff --git a/client.env b/client.env new file mode 100644 index 0000000..4afa412 --- /dev/null +++ b/client.env @@ -0,0 +1,3 @@ +# Test-environment client URL. Private vmbr1 only. Lab loopback stays until cut-over. +export NATS_URL="${NATS_URL:-nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222}" +export JETSTREAM_REPLICAS="${JETSTREAM_REPLICAS:-3}" diff --git a/cluster.env b/cluster.env new file mode 100644 index 0000000..f26af69 --- /dev/null +++ b/cluster.env @@ -0,0 +1,19 @@ +# Distinct Proxmox LXC guests on NS1 vmbr1. Not the host loopback NATS. +BRIDGE="${BRIDGE:-vmbr1}" +GW="${GW:-10.10.10.1}" +MEMORY="${MEMORY:-16384}" +CORES="${CORES:-8}" +DISK="${DISK:-8}" +STORAGE="${STORAGE:-SSD2}" +TEMPLATE="${TEMPLATE:-/var/lib/vz/template/cache/ubuntu-24.04-standard_24.04-2_amd64.tar.zst}" +DNS="${DNS:-8.8.8.8}" +USER_NAME="${USER_NAME:-marchon}" +NATS_VER="${NATS_VER:-2.10.26}" +CLUSTER_NAME="${CLUSTER_NAME:-verae}" + +# vmid hostname ipv4 +NODES="${NODES:- +511 nats-a 10.10.10.21 +512 nats-b 10.10.10.22 +513 nats-c 10.10.10.23 +}" diff --git a/conf/nats.conf.tmpl b/conf/nats.conf.tmpl new file mode 100644 index 0000000..abae4ae --- /dev/null +++ b/conf/nats.conf.tmpl @@ -0,0 +1,19 @@ +# JetStream cluster node. Bind the private guest IP only — never vmbr0 / 0.0.0.0. +server_name: {{NAME}} +host: {{IP}} +port: 4222 +http: 127.0.0.1:8222 + +jetstream { + store_dir: /var/lib/nats/jetstream + max_mem: 8G + max_file: 4G +} + +cluster { + name: {{CLUSTER}} + listen: {{IP}}:6222 + routes: [ +{{ROUTES}} + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dcc5c11 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "verae-nats-cluster", + "version": "0.1.0", + "private": true, + "description": "Three-node NATS JetStream cluster on distinct Proxmox LXC guests (vmbr1 only)", + "scripts": { + "create": "bash scripts/create-cluster.sh", + "test": "bash scripts/test.sh", + "status": "bash scripts/status.sh", + "bench": "bash scripts/bench.sh" + } +} diff --git a/results/.gitignore b/results/.gitignore new file mode 100644 index 0000000..be39466 --- /dev/null +++ b/results/.gitignore @@ -0,0 +1,10 @@ +* +!.gitignore +!20260912T045131Z/ +!20260912T045131Z/** +!20260912T051237Z/ +!20260912T051237Z/** +!20260912T053120Z/ +!20260912T053120Z/** +!20260912T055851Z/ +!20260912T055851Z/** diff --git a/results/20260912T045131Z/BENCH.md b/results/20260912T045131Z/BENCH.md new file mode 100644 index 0000000..a24b18b --- /dev/null +++ b/results/20260912T045131Z/BENCH.md @@ -0,0 +1,49 @@ +# NATS cluster message speed + +Run **`20260912T045131Z`** (UTC). Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only). + +Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +## Method + +- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack. +- **JetStream** uses **file** storage and **replicas=3** (same as product streams). The unique stream `benchstream` is deleted between JS loads. +- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay. +- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster. +- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. This is **queueing under burst**, not wire RTT. +- Probe: `scripts/latency.mjs` (two connections, header timestamp). + +## Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 1,200,836 | 791,094 | 96.57 | 747,461 | 91.24 | +| `core-4p4s-100k-128` | core pub/sub | 1,521,256 | 316,312 | 38.61 | 1,299,634 | 158.65 | +| `core-4p4s-50k-1k` | core pub/sub | 630,460 | 247,747 | 241.94 | 510,216 | 498.26 | +| `core-8p8s-200k-128` | core pub/sub | 2,007,937 | 333,957 | 40.77 | 1,790,736 | 218.60 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 16,155 | 1.97 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 21,861 | 10,965 | 1.34 | 10,942 | 1.34 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 13,493 | 13.18 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 16,607 | 2.03 | — | — | + +## Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.254ms | 0.307ms | 0.286ms | 0.332ms | 0.734ms | 2.763ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 149.279ms | 238.626ms | 248.752ms | 274.314ms | 279.398ms | 279.740ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 155.101ms | 211.706ms | 217.579ms | 223.268ms | 227.798ms | 228.370ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 174.174ms | 263.186ms | 266.672ms | 299.073ms | 304.233ms | 304.475ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 304.586ms | 453.749ms | 466.296ms | 499.924ms | 505.112ms | 505.620ms | + +## What the numbers mean + +Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about **16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers. + +A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers. + +Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`. diff --git a/results/20260912T045131Z/core-1p1s-50k-128.csv b/results/20260912T045131Z/core-1p1s-50k-128.csv new file mode 100644 index 0000000..cfd7d62 --- /dev/null +++ b/results/20260912T045131Z/core-1p1s-50k-128.csv @@ -0,0 +1,3 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +8e1ExxrZ9OtHTkwXsOfzes,S0,50000,6400000,747461,95675134.125326,0.066893 +8e1ExxrZ9OtHTkwXsOfzes,P0,50000,6400000,791094,101260059.613379,0.063204 diff --git a/results/20260912T045131Z/core-1p1s-50k-128.txt b/results/20260912T045131Z/core-1p1s-50k-128.txt new file mode 100644 index 0000000..f2e88d8 --- /dev/null +++ b/results/20260912T045131Z/core-1p1s-50k-128.txt @@ -0,0 +1,7 @@ +=== core-1p1s-50k-128 === + +NATS Pub/Sub stats: 1,200,836 msgs/sec ~ 146.59 MB/sec + Pub stats: 791,094 msgs/sec ~ 96.57 MB/sec + Sub stats: 747,461 msgs/sec ~ 91.24 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/core-4p4s-100k-128.csv b/results/20260912T045131Z/core-4p4s-100k-128.csv new file mode 100644 index 0000000..eaa0df7 --- /dev/null +++ b/results/20260912T045131Z/core-4p4s-100k-128.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +d1csC8CbCBLYlUMAUVZxpb,S0,100000,12800000,377317,48296660.364795,0.265029 +d1csC8CbCBLYlUMAUVZxpb,S1,100000,12800000,351445,44985056.104940,0.284539 +d1csC8CbCBLYlUMAUVZxpb,S2,100000,12800000,372973,47740617.289389,0.268116 +d1csC8CbCBLYlUMAUVZxpb,S3,100000,12800000,324908,41588310.877432,0.307779 +d1csC8CbCBLYlUMAUVZxpb,P0,25000,3200000,524453,67129988.054428,0.047669 +d1csC8CbCBLYlUMAUVZxpb,P1,25000,3200000,324337,41515233.366208,0.077080 +d1csC8CbCBLYlUMAUVZxpb,P2,25000,3200000,103170,13205778.771206,0.242318 +d1csC8CbCBLYlUMAUVZxpb,P3,25000,3200000,79260,10145297.784464,0.315417 diff --git a/results/20260912T045131Z/core-4p4s-100k-128.txt b/results/20260912T045131Z/core-4p4s-100k-128.txt new file mode 100644 index 0000000..175d7ca --- /dev/null +++ b/results/20260912T045131Z/core-4p4s-100k-128.txt @@ -0,0 +1,17 @@ +=== core-4p4s-100k-128 === + +NATS Pub/Sub stats: 1,521,256 msgs/sec ~ 185.70 MB/sec + Pub stats: 316,312 msgs/sec ~ 38.61 MB/sec + [1] 524,453 msgs/sec ~ 64.02 MB/sec (25000 msgs) + [2] 324,337 msgs/sec ~ 39.59 MB/sec (25000 msgs) + [3] 103,170 msgs/sec ~ 12.59 MB/sec (25000 msgs) + [4] 79,260 msgs/sec ~ 9.68 MB/sec (25000 msgs) + min 79,260 | avg 257,805 | max 524,453 | stddev 181,189 msgs + Sub stats: 1,299,634 msgs/sec ~ 158.65 MB/sec + [1] 377,317 msgs/sec ~ 46.06 MB/sec (100000 msgs) + [2] 351,445 msgs/sec ~ 42.90 MB/sec (100000 msgs) + [3] 372,973 msgs/sec ~ 45.53 MB/sec (100000 msgs) + [4] 324,908 msgs/sec ~ 39.66 MB/sec (100000 msgs) + min 324,908 | avg 356,660 | max 377,317 | stddev 20,785 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/core-4p4s-50k-1k.csv b/results/20260912T045131Z/core-4p4s-50k-1k.csv new file mode 100644 index 0000000..becf15b --- /dev/null +++ b/results/20260912T045131Z/core-4p4s-50k-1k.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +LXVQ7kvd26BIwiMRRfbr7Z,S0,50000,51200000,169213,173274762.582948,0.295484 +LXVQ7kvd26BIwiMRRfbr7Z,S1,50000,51200000,150273,153880103.181538,0.332727 +LXVQ7kvd26BIwiMRRfbr7Z,S2,50000,51200000,142138,145550108.197459,0.351769 +LXVQ7kvd26BIwiMRRfbr7Z,S3,50000,51200000,132134,135306220.651698,0.378401 +LXVQ7kvd26BIwiMRRfbr7Z,P0,12500,12800000,176262,180492467.210335,0.070917 +LXVQ7kvd26BIwiMRRfbr7Z,P1,12500,12800000,132211,135384883.941361,0.094545 +LXVQ7kvd26BIwiMRRfbr7Z,P2,12500,12800000,70678,72375041.084851,0.176857 +LXVQ7kvd26BIwiMRRfbr7Z,P3,12500,12800000,61936,63423297.033934,0.201819 diff --git a/results/20260912T045131Z/core-4p4s-50k-1k.txt b/results/20260912T045131Z/core-4p4s-50k-1k.txt new file mode 100644 index 0000000..30a8ca2 --- /dev/null +++ b/results/20260912T045131Z/core-4p4s-50k-1k.txt @@ -0,0 +1,17 @@ +=== core-4p4s-50k-1k === + +NATS Pub/Sub stats: 630,460 msgs/sec ~ 615.68 MB/sec + Pub stats: 247,747 msgs/sec ~ 241.94 MB/sec + [1] 176,262 msgs/sec ~ 172.13 MB/sec (12500 msgs) + [2] 132,211 msgs/sec ~ 129.11 MB/sec (12500 msgs) + [3] 70,678 msgs/sec ~ 69.02 MB/sec (12500 msgs) + [4] 61,936 msgs/sec ~ 60.49 MB/sec (12500 msgs) + min 61,936 | avg 110,271 | max 176,262 | stddev 46,744 msgs + Sub stats: 510,216 msgs/sec ~ 498.26 MB/sec + [1] 169,213 msgs/sec ~ 165.25 MB/sec (50000 msgs) + [2] 150,273 msgs/sec ~ 146.75 MB/sec (50000 msgs) + [3] 142,138 msgs/sec ~ 138.81 MB/sec (50000 msgs) + [4] 132,134 msgs/sec ~ 129.04 MB/sec (50000 msgs) + min 132,134 | avg 148,439 | max 169,213 | stddev 13,605 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/core-8p8s-200k-128.csv b/results/20260912T045131Z/core-8p8s-200k-128.csv new file mode 100644 index 0000000..591315b --- /dev/null +++ b/results/20260912T045131Z/core-8p8s-200k-128.csv @@ -0,0 +1,17 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +HuvulruPjjaqnoHWeFCH2I,S0,200000,25600000,349350,44716843.071789,0.572491 +HuvulruPjjaqnoHWeFCH2I,S1,200000,25600000,248162,31764858.924451,0.805922 +HuvulruPjjaqnoHWeFCH2I,S2,200000,25600000,244206,31258460.045739,0.818978 +HuvulruPjjaqnoHWeFCH2I,S3,200000,25600000,243405,31155913.818220,0.821674 +HuvulruPjjaqnoHWeFCH2I,S4,200000,25600000,230550,29510438.641581,0.867490 +HuvulruPjjaqnoHWeFCH2I,S5,200000,25600000,231495,29631384.363172,0.863949 +HuvulruPjjaqnoHWeFCH2I,S6,200000,25600000,229528,29379679.692357,0.871351 +HuvulruPjjaqnoHWeFCH2I,S7,200000,25600000,223842,28651789.197519,0.893487 +HuvulruPjjaqnoHWeFCH2I,P0,25000,3200000,152536,19524693.902374,0.163895 +HuvulruPjjaqnoHWeFCH2I,P1,25000,3200000,84526,10819333.375159,0.295767 +HuvulruPjjaqnoHWeFCH2I,P2,25000,3200000,82017,10498223.303653,0.304813 +HuvulruPjjaqnoHWeFCH2I,P3,25000,3200000,57214,7323427.896669,0.436954 +HuvulruPjjaqnoHWeFCH2I,P4,25000,3200000,53879,6896566.453658,0.463999 +HuvulruPjjaqnoHWeFCH2I,P5,25000,3200000,51420,6581852.801766,0.486185 +HuvulruPjjaqnoHWeFCH2I,P6,25000,3200000,48572,6217303.262856,0.514693 +HuvulruPjjaqnoHWeFCH2I,P7,25000,3200000,41744,5343333.685114,0.598877 diff --git a/results/20260912T045131Z/core-8p8s-200k-128.txt b/results/20260912T045131Z/core-8p8s-200k-128.txt new file mode 100644 index 0000000..e841fde --- /dev/null +++ b/results/20260912T045131Z/core-8p8s-200k-128.txt @@ -0,0 +1,25 @@ +=== core-8p8s-200k-128 === + +NATS Pub/Sub stats: 2,007,937 msgs/sec ~ 245.11 MB/sec + Pub stats: 333,957 msgs/sec ~ 40.77 MB/sec + [1] 152,536 msgs/sec ~ 18.62 MB/sec (25000 msgs) + [2] 84,526 msgs/sec ~ 10.32 MB/sec (25000 msgs) + [3] 82,017 msgs/sec ~ 10.01 MB/sec (25000 msgs) + [4] 57,214 msgs/sec ~ 6.98 MB/sec (25000 msgs) + [5] 53,879 msgs/sec ~ 6.58 MB/sec (25000 msgs) + [6] 51,420 msgs/sec ~ 6.28 MB/sec (25000 msgs) + [7] 48,572 msgs/sec ~ 5.93 MB/sec (25000 msgs) + [8] 41,744 msgs/sec ~ 5.10 MB/sec (25000 msgs) + min 41,744 | avg 71,488 | max 152,536 | stddev 33,867 msgs + Sub stats: 1,790,736 msgs/sec ~ 218.60 MB/sec + [1] 349,350 msgs/sec ~ 42.65 MB/sec (200000 msgs) + [2] 248,162 msgs/sec ~ 30.29 MB/sec (200000 msgs) + [3] 244,206 msgs/sec ~ 29.81 MB/sec (200000 msgs) + [4] 243,405 msgs/sec ~ 29.71 MB/sec (200000 msgs) + [5] 230,550 msgs/sec ~ 28.14 MB/sec (200000 msgs) + [6] 231,495 msgs/sec ~ 28.26 MB/sec (200000 msgs) + [7] 229,528 msgs/sec ~ 28.02 MB/sec (200000 msgs) + [8] 223,842 msgs/sec ~ 27.32 MB/sec (200000 msgs) + min 223,842 | avg 250,067 | max 349,350 | stddev 38,364 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/js-1p-20k-128-r3.csv b/results/20260912T045131Z/js-1p-20k-128-r3.csv new file mode 100644 index 0000000..a859e0f --- /dev/null +++ b/results/20260912T045131Z/js-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +S0xQOJuOM4PlwAHoh5Bj7f,P0,40000,2560000,16155,2067883.858001,1.237981 diff --git a/results/20260912T045131Z/js-1p-20k-128-r3.txt b/results/20260912T045131Z/js-1p-20k-128-r3.txt new file mode 100644 index 0000000..acbbaba --- /dev/null +++ b/results/20260912T045131Z/js-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-1p-20k-128-r3 === + +Pub stats: 16,155 msgs/sec ~ 1.97 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/js-2p2s-20k-128-r3.csv b/results/20260912T045131Z/js-2p2s-20k-128-r3.csv new file mode 100644 index 0000000..99cceef --- /dev/null +++ b/results/20260912T045131Z/js-2p2s-20k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +smN9zLbWAAZER4FjSmfwcO,S0,20126,1280000,5474,700679.773679,1.826797 +smN9zLbWAAZER4FjSmfwcO,S1,20130,1280000,5471,700313.845376,1.827752 +smN9zLbWAAZER4FjSmfwcO,P0,20000,1280000,8678,1110910.588857,1.152208 +smN9zLbWAAZER4FjSmfwcO,P1,20000,1280000,5485,702119.773868,1.823051 diff --git a/results/20260912T045131Z/js-2p2s-20k-128-r3.txt b/results/20260912T045131Z/js-2p2s-20k-128-r3.txt new file mode 100644 index 0000000..b308dd7 --- /dev/null +++ b/results/20260912T045131Z/js-2p2s-20k-128-r3.txt @@ -0,0 +1,13 @@ +=== js-2p2s-20k-128-r3 === + +NATS Pub/Sub stats: 21,861 msgs/sec ~ 2.67 MB/sec + Pub stats: 10,965 msgs/sec ~ 1.34 MB/sec + [1] 8,678 msgs/sec ~ 1.06 MB/sec (10000 msgs) + [2] 5,485 msgs/sec ~ 685.66 KB/sec (10000 msgs) + min 5,485 | avg 7,081 | max 8,678 | stddev 1,596 msgs + Sub stats: 10,942 msgs/sec ~ 1.34 MB/sec + [1] 5,474 msgs/sec ~ 684.26 KB/sec (10000 msgs) + [2] 5,471 msgs/sec ~ 683.90 KB/sec (10000 msgs) + min 5,471 | avg 5,472 | max 5,474 | stddev 1 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/js-4p-20k-1k-r3.csv b/results/20260912T045131Z/js-4p-20k-1k-r3.csv new file mode 100644 index 0000000..87fbaa8 --- /dev/null +++ b/results/20260912T045131Z/js-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +zOcPILhowCWfp2PGmHUgqx,P0,10000,5120000,5121,5244574.853257,0.976247 +zOcPILhowCWfp2PGmHUgqx,P1,10000,5120000,4281,4383926.080211,1.167903 +zOcPILhowCWfp2PGmHUgqx,P2,10000,5120000,3480,3564282.061583,1.436474 +zOcPILhowCWfp2PGmHUgqx,P3,10000,5120000,3373,3454449.904903,1.482146 diff --git a/results/20260912T045131Z/js-4p-20k-1k-r3.txt b/results/20260912T045131Z/js-4p-20k-1k-r3.txt new file mode 100644 index 0000000..0a13168 --- /dev/null +++ b/results/20260912T045131Z/js-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-20k-1k-r3 === + +Pub stats: 13,493 msgs/sec ~ 13.18 MB/sec + [1] 5,121 msgs/sec ~ 5.00 MB/sec (5000 msgs) + [2] 4,281 msgs/sec ~ 4.18 MB/sec (5000 msgs) + [3] 3,480 msgs/sec ~ 3.40 MB/sec (5000 msgs) + [4] 3,373 msgs/sec ~ 3.29 MB/sec (5000 msgs) + min 3,373 | avg 4,063 | max 5,121 | stddev 704 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/js-4p-50k-128-r3.csv b/results/20260912T045131Z/js-4p-50k-128-r3.csv new file mode 100644 index 0000000..69c5663 --- /dev/null +++ b/results/20260912T045131Z/js-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +vZIFpR751J0vbDTJQDYGTN,P0,25000,1600000,6628,848387.283433,1.885931 +vZIFpR751J0vbDTJQDYGTN,P1,25000,1600000,5762,737571.570534,2.169281 +vZIFpR751J0vbDTJQDYGTN,P2,25000,1600000,4162,532825.280344,3.002861 +vZIFpR751J0vbDTJQDYGTN,P3,25000,1600000,4154,531740.716359,3.008985 diff --git a/results/20260912T045131Z/js-4p-50k-128-r3.txt b/results/20260912T045131Z/js-4p-50k-128-r3.txt new file mode 100644 index 0000000..75ea713 --- /dev/null +++ b/results/20260912T045131Z/js-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-50k-128-r3 === + +Pub stats: 16,607 msgs/sec ~ 2.03 MB/sec + [1] 6,628 msgs/sec ~ 828.50 KB/sec (12500 msgs) + [2] 5,762 msgs/sec ~ 720.28 KB/sec (12500 msgs) + [3] 4,162 msgs/sec ~ 520.34 KB/sec (12500 msgs) + [4] 4,154 msgs/sec ~ 519.28 KB/sec (12500 msgs) + min 4,154 | avg 5,176 | max 6,628 | stddev 1,063 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T045131Z/lat-1p-5k-128.txt b/results/20260912T045131Z/lat-1p-5k-128.txt new file mode 100644 index 0000000..9a05d77 --- /dev/null +++ b/results/20260912T045131Z/lat-1p-5k-128.txt @@ -0,0 +1,2 @@ +=== lat-1p-5k-128 === +{"count":5000,"pubs":1,"size":128,"min_us":149279,"avg_us":238626,"p50_us":248752,"p90_us":274314,"p99_us":279398,"max_us":279740,"min":"149.279ms","avg":"238.626ms","p50":"248.752ms","p90":"274.314ms","p99":"279.398ms","max":"279.740ms"} diff --git a/results/20260912T045131Z/lat-4p-10k-128.txt b/results/20260912T045131Z/lat-4p-10k-128.txt new file mode 100644 index 0000000..e5c883c --- /dev/null +++ b/results/20260912T045131Z/lat-4p-10k-128.txt @@ -0,0 +1,2 @@ +=== lat-4p-10k-128 === +{"count":10000,"pubs":4,"size":128,"min_us":174174,"avg_us":263186,"p50_us":266672,"p90_us":299073,"p99_us":304233,"max_us":304475,"min":"174.174ms","avg":"263.186ms","p50":"266.672ms","p90":"299.073ms","p99":"304.233ms","max":"304.475ms"} diff --git a/results/20260912T045131Z/lat-4p-5k-1k.txt b/results/20260912T045131Z/lat-4p-5k-1k.txt new file mode 100644 index 0000000..e22e578 --- /dev/null +++ b/results/20260912T045131Z/lat-4p-5k-1k.txt @@ -0,0 +1,2 @@ +=== lat-4p-5k-1k === +{"count":5000,"pubs":4,"size":1024,"min_us":155101,"avg_us":211706,"p50_us":217579,"p90_us":223268,"p99_us":227798,"max_us":228370,"min":"155.101ms","avg":"211.706ms","p50":"217.579ms","p90":"223.268ms","p99":"227.798ms","max":"228.370ms"} diff --git a/results/20260912T045131Z/lat-8p-20k-128.txt b/results/20260912T045131Z/lat-8p-20k-128.txt new file mode 100644 index 0000000..b445541 --- /dev/null +++ b/results/20260912T045131Z/lat-8p-20k-128.txt @@ -0,0 +1,2 @@ +=== lat-8p-20k-128 === +{"count":20000,"pubs":8,"size":128,"min_us":304586,"avg_us":453749,"p50_us":466296,"p90_us":499924,"p99_us":505112,"max_us":505620,"min":"304.586ms","avg":"453.749ms","p50":"466.296ms","p90":"499.924ms","p99":"505.112ms","max":"505.620ms"} diff --git a/results/20260912T045131Z/lat-ping-1k-128.txt b/results/20260912T045131Z/lat-ping-1k-128.txt new file mode 100644 index 0000000..4084dc8 --- /dev/null +++ b/results/20260912T045131Z/lat-ping-1k-128.txt @@ -0,0 +1,2 @@ +=== lat-ping-1k-128 === +{"count":1000,"pubs":1,"size":128,"min_us":254,"avg_us":307,"p50_us":286,"p90_us":332,"p99_us":734,"max_us":2763,"min":"0.254ms","avg":"0.307ms","p50":"0.286ms","p90":"0.332ms","p99":"0.734ms","max":"2.763ms"} diff --git a/results/20260912T051237Z/BENCH.md b/results/20260912T051237Z/BENCH.md new file mode 100644 index 0000000..9c58034 --- /dev/null +++ b/results/20260912T051237Z/BENCH.md @@ -0,0 +1,48 @@ +# NATS cluster message speed + +Run **`20260912T051237Z`** (UTC). Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only). + +Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +## Method + +- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack. +- **JetStream** uses **file** storage and **replicas=3** (same as product streams). The unique stream `benchstream` is deleted between JS loads. +- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay. +- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster. +- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. This is **queueing under burst**, not wire RTT. +- Probe: `scripts/latency.mjs` (two connections, header timestamp). + +## Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 776,331 | 502,502 | 61.34 | 420,976 | 51.39 | +| `core-4p4s-100k-128` | core pub/sub | 1,278,454 | 379,985 | 46.38 | 1,078,910 | 131.70 | +| `core-4p4s-50k-1k` | core pub/sub | 581,464 | 209,545 | 204.63 | 482,645 | 471.33 | +| `core-8p8s-200k-128` | core pub/sub | 2,065,217 | 333,731 | 40.74 | 1,847,242 | 225.49 | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 15,416 | — | — | — | — | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 14,985 | 14.63 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 17,986 | 2.20 | — | — | + +## Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.341ms | 0.470ms | 0.395ms | 0.651ms | 1.377ms | 3.243ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 132.425ms | 203.717ms | 204.458ms | 241.750ms | 249.468ms | 249.640ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 196.422ms | 221.264ms | 224.039ms | 233.180ms | 236.798ms | 238.177ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 211.108ms | 294.925ms | 300.254ms | 314.223ms | 315.551ms | 316.091ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 280.608ms | 414.972ms | 409.802ms | 513.821ms | 535.394ms | 536.084ms | + +## What the numbers mean + +Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about **16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers. + +A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers. + +Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`. diff --git a/results/20260912T051237Z/charts/core-throughput.png b/results/20260912T051237Z/charts/core-throughput.png new file mode 100644 index 0000000..44609c2 Binary files /dev/null and b/results/20260912T051237Z/charts/core-throughput.png differ diff --git a/results/20260912T051237Z/charts/core-vs-js.png b/results/20260912T051237Z/charts/core-vs-js.png new file mode 100644 index 0000000..dae2b58 Binary files /dev/null and b/results/20260912T051237Z/charts/core-vs-js.png differ diff --git a/results/20260912T051237Z/charts/delay-percentiles.png b/results/20260912T051237Z/charts/delay-percentiles.png new file mode 100644 index 0000000..f57cf44 Binary files /dev/null and b/results/20260912T051237Z/charts/delay-percentiles.png differ diff --git a/results/20260912T051237Z/charts/js-throughput.png b/results/20260912T051237Z/charts/js-throughput.png new file mode 100644 index 0000000..1edaebe Binary files /dev/null and b/results/20260912T051237Z/charts/js-throughput.png differ diff --git a/results/20260912T051237Z/charts/payload-size.png b/results/20260912T051237Z/charts/payload-size.png new file mode 100644 index 0000000..5c64356 Binary files /dev/null and b/results/20260912T051237Z/charts/payload-size.png differ diff --git a/results/20260912T051237Z/core-1p1s-50k-128.csv b/results/20260912T051237Z/core-1p1s-50k-128.csv new file mode 100644 index 0000000..41892fc --- /dev/null +++ b/results/20260912T051237Z/core-1p1s-50k-128.csv @@ -0,0 +1,3 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +h0r77ozVkEaFq9NntdbNXL,S0,50000,6400000,420976,53884945.437030,0.118772 +h0r77ozVkEaFq9NntdbNXL,P0,50000,6400000,502502,64320317.108811,0.099502 diff --git a/results/20260912T051237Z/core-1p1s-50k-128.txt b/results/20260912T051237Z/core-1p1s-50k-128.txt new file mode 100644 index 0000000..943bab6 --- /dev/null +++ b/results/20260912T051237Z/core-1p1s-50k-128.txt @@ -0,0 +1,7 @@ +=== core-1p1s-50k-128 === + +NATS Pub/Sub stats: 776,331 msgs/sec ~ 94.77 MB/sec + Pub stats: 502,502 msgs/sec ~ 61.34 MB/sec + Sub stats: 420,976 msgs/sec ~ 51.39 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/core-4p4s-100k-128.csv b/results/20260912T051237Z/core-4p4s-100k-128.csv new file mode 100644 index 0000000..5938158 --- /dev/null +++ b/results/20260912T051237Z/core-4p4s-100k-128.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +9lnpijWl8uftGl7cjgjfDG,S0,100000,12800000,338319,43304923.645655,0.295578 +9lnpijWl8uftGl7cjgjfDG,S1,100000,12800000,328136,42001487.567998,0.304751 +9lnpijWl8uftGl7cjgjfDG,S2,100000,12800000,319449,40889502.964037,0.313039 +9lnpijWl8uftGl7cjgjfDG,S3,100000,12800000,269727,34525150.333968,0.370744 +9lnpijWl8uftGl7cjgjfDG,P0,25000,3200000,184132,23569014.120587,0.135771 +9lnpijWl8uftGl7cjgjfDG,P1,25000,3200000,183847,23532419.588601,0.135983 +9lnpijWl8uftGl7cjgjfDG,P2,25000,3200000,100400,12851250.530089,0.249003 +9lnpijWl8uftGl7cjgjfDG,P3,25000,3200000,95281,12196082.439191,0.262379 diff --git a/results/20260912T051237Z/core-4p4s-100k-128.txt b/results/20260912T051237Z/core-4p4s-100k-128.txt new file mode 100644 index 0000000..773d5f7 --- /dev/null +++ b/results/20260912T051237Z/core-4p4s-100k-128.txt @@ -0,0 +1,17 @@ +=== core-4p4s-100k-128 === + +NATS Pub/Sub stats: 1,278,454 msgs/sec ~ 156.06 MB/sec + Pub stats: 379,985 msgs/sec ~ 46.38 MB/sec + [1] 184,132 msgs/sec ~ 22.48 MB/sec (25000 msgs) + [2] 183,847 msgs/sec ~ 22.44 MB/sec (25000 msgs) + [3] 100,400 msgs/sec ~ 12.26 MB/sec (25000 msgs) + [4] 95,281 msgs/sec ~ 11.63 MB/sec (25000 msgs) + min 95,281 | avg 140,915 | max 184,132 | stddev 43,112 msgs + Sub stats: 1,078,910 msgs/sec ~ 131.70 MB/sec + [1] 338,319 msgs/sec ~ 41.30 MB/sec (100000 msgs) + [2] 328,136 msgs/sec ~ 40.06 MB/sec (100000 msgs) + [3] 319,449 msgs/sec ~ 39.00 MB/sec (100000 msgs) + [4] 269,727 msgs/sec ~ 32.93 MB/sec (100000 msgs) + min 269,727 | avg 313,907 | max 338,319 | stddev 26,367 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/core-4p4s-50k-1k.csv b/results/20260912T051237Z/core-4p4s-50k-1k.csv new file mode 100644 index 0000000..ebabee0 --- /dev/null +++ b/results/20260912T051237Z/core-4p4s-50k-1k.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +TBolrRDh6R5ucnsIDRic4k,S0,50000,51200000,152508,156168868.728545,0.327850 +TBolrRDh6R5ucnsIDRic4k,S1,50000,51200000,143666,147114019.174715,0.348029 +TBolrRDh6R5ucnsIDRic4k,S2,50000,51200000,125714,128731399.676746,0.397727 +TBolrRDh6R5ucnsIDRic4k,S3,50000,51200000,123195,126152033.863128,0.405859 +TBolrRDh6R5ucnsIDRic4k,P0,12500,12800000,85005,87045293.746584,0.147050 +TBolrRDh6R5ucnsIDRic4k,P1,12500,12800000,84991,87030908.333567,0.147074 +TBolrRDh6R5ucnsIDRic4k,P2,12500,12800000,54739,56053364.168990,0.228354 +TBolrRDh6R5ucnsIDRic4k,P3,12500,12800000,52467,53726394.065295,0.238244 diff --git a/results/20260912T051237Z/core-4p4s-50k-1k.txt b/results/20260912T051237Z/core-4p4s-50k-1k.txt new file mode 100644 index 0000000..c34c617 --- /dev/null +++ b/results/20260912T051237Z/core-4p4s-50k-1k.txt @@ -0,0 +1,17 @@ +=== core-4p4s-50k-1k === + +NATS Pub/Sub stats: 581,464 msgs/sec ~ 567.84 MB/sec + Pub stats: 209,545 msgs/sec ~ 204.63 MB/sec + [1] 85,005 msgs/sec ~ 83.01 MB/sec (12500 msgs) + [2] 84,991 msgs/sec ~ 83.00 MB/sec (12500 msgs) + [3] 54,739 msgs/sec ~ 53.46 MB/sec (12500 msgs) + [4] 52,467 msgs/sec ~ 51.24 MB/sec (12500 msgs) + min 52,467 | avg 69,300 | max 85,005 | stddev 15,718 msgs + Sub stats: 482,645 msgs/sec ~ 471.33 MB/sec + [1] 152,508 msgs/sec ~ 148.93 MB/sec (50000 msgs) + [2] 143,666 msgs/sec ~ 140.30 MB/sec (50000 msgs) + [3] 125,714 msgs/sec ~ 122.77 MB/sec (50000 msgs) + [4] 123,195 msgs/sec ~ 120.31 MB/sec (50000 msgs) + min 123,195 | avg 136,270 | max 152,508 | stddev 12,255 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/core-8p8s-200k-128.csv b/results/20260912T051237Z/core-8p8s-200k-128.csv new file mode 100644 index 0000000..4d6dc8b --- /dev/null +++ b/results/20260912T051237Z/core-8p8s-200k-128.csv @@ -0,0 +1,17 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +1wXmYbQfeZKgGIuRNRJWE9,S0,200000,25600000,294783,37732336.345174,0.678463 +1wXmYbQfeZKgGIuRNRJWE9,S1,200000,25600000,285956,36602476.046553,0.699406 +1wXmYbQfeZKgGIuRNRJWE9,S2,200000,25600000,270995,34687374.508938,0.738021 +1wXmYbQfeZKgGIuRNRJWE9,S3,200000,25600000,268481,34365602.744388,0.744931 +1wXmYbQfeZKgGIuRNRJWE9,S4,200000,25600000,242749,31071912.954289,0.823895 +1wXmYbQfeZKgGIuRNRJWE9,S5,200000,25600000,243757,31200898.690210,0.820489 +1wXmYbQfeZKgGIuRNRJWE9,S6,200000,25600000,232306,29735268.576473,0.860931 +1wXmYbQfeZKgGIuRNRJWE9,S7,200000,25600000,231301,29606645.928715,0.864671 +1wXmYbQfeZKgGIuRNRJWE9,P0,25000,3200000,161768,20706321.217329,0.154542 +1wXmYbQfeZKgGIuRNRJWE9,P1,25000,3200000,156790,20069204.386621,0.159448 +1wXmYbQfeZKgGIuRNRJWE9,P2,25000,3200000,101477,12989116.865479,0.246360 +1wXmYbQfeZKgGIuRNRJWE9,P3,25000,3200000,96840,12395608.110541,0.258156 +1wXmYbQfeZKgGIuRNRJWE9,P4,25000,3200000,48401,6195444.884723,0.516509 +1wXmYbQfeZKgGIuRNRJWE9,P5,25000,3200000,56695,7256990.291363,0.440954 +1wXmYbQfeZKgGIuRNRJWE9,P6,25000,3200000,42589,5451476.161959,0.586997 +1wXmYbQfeZKgGIuRNRJWE9,P7,25000,3200000,41987,5374439.609281,0.595411 diff --git a/results/20260912T051237Z/core-8p8s-200k-128.txt b/results/20260912T051237Z/core-8p8s-200k-128.txt new file mode 100644 index 0000000..c825366 --- /dev/null +++ b/results/20260912T051237Z/core-8p8s-200k-128.txt @@ -0,0 +1,25 @@ +=== core-8p8s-200k-128 === + +NATS Pub/Sub stats: 2,065,217 msgs/sec ~ 252.10 MB/sec + Pub stats: 333,731 msgs/sec ~ 40.74 MB/sec + [1] 161,768 msgs/sec ~ 19.75 MB/sec (25000 msgs) + [2] 156,790 msgs/sec ~ 19.14 MB/sec (25000 msgs) + [3] 101,477 msgs/sec ~ 12.39 MB/sec (25000 msgs) + [4] 96,840 msgs/sec ~ 11.82 MB/sec (25000 msgs) + [5] 48,401 msgs/sec ~ 5.91 MB/sec (25000 msgs) + [6] 56,695 msgs/sec ~ 6.92 MB/sec (25000 msgs) + [7] 42,589 msgs/sec ~ 5.20 MB/sec (25000 msgs) + [8] 41,987 msgs/sec ~ 5.13 MB/sec (25000 msgs) + min 41,987 | avg 88,318 | max 161,768 | stddev 46,314 msgs + Sub stats: 1,847,242 msgs/sec ~ 225.49 MB/sec + [1] 294,783 msgs/sec ~ 35.98 MB/sec (200000 msgs) + [2] 285,956 msgs/sec ~ 34.91 MB/sec (200000 msgs) + [3] 270,995 msgs/sec ~ 33.08 MB/sec (200000 msgs) + [4] 268,481 msgs/sec ~ 32.77 MB/sec (200000 msgs) + [5] 242,749 msgs/sec ~ 29.63 MB/sec (200000 msgs) + [6] 243,757 msgs/sec ~ 29.76 MB/sec (200000 msgs) + [7] 232,306 msgs/sec ~ 28.36 MB/sec (200000 msgs) + [8] 231,301 msgs/sec ~ 28.24 MB/sec (200000 msgs) + min 231,301 | avg 258,791 | max 294,783 | stddev 22,958 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/host-after.txt b/results/20260912T051237Z/host-after.txt new file mode 100644 index 0000000..c9898c8 --- /dev/null +++ b/results/20260912T051237Z/host-after.txt @@ -0,0 +1,86 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T05:14:00Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=8.67 8.33 8.15 9/3863 4114865 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.3Gi 21Gi 339Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 2 +hostname: verae-px-worker +memory: 2048 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 1 +hostname: nats-a +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 1 +hostname: nats-b +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 1 +hostname: nats-c +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T051237Z/host-before.txt b/results/20260912T051237Z/host-before.txt new file mode 100644 index 0000000..89ca238 --- /dev/null +++ b/results/20260912T051237Z/host-before.txt @@ -0,0 +1,86 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T05:12:37Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=8.59 8.39 8.15 5/3843 4096277 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.2Gi 21Gi 339Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 2 +hostname: verae-px-worker +memory: 2048 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 1 +hostname: nats-a +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 1 +hostname: nats-b +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 1 +hostname: nats-c +memory: 1024 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T051237Z/js-1p-20k-128-r3.csv b/results/20260912T051237Z/js-1p-20k-128-r3.csv new file mode 100644 index 0000000..9752dd6 --- /dev/null +++ b/results/20260912T051237Z/js-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +8rEl4zHbyuPY6Hq62w9Qsw,P0,40000,2560000,7393,946342.174329,2.705153 diff --git a/results/20260912T051237Z/js-1p-20k-128-r3.txt b/results/20260912T051237Z/js-1p-20k-128-r3.txt new file mode 100644 index 0000000..01e01af --- /dev/null +++ b/results/20260912T051237Z/js-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-1p-20k-128-r3 === + +Pub stats: 7,393 msgs/sec ~ 924.16 KB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/js-2p2s-20k-128-r3.csv b/results/20260912T051237Z/js-2p2s-20k-128-r3.csv new file mode 100644 index 0000000..fa3f7fa --- /dev/null +++ b/results/20260912T051237Z/js-2p2s-20k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +VI7cqfC8hjqPoKek3p2a06,S0,20150,1280000,4248,543810.859674,2.353760 +VI7cqfC8hjqPoKek3p2a06,S1,20159,1280000,3858,493875.492651,2.591746 +VI7cqfC8hjqPoKek3p2a06,P0,20000,1280000,11290,1445228.034916,0.885673 +VI7cqfC8hjqPoKek3p2a06,P1,20000,1280000,3864,494600.778891,2.587946 diff --git a/results/20260912T051237Z/js-2p2s-20k-128-r3.txt b/results/20260912T051237Z/js-2p2s-20k-128-r3.txt new file mode 100644 index 0000000..bfcca29 --- /dev/null +++ b/results/20260912T051237Z/js-2p2s-20k-128-r3.txt @@ -0,0 +1,13 @@ +=== js-2p2s-20k-128-r3 === + +NATS Pub/Sub stats: 15,416 msgs/sec ~ 1.88 MB/sec + Pub stats: 7,728 msgs/sec ~ 966.02 KB/sec + [1] 11,290 msgs/sec ~ 1.38 MB/sec (10000 msgs) + [2] 3,864 msgs/sec ~ 483.01 KB/sec (10000 msgs) + min 3,864 | avg 7,577 | max 11,290 | stddev 3,713 msgs + Sub stats: 7,711 msgs/sec ~ 963.88 KB/sec + [1] 4,248 msgs/sec ~ 531.07 KB/sec (10000 msgs) + [2] 3,858 msgs/sec ~ 482.30 KB/sec (10000 msgs) + min 3,858 | avg 4,053 | max 4,248 | stddev 195 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/js-4p-20k-1k-r3.csv b/results/20260912T051237Z/js-4p-20k-1k-r3.csv new file mode 100644 index 0000000..4b305e7 --- /dev/null +++ b/results/20260912T051237Z/js-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +InppzTmEhsFOG1VYCh0Nle,P0,10000,5120000,4792,4907526.298214,1.043295 +InppzTmEhsFOG1VYCh0Nle,P1,10000,5120000,4480,4587592.638694,1.116054 +InppzTmEhsFOG1VYCh0Nle,P2,10000,5120000,3792,3883639.365215,1.318351 +InppzTmEhsFOG1VYCh0Nle,P3,10000,5120000,3750,3840425.475938,1.333186 diff --git a/results/20260912T051237Z/js-4p-20k-1k-r3.txt b/results/20260912T051237Z/js-4p-20k-1k-r3.txt new file mode 100644 index 0000000..0e35ef9 --- /dev/null +++ b/results/20260912T051237Z/js-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-20k-1k-r3 === + +Pub stats: 14,985 msgs/sec ~ 14.63 MB/sec + [1] 4,792 msgs/sec ~ 4.68 MB/sec (5000 msgs) + [2] 4,480 msgs/sec ~ 4.38 MB/sec (5000 msgs) + [3] 3,792 msgs/sec ~ 3.70 MB/sec (5000 msgs) + [4] 3,750 msgs/sec ~ 3.66 MB/sec (5000 msgs) + min 3,750 | avg 4,203 | max 4,792 | stddev 446 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/js-4p-50k-128-r3.csv b/results/20260912T051237Z/js-4p-50k-128-r3.csv new file mode 100644 index 0000000..43e6f9a --- /dev/null +++ b/results/20260912T051237Z/js-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +JeTysfxECHCJccBaGH4xXX,P0,25000,1600000,5640,721943.982931,2.216238 +JeTysfxECHCJccBaGH4xXX,P1,25000,1600000,5642,722187.413742,2.215491 +JeTysfxECHCJccBaGH4xXX,P2,25000,1600000,5056,647242.012627,2.472027 +JeTysfxECHCJccBaGH4xXX,P3,25000,1600000,4497,575730.101666,2.779080 diff --git a/results/20260912T051237Z/js-4p-50k-128-r3.txt b/results/20260912T051237Z/js-4p-50k-128-r3.txt new file mode 100644 index 0000000..676949a --- /dev/null +++ b/results/20260912T051237Z/js-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-50k-128-r3 === + +Pub stats: 17,986 msgs/sec ~ 2.20 MB/sec + [1] 5,640 msgs/sec ~ 705.02 KB/sec (12500 msgs) + [2] 5,642 msgs/sec ~ 705.26 KB/sec (12500 msgs) + [3] 5,056 msgs/sec ~ 632.07 KB/sec (12500 msgs) + [4] 4,497 msgs/sec ~ 562.24 KB/sec (12500 msgs) + min 4,497 | avg 5,208 | max 5,642 | stddev 475 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T051237Z/lat-1p-5k-128.txt b/results/20260912T051237Z/lat-1p-5k-128.txt new file mode 100644 index 0000000..709e775 --- /dev/null +++ b/results/20260912T051237Z/lat-1p-5k-128.txt @@ -0,0 +1,2 @@ +=== lat-1p-5k-128 === +{"count":5000,"pubs":1,"size":128,"mode":"flood","min_us":132425,"avg_us":203717,"p50_us":204458,"p90_us":241750,"p99_us":249468,"max_us":249640,"min":"132.425ms","avg":"203.717ms","p50":"204.458ms","p90":"241.750ms","p99":"249.468ms","max":"249.640ms"} diff --git a/results/20260912T051237Z/lat-4p-10k-128.txt b/results/20260912T051237Z/lat-4p-10k-128.txt new file mode 100644 index 0000000..fefb67c --- /dev/null +++ b/results/20260912T051237Z/lat-4p-10k-128.txt @@ -0,0 +1,2 @@ +=== lat-4p-10k-128 === +{"count":10000,"pubs":4,"size":128,"mode":"flood","min_us":211108,"avg_us":294925,"p50_us":300254,"p90_us":314223,"p99_us":315551,"max_us":316091,"min":"211.108ms","avg":"294.925ms","p50":"300.254ms","p90":"314.223ms","p99":"315.551ms","max":"316.091ms"} diff --git a/results/20260912T051237Z/lat-4p-5k-1k.txt b/results/20260912T051237Z/lat-4p-5k-1k.txt new file mode 100644 index 0000000..4464289 --- /dev/null +++ b/results/20260912T051237Z/lat-4p-5k-1k.txt @@ -0,0 +1,2 @@ +=== lat-4p-5k-1k === +{"count":5000,"pubs":4,"size":1024,"mode":"flood","min_us":196422,"avg_us":221264,"p50_us":224039,"p90_us":233180,"p99_us":236798,"max_us":238177,"min":"196.422ms","avg":"221.264ms","p50":"224.039ms","p90":"233.180ms","p99":"236.798ms","max":"238.177ms"} diff --git a/results/20260912T051237Z/lat-8p-20k-128.txt b/results/20260912T051237Z/lat-8p-20k-128.txt new file mode 100644 index 0000000..04dc29d --- /dev/null +++ b/results/20260912T051237Z/lat-8p-20k-128.txt @@ -0,0 +1,2 @@ +=== lat-8p-20k-128 === +{"count":20000,"pubs":8,"size":128,"mode":"flood","min_us":280608,"avg_us":414972,"p50_us":409802,"p90_us":513821,"p99_us":535394,"max_us":536084,"min":"280.608ms","avg":"414.972ms","p50":"409.802ms","p90":"513.821ms","p99":"535.394ms","max":"536.084ms"} diff --git a/results/20260912T051237Z/lat-ping-1k-128.txt b/results/20260912T051237Z/lat-ping-1k-128.txt new file mode 100644 index 0000000..c9bca7f --- /dev/null +++ b/results/20260912T051237Z/lat-ping-1k-128.txt @@ -0,0 +1,2 @@ +=== lat-ping-1k-128 === +{"count":1000,"pubs":1,"size":128,"mode":"ping","min_us":341,"avg_us":470,"p50_us":395,"p90_us":651,"p99_us":1377,"max_us":3243,"min":"0.341ms","avg":"0.470ms","p50":"0.395ms","p90":"0.651ms","p99":"1.377ms","max":"3.243ms"} diff --git a/results/20260912T051237Z/nats-cluster-bench-ns1.html b/results/20260912T051237Z/nats-cluster-bench-ns1.html new file mode 100644 index 0000000..59b896e --- /dev/null +++ b/results/20260912T051237Z/nats-cluster-bench-ns1.html @@ -0,0 +1,1291 @@ + + + + + + + NATS cluster message speed — NS1 host study + + + + +
Verae Time × Zapier · progress report · run on NS1.GEORGELAMBERT.ORG

NATS cluster message speed — NS1 host study

packages/zapier-decisions/reports/nats-cluster-bench-ns1.md
+
+

NATS cluster message speed — NS1 host study

+
+

Progress report (second study) · run +20260912T051237Z (UTC)

+
+

Execution provenance. Every process for this study +ran on NS1.GEORGELAMBERT.ORG +(70.88.205.138): the orchestrator +(study-on-ns1.sh), nats bench, +latency.mjs (inside LXC 510 on this hypervisor), charting +(matplotlib), and HTML/PDF (pandoc + +weasyprint). The operator laptop did not +publish, subscribe, draw charts, or render the PDF. Traffic stayed on +vmbr1 from LXC 510 to +nats-a/b/c (511–513).

+
+

This is a full methodology write-up plus the numbers from that +on-host run. The earlier report (nats-cluster-bench, run +20260912T045131Z) used the same cluster but was +orchestrated and rendered off-box. Use this document +when you need “it was all run on 138.”

+
+

1. Executive summary

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemThis NS1-host run
Control planeNS1.GEORGELAMBERT.ORG (70.88.205.138), user +marchon
Bench clientLXC 510 verae-px-worker
BrokersLXC 511/512/513 nats-a/b/c on +10.10.10.21–23
Client URLnats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222
Host load before8.59 8.39 8.15 5/3843 4096277
Host load after8.67 8.33 8.15 9/3863 4114865
Core 1p1s 128 B pub502,502 msgs/s
JetStream 1p 128 B r=37,393 durable pubs/s
Ping p50 / p990.395ms / 1.377ms
+

Product traffic is the JetStream row. Ping is one-message delay. +Flood is mailbox catch-up after a burst.

+
+

2. Where it ran (and where it +did not)

+
Operator laptop  ──ssh──►  NS1.GEORGELAMBERT.ORG  70.88.205.138
+                              study-on-ns1.sh
+                              python3 build-ns1-study-report.py
+                              sudo pct exec 510 ──► nats bench / latency.mjs
+                                                    │
+                                                    ▼ vmbr1
+                                              10.10.10.21-23 :4222
+
    +
  • Did run on 138: bash, python3, matplotlib, pandoc, +weasyprint, pct, nats-server (in LXC), nats CLI and Node +(in LXC 510).
  • +
  • Did not run on the laptop: no local +nats bench, no local charting, no local WeasyPrint for this +file.
  • +
+
+

3. Results (this run)

+

Host and brokers

+

Before

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a51121,721,2034,933,7591126,398,720True
nats-b51221,613,2053,513,0960123,097,344True
nats-c51321,760,9734,048,5822123,867,392True
+

After

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a51122,097,5965,910,1611138,273,024True
nats-b51222,121,8164,746,5391141,361,408True
nats-c51322,241,3665,053,9781138,162,432True
+

nproc=40 · +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux

+

Throughput

+ +++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunModeAggregate msgs/sPub msgs/sPub MB/sSub msgs/sSub MB/s
core-1p1s-50k-128core pub/sub776,331502,50261.34420,97651.39
core-4p4s-100k-128core pub/sub1,278,454379,98546.381,078,910131.70
core-4p4s-50k-1kcore pub/sub581,464209,545204.63482,645471.33
core-8p8s-200k-128core pub/sub2,065,217333,73140.741,847,242225.49
js-1p-20k-128-r3jetstream r=3 file7,3930.90
js-2p2s-20k-128-r3jetstream r=3 file15,4167,7280.947,7110.94
js-4p-20k-1k-r3jetstream r=3 file14,98514.63
js-4p-50k-128-r3jetstream r=3 file17,9862.20
+

Round-trip delay

+ +++++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunKindCountPubsSizeminavgp50p90p99max
lat-ping-1k-128ping (sequential RTT)10001128 B0.341ms0.470ms0.395ms0.651ms1.377ms3.243ms
lat-1p-5k-128flood (burst queueing)50001128 B132.425ms203.717ms204.458ms241.750ms249.468ms249.640ms
lat-4p-5k-1kflood (burst queueing)500041024 B196.422ms221.264ms224.039ms233.180ms236.798ms238.177ms
lat-4p-10k-128flood (burst queueing)100004128 B211.108ms294.925ms300.254ms314.223ms315.551ms316.091ms
lat-8p-20k-128flood (burst queueing)200008128 B280.608ms414.972ms409.802ms513.821ms535.394ms536.084ms
+

Core NATS

+
+ + +
+

Core NATS throughput at four loads (NS1 host run) ### +Payload size (core)

+
+ + +
+

Core NATS 128 B vs 1 KiB (NS1 host run) ### JetStream r=3 +file

+
+ + +
+

JetStream durable publish rate (NS1 host run) ### Core vs +JetStream

+
+ + +
+

Core vs JetStream publish rate, log scale (NS1 host run) ### +Delay

+
+ + +
+

Ping vs flood delay percentiles, log scale (NS1 host +run)

+
+

4. Study methodology

+

4.1 Question

+

On the NS1 test stand, what message throughput and +delay does the three-node verae JetStream +cluster deliver at several loads, and which part of the stack is the +limiter for product traffic (jobs, events, webhooks, archive)?

+

4.2 Hypotheses (stated before +the run)

+
    +
  1. H1 — Core vs JetStream. Fire-and-forget core NATS +is at least an order of magnitude faster than JetStream file + +replicas=3, because durable publish waits for a majority disk +replica.
  2. +
  3. H2 — JetStream parallelism. Adding publishers does +not linearly increase JetStream write rate once the +replica log is saturated.
  4. +
  5. H3 — Quiet delay. Sequential pub→sub round trip on +vmbr1 is well under 1 ms p99 when the consumer is +waiting.
  6. +
  7. H4 — Burst delay. If publishers dump a batch before +the subscriber drains, observed delay is queueing time, +roughly linear in backlog, not in cluster hop count.
  8. +
  9. H5 — Payload. Moving 128 B → 1 KiB lowers message +rate and raises byte rate on core NATS; JetStream in this size band +stays replica/fsync bound.
  10. +
+

4.3 Independent variables +(what we changed)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FactorLevels
TransportCore NATS pub/sub vs JetStream file replicas=3
Publisher count1, 2, 4, 8
Subscriber count0 (JS publish-only), 1, 2, 4, 8
Message count1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step)
Payload128 B, 1024 B
Delay modeping (publish, wait, repeat) vs +flood (publish all, then drain)
+

4.4 Dependent variables +(what we recorded)

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricInstrumentUnit
Publish ratenats bench 0.1.6 Pub statsmsgs/s, MB/s
Subscribe ratenats bench Sub statsmsgs/s, MB/s
Aggregatenats bench NATS Pub/Sub statsmsgs/s (fan-out counts both sides)
Publisher spreadnats min/avg/max msgs/snot delay
One-way-ish RTTlatency.mjs header timestampmin, avg, p50, p90, p99, max
Host load/proc/loadavg before and afterload average
Broker countershttp://127.0.0.1:8222/varz inside each nats LXCconnections, in/out msgs, cpu, mem
+

Important: nats CLI 0.1.6 min/avg/max are +rate spread across publishers, not microseconds of +delay. Delay is only latency.mjs.

+

4.5 Controls and constants

+
    +
  • Cluster name verae, three routes, client +:4222, cluster :6222, monitor loopback +:8222.
  • +
  • Client URL always the three-node list on vmbr1 (never +host 127.0.0.1:4222, never vmbr0).
  • +
  • Bench client is LXC 510, not a nats-* server.
  • +
  • JetStream bench stream name benchstream, +file storage, replicas=3, deleted +between JS loads (nats stream rm --force) so names do not +collide.
  • +
  • Product streams were not the bench target (no load +test on ZAPIER_* / VERAE_ARCHIVE).
  • +
  • No TLS, no nkeys, no account isolation (isolation is +vmbr1).
  • +
  • Same nats CLI version (0.1.6) and nats@2 Node client as +the first ladder.
  • +
+

4.6 Procedure

+
    +
  1. Confirm this script is executing on +NS1.GEORGELAMBERT.ORG. Refuse otherwise.
  2. +
  3. Snapshot host load, memory, LXC configs, and each nats +varz.
  4. +
  5. From NS1, pct exec 510 the core ladder (1p1s, 4p4s, +8p8s at 128 B; 4p4s at 1 KiB).
  6. +
  7. Delete benchstream; JS ladder (1p, 4p, 4p×1 KiB, 2p2s +pull) at replicas=3 file.
  8. +
  9. Copy latency.mjs into 510; ping then flood at several +batch sizes.
  10. +
  11. Snapshot host/varz again.
  12. +
  13. Parse logs on this host; draw charts; write HTML +and PDF on this host.
  14. +
+

No publish, subscribe, chart, or PDF process runs on the operator +laptop for this study.

+

4.7 Instrumentation path

+
[NS1 host 70.88.205.138]
+   study-on-ns1.sh  (bash + python3)
+        |
+        | sudo pct exec 510
+        v
+[LXC 510 verae-px-worker 10.10.10.20]
+   nats bench / node latency.mjs
+        |
+        | NATS client protocol to
+        v
+[LXC 511/512/513  10.10.10.21-23 :4222]
+   nats-server -js  cluster routes :6222
+

The hypervisor issues the guest commands. The messages themselves +never leave vmbr1.

+

4.8 Threats to validity

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThreatEffect on numbers
One physical hostThree “replicas” share CPU, memory, and usually the same datastore. +This measures process/LXC HA, not disk HA.
Shared loadNS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. +Load average during a run is part of the result, not noise to +ignore.
Single bench clientAll publishers live in 510. Per-publisher rate spread is contention +in that guest.
Short runsSeconds of traffic. No compaction, no multi-hour page-cache +eviction, no snapshot during load.
No TLS/nkeysProduction auth will cost CPU. Do not treat these rates as +post-nkeys rates.
Fan-out aggregateCore aggregate msgs/s counts pub+sub. Do not compare that column to +JetStream unique writes.
Flood ≠ RTTMixing flood averages with ping p99 produces a fake “NATS is slow” +story.
Lab onlyNot a Zapier HTTPS bench and not live +api.veraetime.net.
+

4.9 Ethics / safety

+

Bench uses throwaway subjects (bench.core.*, +bench.js.*, bench.lat.*) and a throwaway +stream. It does not purge product streams. Zapier cloud has no NATS +socket.

+
+

5. Suggestions for fine-tuning

+

These follow from the method and from the first ladder on this stand +(JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of +ms). Apply in order of leverage. Re-run this NS1 study +after each change so the delta is measured the same way.

+

5.1 Treat JetStream as +the product limiter

+

Product jobs/events/webhooks/archive are durable. Tuning core NATS to +2M msgs/s will not move a timestamp Zap. Put effort into replica +write path and consumer lag, not core +fan-out.

+

5.2 Split storage class by +stream

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StreamSuggested storeWhy
ZAPIER_JOBSfile, r=3Work queue; lose-a-job is bad
ZAPIER_EVENTSfile r=3, or memory r=3 if events are rebuildable from job +statusHot waiters; measure both
ZAPIER_WEBHOOKSfile, r=3, workqueueHTTPS to Zapier is the slow consumer
ZAPIER_USAGEfile, r=3, limits + max-ageTelemetry
VERAE_ARCHIVEfile, r=3, on the best diskPuts are larger and must survive
+

Try ZAPIER_EVENTS as memory store in a maintenance +window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms +and durable events still ack at a higher rate, keep it; if a CT restart +drops in-flight waiters, revert.

+

5.3 Give JetStream real disks

+

Today r=3 on three LXC guests on one Proxmox host is +three files, one failure domain.

+
    +
  • Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) +into each nats LXC store_dir.
  • +
  • Set sync: always only on archive if you need it; +default sync is often enough for jobs and is faster. Measure.
  • +
  • Do not put JetStream store_dir on the same busy rootfs +as Forgejo/Caddy if we can avoid it.
  • +
  • When moving to three metal boxes: same configs, private NIC, one +disk (or mirror) per node. That is the first change +that makes r=3 mean “two boxes can die.”
  • +
+

5.4 Isolate the nats +CTs from the rest of NS1

+

Host load on this box is often already several. Pin:

+
    +
  • nats-a/b/c: dedicated cores, no steal from keep/fleet +Node processes.
  • +
  • Memory high enough that file-backed streams stay cache-hot for the +working set.
  • +
  • cpuunits / cpuset in pct config so a +Zapier-facing Node GC pause does not stall fsync.
  • +
+

Re-run this study after pinning; H1/H2 should move more than +ping.

+

5.5 Consumer and mailbox +tuning (delay H4)

+

Flood delay is backlog / consume_rate. Fine-tune the +waiters, not the broker RTT.

+
    +
  • jobs.events and webhooks.deliver: raise +max_ack_pending so a slow HTTPS hook does not stall the +whole consumer; cap it so a poison message cannot unbounded-buffer +RAM.
  • +
  • Pull consumers: larger batch, shorter expires, more +pullers horizontally (fleet replica floors) instead of one fat +subscriber.
  • +
  • Middleware should not flood-publish then wait; it +already does per-job publish. Keep that. The flood test is the outage +profile when a consumer is stopped.
  • +
  • Alert on consumer lag (pending + ack pending) from +JetStream, not on ping RTT.
  • +
+

5.6 Publisher-side +batching in middleware

+

A timestamp job is one small JSON. 16k msgs/s is ample. Still:

+
    +
  • Avoid per-byte publishes; one message per job/event.
  • +
  • Reuse NATS connections (connection churn showed up as publisher +spread in the core 4p/8p runs).
  • +
  • Idempotent msg id / duplicate window sized to Verae +retry window, not default-only.
  • +
+

5.7 +nats-server knobs worth measuring (A/B with this script)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KnobWhy try it
max_payloadKeep default unless archive puts grow
write_deadlineSlow consumer protection for webhooks
max_pendingBound memory on a stuck Zapier hook
max_connectionsFleet workers + keep + middleware
JetStream max_file_store / +max_memory_storePrevent one stream from filling the CT
max_outstanding_catchupReplica restart after a nats-c blip
GOMAXPROCS = LXC coresDo not overthread a 2-core CT
+

Change one knob, re-run +study-on-ns1.sh, compare JetStream 1p 128 B and ping +p99.

+

5.8 Network

+
    +
  • Keep NATS off vmbr0. No change.
  • +
  • When on metal: dedicated NIC or VLAN for cluster :6222 +vs client :4222 if possible (replication vs client +load).
  • +
  • Check virtio queue counts on the LXC nics if core 1 KiB byte rate +plateaus.
  • +
+

5.9 Security cost (when +nkeys/mTLS flip)

+

verae-nats-accounts is still a sketch. Enabling accounts +will add CPU on publish. Budget: re-run this exact study +after creds are in every NATS_URL, and +accept a drop on both core and JS. Do not flip without that +measurement.

+

5.10 Operational +fine-tuning (lag, not peak msgs/s)

+
    +
  1. Scrape varz / jsz from the host over +vmbr1 (not public). Monitor loopback :8222 is +invisible to Prometheus on NS1 unless we add a host-side proxy on +10.10.10.21:8222 bound only to vmbr1.
  2. +
  3. Keep replica floors for webhook-deliver and job-poller — they are +the flood defense.
  4. +
  5. Backup/restore drill of JetStream during idle, then +a short JS 1p run to see catchup cost.
  6. +
  7. A 15–30 minute soak (not in this ladder) for page cache and +compaction; add that as a third study when disks are dedicated.
  8. +
+

5.11 What not to tune

+
    +
  • Do not chase core 8p8s aggregate. It is fan-out on a lab +bridge.
  • +
  • Do not treat flood 400 ms as “cluster RTT.” Fix consumers.
  • +
  • Do not load-test on ZAPIER_* streams.
  • +
  • Do not bind client NATS to 0.0.0.0 on +vmbr0.
  • +
+ +
    +
  1. CPU pin nats-a/b/c → re-run JS 1p + ping.
  2. +
  3. ZAPIER_EVENTS-shaped memory stream vs file (throwaway +stream, same flags as this JS ladder).
  4. +
  5. Distinct store_dir disks per node.
  6. +
  7. nkeys on, same ladder.
  8. +
  9. Three hardware boxes, same cluster.env IPs +updated.
  10. +
+

Each experiment should produce a new +results/<utc>/ on NS1 and a new progress-repo report +so we can diff H1–H5 instead of arguing from memory.

+
+

6. Reproducing this study

+

On NS1 only:

+
cd ~/verae-src/verae-nats-cluster
+bash scripts/study-on-ns1.sh
+

The script exits if hostname is not NS1. Outputs land in +results/<utc>/ including +nats-cluster-bench-ns1.{md,html,pdf} and +charts/. Copy those into +zapier-decisions/reports/ for the progress repo and +catalog.

+

Raw logs for this run: results/20260912T051237Z/.

+ + diff --git a/results/20260912T051237Z/nats-cluster-bench-ns1.md b/results/20260912T051237Z/nats-cluster-bench-ns1.md new file mode 100644 index 0000000..cf0011d --- /dev/null +++ b/results/20260912T051237Z/nats-cluster-bench-ns1.md @@ -0,0 +1,334 @@ +**Progress report (second study)** · run `20260912T051237Z` (UTC) + +> **Execution provenance.** Every process for this study ran on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`): the orchestrator (`study-on-ns1.sh`), `nats bench`, `latency.mjs` (inside LXC 510 on this hypervisor), charting (`matplotlib`), and HTML/PDF (`pandoc` + `weasyprint`). The operator laptop did **not** publish, subscribe, draw charts, or render the PDF. Traffic stayed on `vmbr1` from LXC **510** to `nats-a/b/c` (**511–513**). + +This is a full methodology write-up plus the numbers from that on-host run. The earlier report (`nats-cluster-bench`, run `20260912T045131Z`) used the same cluster but was **orchestrated and rendered off-box**. Use this document when you need “it was all run on 138.” + +--- + +## 1. Executive summary + +| Item | This NS1-host run | +|------|-------------------| +| Control plane | NS1.GEORGELAMBERT.ORG (`70.88.205.138`), user `marchon` | +| Bench client | LXC 510 `verae-px-worker` | +| Brokers | LXC 511/512/513 `nats-a/b/c` on `10.10.10.21–23` | +| Client URL | `nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222` | +| Host load before | `8.59 8.39 8.15 5/3843 4096277` | +| Host load after | `8.67 8.33 8.15 9/3863 4114865` | +| Core 1p1s 128 B pub | 502,502 msgs/s | +| JetStream 1p 128 B r=3 | 7,393 durable pubs/s | +| Ping p50 / p99 | 0.395ms / 1.377ms | + +Product traffic is the JetStream row. Ping is one-message delay. Flood is mailbox catch-up after a burst. + +--- + +## 2. Where it ran (and where it did not) + +```text +Operator laptop ──ssh──► NS1.GEORGELAMBERT.ORG 70.88.205.138 + study-on-ns1.sh + python3 build-ns1-study-report.py + sudo pct exec 510 ──► nats bench / latency.mjs + │ + ▼ vmbr1 + 10.10.10.21-23 :4222 +``` + +- **Did run on 138:** bash, python3, matplotlib, pandoc, weasyprint, `pct`, nats-server (in LXC), nats CLI and Node (in LXC 510). +- **Did not run on the laptop:** no local `nats bench`, no local charting, no local WeasyPrint for this file. + +--- + +## 3. Results (this run) + +### Host and brokers + +**Before** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 2 | 1,721,203 | 4,933,759 | 1 | 1 | 26,398,720 | True | +| nats-b | 512 | 2 | 1,613,205 | 3,513,096 | 0 | 1 | 23,097,344 | True | +| nats-c | 513 | 2 | 1,760,973 | 4,048,582 | 2 | 1 | 23,867,392 | True | + +**After** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 2 | 2,097,596 | 5,910,161 | 1 | 1 | 38,273,024 | True | +| nats-b | 512 | 2 | 2,121,816 | 4,746,539 | 1 | 1 | 41,361,408 | True | +| nats-c | 513 | 2 | 2,241,366 | 5,053,978 | 1 | 1 | 38,162,432 | True | + +nproc=40 · uname=`Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux` + +### Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 776,331 | 502,502 | 61.34 | 420,976 | 51.39 | +| `core-4p4s-100k-128` | core pub/sub | 1,278,454 | 379,985 | 46.38 | 1,078,910 | 131.70 | +| `core-4p4s-50k-1k` | core pub/sub | 581,464 | 209,545 | 204.63 | 482,645 | 471.33 | +| `core-8p8s-200k-128` | core pub/sub | 2,065,217 | 333,731 | 40.74 | 1,847,242 | 225.49 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 7,393 | 0.90 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 15,416 | 7,728 | 0.94 | 7,711 | 0.94 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 14,985 | 14.63 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 17,986 | 2.20 | — | — | + +### Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.341ms | 0.470ms | 0.395ms | 0.651ms | 1.377ms | 3.243ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 132.425ms | 203.717ms | 204.458ms | 241.750ms | 249.468ms | 249.640ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 196.422ms | 221.264ms | 224.039ms | 233.180ms | 236.798ms | 238.177ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 211.108ms | 294.925ms | 300.254ms | 314.223ms | 315.551ms | 316.091ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 280.608ms | 414.972ms | 409.802ms | 513.821ms | 535.394ms | 536.084ms | + +### Core NATS + +![Core NATS throughput at four loads (NS1 host run)](charts/core-throughput.png) + +*Core NATS throughput at four loads (NS1 host run)* +### Payload size (core) + +![Core NATS 128 B vs 1 KiB (NS1 host run)](charts/payload-size.png) + +*Core NATS 128 B vs 1 KiB (NS1 host run)* +### JetStream r=3 file + +![JetStream durable publish rate (NS1 host run)](charts/js-throughput.png) + +*JetStream durable publish rate (NS1 host run)* +### Core vs JetStream + +![Core vs JetStream publish rate, log scale (NS1 host run)](charts/core-vs-js.png) + +*Core vs JetStream publish rate, log scale (NS1 host run)* +### Delay + +![Ping vs flood delay percentiles, log scale (NS1 host run)](charts/delay-percentiles.png) + +*Ping vs flood delay percentiles, log scale (NS1 host run)* + +--- + +## 4. Study methodology + +### 4.1 Question + +On the NS1 test stand, what message **throughput** and **delay** does the three-node `verae` JetStream cluster deliver at several loads, and which part of the stack is the limiter for product traffic (jobs, events, webhooks, archive)? + +### 4.2 Hypotheses (stated before the run) + +1. **H1 — Core vs JetStream.** Fire-and-forget core NATS is at least an order of magnitude faster than JetStream **file + replicas=3**, because durable publish waits for a majority disk replica. +2. **H2 — JetStream parallelism.** Adding publishers does **not** linearly increase JetStream write rate once the replica log is saturated. +3. **H3 — Quiet delay.** Sequential pub→sub round trip on `vmbr1` is well under 1 ms p99 when the consumer is waiting. +4. **H4 — Burst delay.** If publishers dump a batch before the subscriber drains, observed delay is **queueing time**, roughly linear in backlog, not in cluster hop count. +5. **H5 — Payload.** Moving 128 B → 1 KiB lowers message rate and raises byte rate on core NATS; JetStream in this size band stays replica/fsync bound. + +### 4.3 Independent variables (what we changed) + +| Factor | Levels | +|--------|--------| +| Transport | Core NATS pub/sub vs JetStream file replicas=3 | +| Publisher count | 1, 2, 4, 8 | +| Subscriber count | 0 (JS publish-only), 1, 2, 4, 8 | +| Message count | 1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step) | +| Payload | 128 B, 1024 B | +| Delay mode | **ping** (publish, wait, repeat) vs **flood** (publish all, then drain) | + +### 4.4 Dependent variables (what we recorded) + +| Metric | Instrument | Unit | +|--------|------------|------| +| Publish rate | `nats bench` 0.1.6 Pub stats | msgs/s, MB/s | +| Subscribe rate | `nats bench` Sub stats | msgs/s, MB/s | +| Aggregate | `nats bench` NATS Pub/Sub stats | msgs/s (fan-out counts both sides) | +| Publisher spread | nats min/avg/max **msgs/s** | not delay | +| One-way-ish RTT | `latency.mjs` header timestamp | min, avg, p50, p90, p99, max | +| Host load | `/proc/loadavg` before and after | load average | +| Broker counters | `http://127.0.0.1:8222/varz` inside each nats LXC | connections, in/out msgs, cpu, mem | + +**Important:** nats CLI 0.1.6 min/avg/max are **rate spread across publishers**, not microseconds of delay. Delay is only `latency.mjs`. + +### 4.5 Controls and constants + +- Cluster name `verae`, three routes, client `:4222`, cluster `:6222`, monitor loopback `:8222`. +- Client URL always the three-node list on `vmbr1` (never host `127.0.0.1:4222`, never `vmbr0`). +- Bench client is LXC **510**, not a nats-* server. +- JetStream bench stream name `benchstream`, **file** storage, **replicas=3**, deleted between JS loads (`nats stream rm --force`) so names do not collide. +- Product streams were **not** the bench target (no load test on `ZAPIER_*` / `VERAE_ARCHIVE`). +- No TLS, no nkeys, no account isolation (isolation is `vmbr1`). +- Same nats CLI version (0.1.6) and `nats@2` Node client as the first ladder. + +### 4.6 Procedure + +1. Confirm this script is executing on **NS1.GEORGELAMBERT.ORG**. Refuse otherwise. +2. Snapshot host load, memory, LXC configs, and each nats `varz`. +3. From NS1, `pct exec 510` the core ladder (1p1s, 4p4s, 8p8s at 128 B; 4p4s at 1 KiB). +4. Delete `benchstream`; JS ladder (1p, 4p, 4p×1 KiB, 2p2s pull) at replicas=3 file. +5. Copy `latency.mjs` into 510; ping then flood at several batch sizes. +6. Snapshot host/`varz` again. +7. Parse logs on **this host**; draw charts; write HTML and PDF on **this host**. + +No publish, subscribe, chart, or PDF process runs on the operator laptop for this study. + +### 4.7 Instrumentation path + +```text +[NS1 host 70.88.205.138] + study-on-ns1.sh (bash + python3) + | + | sudo pct exec 510 + v +[LXC 510 verae-px-worker 10.10.10.20] + nats bench / node latency.mjs + | + | NATS client protocol to + v +[LXC 511/512/513 10.10.10.21-23 :4222] + nats-server -js cluster routes :6222 +``` + +The hypervisor issues the guest commands. The messages themselves never leave `vmbr1`. + +### 4.8 Threats to validity + +| Threat | Effect on numbers | +|--------|-------------------| +| **One physical host** | Three “replicas” share CPU, memory, and usually the same datastore. This measures process/LXC HA, not disk HA. | +| **Shared load** | NS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. Load average during a run is part of the result, not noise to ignore. | +| **Single bench client** | All publishers live in 510. Per-publisher rate spread is contention in that guest. | +| **Short runs** | Seconds of traffic. No compaction, no multi-hour page-cache eviction, no snapshot during load. | +| **No TLS/nkeys** | Production auth will cost CPU. Do not treat these rates as post-nkeys rates. | +| **Fan-out aggregate** | Core aggregate msgs/s counts pub+sub. Do not compare that column to JetStream unique writes. | +| **Flood ≠ RTT** | Mixing flood averages with ping p99 produces a fake “NATS is slow” story. | +| **Lab only** | Not a Zapier HTTPS bench and not live `api.veraetime.net`. | + +### 4.9 Ethics / safety + +Bench uses throwaway subjects (`bench.core.*`, `bench.js.*`, `bench.lat.*`) and a throwaway stream. It does not purge product streams. Zapier cloud has no NATS socket. + +--- + +## 5. Suggestions for fine-tuning + +These follow from the method and from the first ladder on this stand (JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of ms). Apply in order of leverage. Re-run **this NS1 study** after each change so the delta is measured the same way. + +### 5.1 Treat JetStream as the product limiter + +Product jobs/events/webhooks/archive are durable. Tuning core NATS to 2M msgs/s will not move a timestamp Zap. Put effort into **replica write path** and **consumer lag**, not core fan-out. + +### 5.2 Split storage class by stream + +| Stream | Suggested store | Why | +|--------|-----------------|-----| +| `ZAPIER_JOBS` | file, r=3 | Work queue; lose-a-job is bad | +| `ZAPIER_EVENTS` | file r=3, or memory r=3 if events are rebuildable from job status | Hot waiters; measure both | +| `ZAPIER_WEBHOOKS` | file, r=3, workqueue | HTTPS to Zapier is the slow consumer | +| `ZAPIER_USAGE` | file, r=3, limits + max-age | Telemetry | +| `VERAE_ARCHIVE` | file, r=3, on the **best disk** | Puts are larger and must survive | + +Try `ZAPIER_EVENTS` as memory store in a maintenance window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms and durable events still ack at a higher rate, keep it; if a CT restart drops in-flight waiters, revert. + +### 5.3 Give JetStream real disks + +Today r=3 on three LXC guests on **one Proxmox host** is three files, one failure domain. + +- Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) into each nats LXC `store_dir`. +- Set `sync: always` only on archive if you need it; default sync is often enough for jobs and is faster. Measure. +- Do not put JetStream `store_dir` on the same busy rootfs as Forgejo/Caddy if we can avoid it. +- When moving to three metal boxes: same configs, private NIC, one disk (or mirror) **per node**. That is the first change that makes r=3 mean “two boxes can die.” + +### 5.4 Isolate the nats CTs from the rest of NS1 + +Host load on this box is often already several. Pin: + +- `nats-a/b/c`: dedicated cores, no steal from keep/fleet Node processes. +- Memory high enough that file-backed streams stay cache-hot for the working set. +- `cpuunits` / cpuset in `pct config` so a Zapier-facing Node GC pause does not stall fsync. + +Re-run this study after pinning; H1/H2 should move more than ping. + +### 5.5 Consumer and mailbox tuning (delay H4) + +Flood delay is backlog / consume_rate. Fine-tune the **waiters**, not the broker RTT. + +- `jobs.events` and `webhooks.deliver`: raise `max_ack_pending` so a slow HTTPS hook does not stall the whole consumer; cap it so a poison message cannot unbounded-buffer RAM. +- Pull consumers: larger batch, shorter `expires`, more pullers horizontally (fleet replica floors) instead of one fat subscriber. +- Middleware should **not** flood-publish then wait; it already does per-job publish. Keep that. The flood test is the outage profile when a consumer is stopped. +- Alert on **consumer lag** (pending + ack pending) from JetStream, not on ping RTT. + +### 5.6 Publisher-side batching in middleware + +A timestamp job is one small JSON. 16k msgs/s is ample. Still: + +- Avoid per-byte publishes; one message per job/event. +- Reuse NATS connections (connection churn showed up as publisher spread in the core 4p/8p runs). +- Idempotent `msg id` / duplicate window sized to Verae retry window, not default-only. + +### 5.7 nats-server knobs worth measuring (A/B with this script) + +| Knob | Why try it | +|------|------------| +| `max_payload` | Keep default unless archive puts grow | +| `write_deadline` | Slow consumer protection for webhooks | +| `max_pending` | Bound memory on a stuck Zapier hook | +| `max_connections` | Fleet workers + keep + middleware | +| JetStream `max_file_store` / `max_memory_store` | Prevent one stream from filling the CT | +| `max_outstanding_catchup` | Replica restart after a nats-c blip | +| GOMAXPROCS = LXC cores | Do not overthread a 2-core CT | + +Change **one** knob, re-run `study-on-ns1.sh`, compare JetStream 1p 128 B and ping p99. + +### 5.8 Network + +- Keep NATS off `vmbr0`. No change. +- When on metal: dedicated NIC or VLAN for cluster `:6222` vs client `:4222` if possible (replication vs client load). +- Check virtio queue counts on the LXC nics if core 1 KiB byte rate plateaus. + +### 5.9 Security cost (when nkeys/mTLS flip) + +`verae-nats-accounts` is still a sketch. Enabling accounts will add CPU on publish. Budget: re-run this exact study **after** creds are in every `NATS_URL`, and accept a drop on both core and JS. Do not flip without that measurement. + +### 5.10 Operational fine-tuning (lag, not peak msgs/s) + +1. Scrape `varz` / `jsz` from the host over `vmbr1` (not public). Monitor loopback `:8222` is invisible to Prometheus on NS1 unless we add a host-side proxy on `10.10.10.21:8222` bound only to `vmbr1`. +2. Keep replica floors for webhook-deliver and job-poller — they are the flood defense. +3. Backup/restore drill of JetStream **during idle**, then a short JS 1p run to see catchup cost. +4. A 15–30 minute soak (not in this ladder) for page cache and compaction; add that as a third study when disks are dedicated. + +### 5.11 What not to tune + +- Do not chase core 8p8s aggregate. It is fan-out on a lab bridge. +- Do not treat flood 400 ms as “cluster RTT.” Fix consumers. +- Do not load-test on `ZAPIER_*` streams. +- Do not bind client NATS to `0.0.0.0` on `vmbr0`. + +### 5.12 Recommended next experiments (same method, one change each) + +1. CPU pin nats-a/b/c → re-run JS 1p + ping. +2. `ZAPIER_EVENTS`-shaped memory stream vs file (throwaway stream, same flags as this JS ladder). +3. Distinct `store_dir` disks per node. +4. nkeys on, same ladder. +5. Three hardware boxes, same `cluster.env` IPs updated. + +Each experiment should produce a new `results//` on NS1 and a new progress-repo report so we can diff H1–H5 instead of arguing from memory. + + +--- + +## 6. Reproducing this study + +On **NS1 only**: + +```bash +cd ~/verae-src/verae-nats-cluster +bash scripts/study-on-ns1.sh +``` + +The script exits if `hostname` is not NS1. Outputs land in `results//` including `nats-cluster-bench-ns1.{md,html,pdf}` and `charts/`. Copy those into `zapier-decisions/reports/` for the progress repo and catalog. + +Raw logs for this run: `results/20260912T051237Z/`. diff --git a/results/20260912T051237Z/nats-cluster-bench-ns1.pdf b/results/20260912T051237Z/nats-cluster-bench-ns1.pdf new file mode 100644 index 0000000..c25d95e Binary files /dev/null and b/results/20260912T051237Z/nats-cluster-bench-ns1.pdf differ diff --git a/results/20260912T051237Z/varz-after.json b/results/20260912T051237Z/varz-after.json new file mode 100644 index 0000000..c55309c --- /dev/null +++ b/results/20260912T051237Z/varz-after.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 2, + "in_msgs": 2097596, + "out_msgs": 5910161, + "in_bytes": 559427613, + "out_bytes": 1494241553, + "cpu": 1, + "cores": 1, + "mem": 38273024, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 2, + "in_msgs": 2121816, + "out_msgs": 4746539, + "in_bytes": 585390797, + "out_bytes": 974695990, + "cpu": 1, + "cores": 1, + "mem": 41361408, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 2, + "in_msgs": 2241366, + "out_msgs": 5053978, + "in_bytes": 586858467, + "out_bytes": 1040486148, + "cpu": 1, + "cores": 1, + "mem": 38162432, + "jetstream": true + } +] diff --git a/results/20260912T051237Z/varz-before.json b/results/20260912T051237Z/varz-before.json new file mode 100644 index 0000000..945da17 --- /dev/null +++ b/results/20260912T051237Z/varz-before.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 2, + "in_msgs": 1721203, + "out_msgs": 4933759, + "in_bytes": 440427700, + "out_bytes": 1169323990, + "cpu": 1, + "cores": 1, + "mem": 26398720, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 2, + "in_msgs": 1613205, + "out_msgs": 3513096, + "in_bytes": 428640182, + "out_bytes": 715428083, + "cpu": 0, + "cores": 1, + "mem": 23097344, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 2, + "in_msgs": 1760973, + "out_msgs": 4048582, + "in_bytes": 447844308, + "out_bytes": 864199465, + "cpu": 2, + "cores": 1, + "mem": 23867392, + "jetstream": true + } +] diff --git a/results/20260912T053120Z/BENCH.md b/results/20260912T053120Z/BENCH.md new file mode 100644 index 0000000..c3a69ae --- /dev/null +++ b/results/20260912T053120Z/BENCH.md @@ -0,0 +1,52 @@ +# NATS cluster message speed + +Run **`20260912T053120Z`** (UTC). Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only). + +Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +## Method + +- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack. +- **JetStream** uses **file** storage and **replicas=3** (same as product streams). The unique stream `benchstream` is deleted between JS loads. +- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay. +- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster. +- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. This is **queueing under burst**, not wire RTT. +- Probe: `scripts/latency.mjs` (two connections, header timestamp). + +## Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 810,988 | 599,004 | 73.12 | 456,116 | 55.68 | +| `core-4p4s-100k-128` | core pub/sub | 1,361,921 | 379,346 | 46.31 | 1,150,536 | 140.45 | +| `core-4p4s-50k-1k` | core pub/sub | 695,192 | 175,725 | 171.61 | 584,024 | 570.34 | +| `core-8p8s-200k-128` | core pub/sub | 1,998,733 | 283,259 | 34.58 | 1,780,589 | 217.36 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 17,388 | 2.12 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 19,876 | 9,959 | 1.22 | 9,942 | 1.21 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 18,114 | 17.69 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 22,853 | 2.79 | — | — | +| `js-mem-1p-20k-128-r3` | jetstream r=3 file | — | 22,153 | 2.70 | — | — | +| `js-mem-4p-20k-1k-r3` | jetstream r=3 file | — | 28,685 | 28.01 | — | — | +| `js-mem-4p-50k-128-r3` | jetstream r=3 file | — | 36,355 | 4.44 | — | — | + +## Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.250ms | 0.319ms | 0.299ms | 0.363ms | 0.684ms | 2.759ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 94.920ms | 132.026ms | 131.990ms | 156.086ms | 158.860ms | 158.965ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 113.281ms | 132.425ms | 131.457ms | 140.317ms | 144.683ms | 145.365ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 153.318ms | 204.329ms | 205.865ms | 234.151ms | 237.346ms | 237.477ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 233.727ms | 310.924ms | 311.170ms | 373.437ms | 400.764ms | 402.811ms | + +## What the numbers mean + +Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about **16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers. + +A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers. + +Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`. diff --git a/results/20260912T053120Z/charts/core-throughput.png b/results/20260912T053120Z/charts/core-throughput.png new file mode 100644 index 0000000..683f57e Binary files /dev/null and b/results/20260912T053120Z/charts/core-throughput.png differ diff --git a/results/20260912T053120Z/charts/core-vs-js.png b/results/20260912T053120Z/charts/core-vs-js.png new file mode 100644 index 0000000..b6a880c Binary files /dev/null and b/results/20260912T053120Z/charts/core-vs-js.png differ diff --git a/results/20260912T053120Z/charts/delay-percentiles.png b/results/20260912T053120Z/charts/delay-percentiles.png new file mode 100644 index 0000000..0b5f851 Binary files /dev/null and b/results/20260912T053120Z/charts/delay-percentiles.png differ diff --git a/results/20260912T053120Z/charts/delta-vs-baseline.png b/results/20260912T053120Z/charts/delta-vs-baseline.png new file mode 100644 index 0000000..f23250c Binary files /dev/null and b/results/20260912T053120Z/charts/delta-vs-baseline.png differ diff --git a/results/20260912T053120Z/charts/js-throughput.png b/results/20260912T053120Z/charts/js-throughput.png new file mode 100644 index 0000000..55452c7 Binary files /dev/null and b/results/20260912T053120Z/charts/js-throughput.png differ diff --git a/results/20260912T053120Z/charts/payload-size.png b/results/20260912T053120Z/charts/payload-size.png new file mode 100644 index 0000000..1118a42 Binary files /dev/null and b/results/20260912T053120Z/charts/payload-size.png differ diff --git a/results/20260912T053120Z/core-1p1s-50k-128.csv b/results/20260912T053120Z/core-1p1s-50k-128.csv new file mode 100644 index 0000000..8a98c30 --- /dev/null +++ b/results/20260912T053120Z/core-1p1s-50k-128.csv @@ -0,0 +1,3 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +w2OZLH4YWp0DF7nX2fttF4,S0,50000,6400000,456116,58382861.156470,0.109621 +w2OZLH4YWp0DF7nX2fttF4,P0,50000,6400000,599004,76672615.503222,0.083472 diff --git a/results/20260912T053120Z/core-1p1s-50k-128.txt b/results/20260912T053120Z/core-1p1s-50k-128.txt new file mode 100644 index 0000000..f28700d --- /dev/null +++ b/results/20260912T053120Z/core-1p1s-50k-128.txt @@ -0,0 +1,7 @@ +=== core-1p1s-50k-128 === + +NATS Pub/Sub stats: 810,988 msgs/sec ~ 99.00 MB/sec + Pub stats: 599,004 msgs/sec ~ 73.12 MB/sec + Sub stats: 456,116 msgs/sec ~ 55.68 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/core-4p4s-100k-128.csv b/results/20260912T053120Z/core-4p4s-100k-128.csv new file mode 100644 index 0000000..d4854b8 --- /dev/null +++ b/results/20260912T053120Z/core-4p4s-100k-128.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +objwjMbBXlRDUb07UVtRo4,S0,100000,12800000,292772,37474879.058831,0.341562 +objwjMbBXlRDUb07UVtRo4,S1,100000,12800000,290329,37162115.621242,0.344437 +objwjMbBXlRDUb07UVtRo4,S2,100000,12800000,290323,37161444.974130,0.344443 +objwjMbBXlRDUb07UVtRo4,S3,100000,12800000,289603,37069299.877433,0.345299 +objwjMbBXlRDUb07UVtRo4,P0,25000,3200000,554855,71021459.933110,0.045057 +objwjMbBXlRDUb07UVtRo4,P1,25000,3200000,189477,24253105.346766,0.131942 +objwjMbBXlRDUb07UVtRo4,P2,25000,3200000,105092,13451809.359579,0.237886 +objwjMbBXlRDUb07UVtRo4,P3,25000,3200000,95101,12173034.434871,0.262876 diff --git a/results/20260912T053120Z/core-4p4s-100k-128.txt b/results/20260912T053120Z/core-4p4s-100k-128.txt new file mode 100644 index 0000000..2f1a448 --- /dev/null +++ b/results/20260912T053120Z/core-4p4s-100k-128.txt @@ -0,0 +1,17 @@ +=== core-4p4s-100k-128 === + +NATS Pub/Sub stats: 1,361,921 msgs/sec ~ 166.25 MB/sec + Pub stats: 379,346 msgs/sec ~ 46.31 MB/sec + [1] 554,855 msgs/sec ~ 67.73 MB/sec (25000 msgs) + [2] 189,477 msgs/sec ~ 23.13 MB/sec (25000 msgs) + [3] 105,092 msgs/sec ~ 12.83 MB/sec (25000 msgs) + [4] 95,101 msgs/sec ~ 11.61 MB/sec (25000 msgs) + min 95,101 | avg 236,131 | max 554,855 | stddev 187,631 msgs + Sub stats: 1,150,536 msgs/sec ~ 140.45 MB/sec + [1] 292,772 msgs/sec ~ 35.74 MB/sec (100000 msgs) + [2] 290,329 msgs/sec ~ 35.44 MB/sec (100000 msgs) + [3] 290,323 msgs/sec ~ 35.44 MB/sec (100000 msgs) + [4] 289,603 msgs/sec ~ 35.35 MB/sec (100000 msgs) + min 289,603 | avg 290,756 | max 292,772 | stddev 1,200 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/core-4p4s-50k-1k.csv b/results/20260912T053120Z/core-4p4s-50k-1k.csv new file mode 100644 index 0000000..5dd0a8c --- /dev/null +++ b/results/20260912T053120Z/core-4p4s-50k-1k.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +LPWAPBuugXuZD3pcA9R4Q1,S0,50000,51200000,175324,179532297.676261,0.285185 +LPWAPBuugXuZD3pcA9R4Q1,S1,50000,51200000,174783,178978214.492079,0.286068 +LPWAPBuugXuZD3pcA9R4Q1,S2,50000,51200000,170404,174493875.742093,0.293420 +LPWAPBuugXuZD3pcA9R4Q1,S3,50000,51200000,146006,149510306.440412,0.342451 +LPWAPBuugXuZD3pcA9R4Q1,P0,12500,12800000,206954,211921364.855709,0.060400 +LPWAPBuugXuZD3pcA9R4Q1,P1,12500,12800000,78140,80016334.334450,0.159967 +LPWAPBuugXuZD3pcA9R4Q1,P2,12500,12800000,47044,48173938.085417,0.265704 +LPWAPBuugXuZD3pcA9R4Q1,P3,12500,12800000,43981,45036904.770320,0.284211 diff --git a/results/20260912T053120Z/core-4p4s-50k-1k.txt b/results/20260912T053120Z/core-4p4s-50k-1k.txt new file mode 100644 index 0000000..1fd3d50 --- /dev/null +++ b/results/20260912T053120Z/core-4p4s-50k-1k.txt @@ -0,0 +1,17 @@ +=== core-4p4s-50k-1k === + +NATS Pub/Sub stats: 695,192 msgs/sec ~ 678.90 MB/sec + Pub stats: 175,725 msgs/sec ~ 171.61 MB/sec + [1] 206,954 msgs/sec ~ 202.10 MB/sec (12500 msgs) + [2] 78,140 msgs/sec ~ 76.31 MB/sec (12500 msgs) + [3] 47,044 msgs/sec ~ 45.94 MB/sec (12500 msgs) + [4] 43,981 msgs/sec ~ 42.95 MB/sec (12500 msgs) + min 43,981 | avg 94,029 | max 206,954 | stddev 66,552 msgs + Sub stats: 584,024 msgs/sec ~ 570.34 MB/sec + [1] 175,324 msgs/sec ~ 171.22 MB/sec (50000 msgs) + [2] 174,783 msgs/sec ~ 170.69 MB/sec (50000 msgs) + [3] 170,404 msgs/sec ~ 166.41 MB/sec (50000 msgs) + [4] 146,006 msgs/sec ~ 142.58 MB/sec (50000 msgs) + min 146,006 | avg 166,629 | max 175,324 | stddev 12,058 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/core-8p8s-200k-128.csv b/results/20260912T053120Z/core-8p8s-200k-128.csv new file mode 100644 index 0000000..c1f7489 --- /dev/null +++ b/results/20260912T053120Z/core-8p8s-200k-128.csv @@ -0,0 +1,17 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +D29xFtCiL3n1hFtnnty2bJ,S0,200000,25600000,275094,35212130.182711,0.727022 +D29xFtCiL3n1hFtnnty2bJ,S1,200000,25600000,274807,35175364.678326,0.727782 +D29xFtCiL3n1hFtnnty2bJ,S2,200000,25600000,274647,35154883.000958,0.728206 +D29xFtCiL3n1hFtnnty2bJ,S3,200000,25600000,274140,35089962.583779,0.729553 +D29xFtCiL3n1hFtnnty2bJ,S4,200000,25600000,273964,35067496.657328,0.730021 +D29xFtCiL3n1hFtnnty2bJ,S5,200000,25600000,228037,29188847.958244,0.877047 +D29xFtCiL3n1hFtnnty2bJ,S6,200000,25600000,227447,29113332.808421,0.879322 +D29xFtCiL3n1hFtnnty2bJ,S7,200000,25600000,232494,29759235.647424,0.860237 +D29xFtCiL3n1hFtnnty2bJ,P0,25000,3200000,127444,16312929.457622,0.196163 +D29xFtCiL3n1hFtnnty2bJ,P1,25000,3200000,77933,9975435.116946,0.320788 +D29xFtCiL3n1hFtnnty2bJ,P2,25000,3200000,77633,9937136.216776,0.322024 +D29xFtCiL3n1hFtnnty2bJ,P3,25000,3200000,76965,9851558.471756,0.324822 +D29xFtCiL3n1hFtnnty2bJ,P4,25000,3200000,77205,9882296.902740,0.323811 +D29xFtCiL3n1hFtnnty2bJ,P5,25000,3200000,44296,5669906.917475,0.564383 +D29xFtCiL3n1hFtnnty2bJ,P6,25000,3200000,35857,4589723.695277,0.697210 +D29xFtCiL3n1hFtnnty2bJ,P7,25000,3200000,35509,4545175.649861,0.704043 diff --git a/results/20260912T053120Z/core-8p8s-200k-128.txt b/results/20260912T053120Z/core-8p8s-200k-128.txt new file mode 100644 index 0000000..2072178 --- /dev/null +++ b/results/20260912T053120Z/core-8p8s-200k-128.txt @@ -0,0 +1,25 @@ +=== core-8p8s-200k-128 === + +NATS Pub/Sub stats: 1,998,733 msgs/sec ~ 243.99 MB/sec + Pub stats: 283,259 msgs/sec ~ 34.58 MB/sec + [1] 127,444 msgs/sec ~ 15.56 MB/sec (25000 msgs) + [2] 77,933 msgs/sec ~ 9.51 MB/sec (25000 msgs) + [3] 77,633 msgs/sec ~ 9.48 MB/sec (25000 msgs) + [4] 76,965 msgs/sec ~ 9.40 MB/sec (25000 msgs) + [5] 77,205 msgs/sec ~ 9.42 MB/sec (25000 msgs) + [6] 44,296 msgs/sec ~ 5.41 MB/sec (25000 msgs) + [7] 35,857 msgs/sec ~ 4.38 MB/sec (25000 msgs) + [8] 35,509 msgs/sec ~ 4.33 MB/sec (25000 msgs) + min 35,509 | avg 69,105 | max 127,444 | stddev 28,572 msgs + Sub stats: 1,780,589 msgs/sec ~ 217.36 MB/sec + [1] 275,094 msgs/sec ~ 33.58 MB/sec (200000 msgs) + [2] 274,807 msgs/sec ~ 33.55 MB/sec (200000 msgs) + [3] 274,647 msgs/sec ~ 33.53 MB/sec (200000 msgs) + [4] 274,140 msgs/sec ~ 33.46 MB/sec (200000 msgs) + [5] 273,964 msgs/sec ~ 33.44 MB/sec (200000 msgs) + [6] 228,037 msgs/sec ~ 27.84 MB/sec (200000 msgs) + [7] 227,447 msgs/sec ~ 27.76 MB/sec (200000 msgs) + [8] 232,494 msgs/sec ~ 28.38 MB/sec (200000 msgs) + min 227,447 | avg 257,578 | max 275,094 | stddev 21,930 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/host-after.txt b/results/20260912T053120Z/host-after.txt new file mode 100644 index 0000000..7897ec0 --- /dev/null +++ b/results/20260912T053120Z/host-after.txt @@ -0,0 +1,92 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T05:33:03Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=9.02 8.82 8.44 6/3847 224483 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +js_extra_memory=1 +compare_dir=/home/marchon/verae-src/verae-nats-cluster/results/20260912T051237Z +--- nats 511 max_mem --- + max_mem: 8G +--- nats 511 jetstream mount --- +nats-js on /var/lib/nats/jetstream type tmpfs (rw,relatime,size=8388608k,uid=100000,gid=100000,inode64) +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.3Gi 21Gi 339Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 8 +hostname: verae-px-worker +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 8 +hostname: nats-a +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 8 +hostname: nats-b +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 8 +hostname: nats-c +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T053120Z/host-before.txt b/results/20260912T053120Z/host-before.txt new file mode 100644 index 0000000..da95835 --- /dev/null +++ b/results/20260912T053120Z/host-before.txt @@ -0,0 +1,92 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T05:31:20Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=8.77 8.39 8.26 6/3849 198471 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +js_extra_memory=1 +compare_dir=/home/marchon/verae-src/verae-nats-cluster/results/20260912T051237Z +--- nats 511 max_mem --- + max_mem: 8G +--- nats 511 jetstream mount --- +nats-js on /var/lib/nats/jetstream type tmpfs (rw,relatime,size=8388608k,uid=100000,gid=100000,inode64) +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.2Gi 21Gi 339Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 8 +hostname: verae-px-worker +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 8 +hostname: nats-a +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 8 +hostname: nats-b +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 8 +hostname: nats-c +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T053120Z/js-1p-20k-128-r3.csv b/results/20260912T053120Z/js-1p-20k-128-r3.csv new file mode 100644 index 0000000..c17a87d --- /dev/null +++ b/results/20260912T053120Z/js-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +DsDjrcOoYbw8k54T1mMXxq,P0,40000,2560000,17388,2225703.026620,1.150198 diff --git a/results/20260912T053120Z/js-1p-20k-128-r3.txt b/results/20260912T053120Z/js-1p-20k-128-r3.txt new file mode 100644 index 0000000..fda3f14 --- /dev/null +++ b/results/20260912T053120Z/js-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-1p-20k-128-r3 === + +Pub stats: 17,388 msgs/sec ~ 2.12 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-2p2s-20k-128-r3.csv b/results/20260912T053120Z/js-2p2s-20k-128-r3.csv new file mode 100644 index 0000000..270bee9 --- /dev/null +++ b/results/20260912T053120Z/js-2p2s-20k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +JYqsv0sXzEqkC0r49q0aE5,S0,20203,1280000,5135,657394.514895,1.947080 +JYqsv0sXzEqkC0r49q0aE5,S1,20210,1280000,4971,636317.997133,2.011573 +JYqsv0sXzEqkC0r49q0aE5,P0,20000,1280000,5099,652788.600986,1.960819 +JYqsv0sXzEqkC0r49q0aE5,P1,20000,1280000,4979,637405.779512,2.008140 diff --git a/results/20260912T053120Z/js-2p2s-20k-128-r3.txt b/results/20260912T053120Z/js-2p2s-20k-128-r3.txt new file mode 100644 index 0000000..4dc4838 --- /dev/null +++ b/results/20260912T053120Z/js-2p2s-20k-128-r3.txt @@ -0,0 +1,13 @@ +=== js-2p2s-20k-128-r3 === + +NATS Pub/Sub stats: 19,876 msgs/sec ~ 2.43 MB/sec + Pub stats: 9,959 msgs/sec ~ 1.22 MB/sec + [1] 5,099 msgs/sec ~ 637.49 KB/sec (10000 msgs) + [2] 4,979 msgs/sec ~ 622.47 KB/sec (10000 msgs) + min 4,979 | avg 5,039 | max 5,099 | stddev 60 msgs + Sub stats: 9,942 msgs/sec ~ 1.21 MB/sec + [1] 5,135 msgs/sec ~ 641.99 KB/sec (10000 msgs) + [2] 4,971 msgs/sec ~ 621.40 KB/sec (10000 msgs) + min 4,971 | avg 5,053 | max 5,135 | stddev 82 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-4p-20k-1k-r3.csv b/results/20260912T053120Z/js-4p-20k-1k-r3.csv new file mode 100644 index 0000000..8c263a1 --- /dev/null +++ b/results/20260912T053120Z/js-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +r1r7AI1sRr29EvqNG14gSh,P0,10000,5120000,7677,7861269.752874,0.651294 +r1r7AI1sRr29EvqNG14gSh,P1,10000,5120000,4571,4680909.596986,1.093805 +r1r7AI1sRr29EvqNG14gSh,P2,10000,5120000,4529,4637905.946623,1.103946 +r1r7AI1sRr29EvqNG14gSh,P3,10000,5120000,4528,4637319.175494,1.104086 diff --git a/results/20260912T053120Z/js-4p-20k-1k-r3.txt b/results/20260912T053120Z/js-4p-20k-1k-r3.txt new file mode 100644 index 0000000..13775e4 --- /dev/null +++ b/results/20260912T053120Z/js-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-20k-1k-r3 === + +Pub stats: 18,114 msgs/sec ~ 17.69 MB/sec + [1] 7,677 msgs/sec ~ 7.50 MB/sec (5000 msgs) + [2] 4,571 msgs/sec ~ 4.46 MB/sec (5000 msgs) + [3] 4,529 msgs/sec ~ 4.42 MB/sec (5000 msgs) + [4] 4,528 msgs/sec ~ 4.42 MB/sec (5000 msgs) + min 4,528 | avg 5,326 | max 7,677 | stddev 1,357 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-4p-50k-128-r3.csv b/results/20260912T053120Z/js-4p-50k-128-r3.csv new file mode 100644 index 0000000..ccebd0b --- /dev/null +++ b/results/20260912T053120Z/js-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +6cJTgCeHUXHowXNlC39Tws,P0,25000,1600000,7724,988748.856206,1.618207 +6cJTgCeHUXHowXNlC39Tws,P1,25000,1600000,7704,986199.433317,1.622390 +6cJTgCeHUXHowXNlC39Tws,P2,25000,1600000,5713,731384.231657,2.187633 +6cJTgCeHUXHowXNlC39Tws,P3,25000,1600000,5713,731308.958368,2.187858 diff --git a/results/20260912T053120Z/js-4p-50k-128-r3.txt b/results/20260912T053120Z/js-4p-50k-128-r3.txt new file mode 100644 index 0000000..f4b0dec --- /dev/null +++ b/results/20260912T053120Z/js-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-50k-128-r3 === + +Pub stats: 22,853 msgs/sec ~ 2.79 MB/sec + [1] 7,724 msgs/sec ~ 965.58 KB/sec (12500 msgs) + [2] 7,704 msgs/sec ~ 963.09 KB/sec (12500 msgs) + [3] 5,713 msgs/sec ~ 714.24 KB/sec (12500 msgs) + [4] 5,713 msgs/sec ~ 714.17 KB/sec (12500 msgs) + min 5,713 | avg 6,713 | max 7,724 | stddev 1,000 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-mem-1p-20k-128-r3.csv b/results/20260912T053120Z/js-mem-1p-20k-128-r3.csv new file mode 100644 index 0000000..58feb82 --- /dev/null +++ b/results/20260912T053120Z/js-mem-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +jHJGzEaQyqmtfzAfyQRddn,P0,40000,2560000,22153,2835624.882291,0.902799 diff --git a/results/20260912T053120Z/js-mem-1p-20k-128-r3.txt b/results/20260912T053120Z/js-mem-1p-20k-128-r3.txt new file mode 100644 index 0000000..e43a918 --- /dev/null +++ b/results/20260912T053120Z/js-mem-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-mem-1p-20k-128-r3 === + +Pub stats: 22,153 msgs/sec ~ 2.70 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-mem-4p-20k-1k-r3.csv b/results/20260912T053120Z/js-mem-4p-20k-1k-r3.csv new file mode 100644 index 0000000..d528650 --- /dev/null +++ b/results/20260912T053120Z/js-mem-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +bSUG5M5Cdh9fEhPsY8MWLm,P0,10000,5120000,9256,9478798.103952,0.540153 +bSUG5M5Cdh9fEhPsY8MWLm,P1,10000,5120000,8901,9115347.820727,0.561690 +bSUG5M5Cdh9fEhPsY8MWLm,P2,10000,5120000,7192,7365226.588506,0.695159 +bSUG5M5Cdh9fEhPsY8MWLm,P3,10000,5120000,7171,7343566.738925,0.697209 diff --git a/results/20260912T053120Z/js-mem-4p-20k-1k-r3.txt b/results/20260912T053120Z/js-mem-4p-20k-1k-r3.txt new file mode 100644 index 0000000..5014c01 --- /dev/null +++ b/results/20260912T053120Z/js-mem-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-mem-4p-20k-1k-r3 === + +Pub stats: 28,685 msgs/sec ~ 28.01 MB/sec + [1] 9,256 msgs/sec ~ 9.04 MB/sec (5000 msgs) + [2] 8,901 msgs/sec ~ 8.69 MB/sec (5000 msgs) + [3] 7,192 msgs/sec ~ 7.02 MB/sec (5000 msgs) + [4] 7,171 msgs/sec ~ 7.00 MB/sec (5000 msgs) + min 7,171 | avg 8,130 | max 9,256 | stddev 956 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/js-mem-4p-50k-128-r3.csv b/results/20260912T053120Z/js-mem-4p-50k-128-r3.csv new file mode 100644 index 0000000..3bcfc38 --- /dev/null +++ b/results/20260912T053120Z/js-mem-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +RCjYO8CWEOKsynfDUe3izo,P0,25000,1600000,9194,1176894.303068,1.359510 +RCjYO8CWEOKsynfDUe3izo,P1,25000,1600000,9199,1177484.153608,1.358829 +RCjYO8CWEOKsynfDUe3izo,P2,25000,1600000,9144,1170517.752374,1.366916 +RCjYO8CWEOKsynfDUe3izo,P3,25000,1600000,9097,1164445.806755,1.374044 diff --git a/results/20260912T053120Z/js-mem-4p-50k-128-r3.txt b/results/20260912T053120Z/js-mem-4p-50k-128-r3.txt new file mode 100644 index 0000000..bfe718e --- /dev/null +++ b/results/20260912T053120Z/js-mem-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-mem-4p-50k-128-r3 === + +Pub stats: 36,355 msgs/sec ~ 4.44 MB/sec + [1] 9,194 msgs/sec ~ 1.12 MB/sec (12500 msgs) + [2] 9,199 msgs/sec ~ 1.12 MB/sec (12500 msgs) + [3] 9,144 msgs/sec ~ 1.12 MB/sec (12500 msgs) + [4] 9,097 msgs/sec ~ 1.11 MB/sec (12500 msgs) + min 9,097 | avg 9,158 | max 9,199 | stddev 41 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T053120Z/lat-1p-5k-128.txt b/results/20260912T053120Z/lat-1p-5k-128.txt new file mode 100644 index 0000000..849fbc2 --- /dev/null +++ b/results/20260912T053120Z/lat-1p-5k-128.txt @@ -0,0 +1,2 @@ +=== lat-1p-5k-128 === +{"count":5000,"pubs":1,"size":128,"mode":"flood","min_us":94920,"avg_us":132026,"p50_us":131990,"p90_us":156086,"p99_us":158860,"max_us":158965,"min":"94.920ms","avg":"132.026ms","p50":"131.990ms","p90":"156.086ms","p99":"158.860ms","max":"158.965ms"} diff --git a/results/20260912T053120Z/lat-4p-10k-128.txt b/results/20260912T053120Z/lat-4p-10k-128.txt new file mode 100644 index 0000000..f394dfa --- /dev/null +++ b/results/20260912T053120Z/lat-4p-10k-128.txt @@ -0,0 +1,2 @@ +=== lat-4p-10k-128 === +{"count":10000,"pubs":4,"size":128,"mode":"flood","min_us":153318,"avg_us":204329,"p50_us":205865,"p90_us":234151,"p99_us":237346,"max_us":237477,"min":"153.318ms","avg":"204.329ms","p50":"205.865ms","p90":"234.151ms","p99":"237.346ms","max":"237.477ms"} diff --git a/results/20260912T053120Z/lat-4p-5k-1k.txt b/results/20260912T053120Z/lat-4p-5k-1k.txt new file mode 100644 index 0000000..66a5c5a --- /dev/null +++ b/results/20260912T053120Z/lat-4p-5k-1k.txt @@ -0,0 +1,2 @@ +=== lat-4p-5k-1k === +{"count":5000,"pubs":4,"size":1024,"mode":"flood","min_us":113281,"avg_us":132425,"p50_us":131457,"p90_us":140317,"p99_us":144683,"max_us":145365,"min":"113.281ms","avg":"132.425ms","p50":"131.457ms","p90":"140.317ms","p99":"144.683ms","max":"145.365ms"} diff --git a/results/20260912T053120Z/lat-8p-20k-128.txt b/results/20260912T053120Z/lat-8p-20k-128.txt new file mode 100644 index 0000000..273544e --- /dev/null +++ b/results/20260912T053120Z/lat-8p-20k-128.txt @@ -0,0 +1,2 @@ +=== lat-8p-20k-128 === +{"count":20000,"pubs":8,"size":128,"mode":"flood","min_us":233727,"avg_us":310924,"p50_us":311170,"p90_us":373437,"p99_us":400764,"max_us":402811,"min":"233.727ms","avg":"310.924ms","p50":"311.170ms","p90":"373.437ms","p99":"400.764ms","max":"402.811ms"} diff --git a/results/20260912T053120Z/lat-ping-1k-128.txt b/results/20260912T053120Z/lat-ping-1k-128.txt new file mode 100644 index 0000000..4604122 --- /dev/null +++ b/results/20260912T053120Z/lat-ping-1k-128.txt @@ -0,0 +1,2 @@ +=== lat-ping-1k-128 === +{"count":1000,"pubs":1,"size":128,"mode":"ping","min_us":250,"avg_us":319,"p50_us":299,"p90_us":363,"p99_us":684,"max_us":2759,"min":"0.250ms","avg":"0.319ms","p50":"0.299ms","p90":"0.363ms","p99":"0.684ms","max":"2.759ms"} diff --git a/results/20260912T053120Z/nats-cluster-bench-ns1.html b/results/20260912T053120Z/nats-cluster-bench-ns1.html new file mode 100644 index 0000000..c466b2a --- /dev/null +++ b/results/20260912T053120Z/nats-cluster-bench-ns1.html @@ -0,0 +1,1385 @@ + + + + + + + NATS cluster message speed — NS1 host study + + + + +
Verae Time × Zapier · progress report · maximized NS1 study

NATS cluster message speed — maximized (RAM disk + 8 cores)

packages/zapier-decisions/reports/nats-cluster-bench-ns1.md
+
+

NATS cluster message speed — NS1 host study

+
+

Progress report (maximized NS1 study) · run +20260912T053120Z (UTC)

+
+

Execution provenance. Every process for this study +ran on NS1.GEORGELAMBERT.ORG +(70.88.205.138): maximize-ns1-study.sh +(cores/RAM/max_mem/tmpfs), then +study-on-ns1.sh, nats bench, +latency.mjs (LXC 510), matplotlib, pandoc, weasyprint. +Traffic stayed on vmbr1. veth/10G was not +changed. After the ladder, JetStream was put back on ZFS and product +streams were re-created; 8 cores / 16 GiB / max_mem 8G +stay.

+
+

Measured delta vs +20260912T051237Z

+

Baseline: 1 core / 1 GiB / JetStream on ZFS. This run: 8 cores / 16 +GiB / JetStream tmpfs (file r=3) plus extra +memory store rows. veth/10G unchanged.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricBaseline 20260912T051237ZThis runRatio
Core 1p1s 128 B pub msgs/s502,502599,0041.19×
Core 8p8s 128 B aggregate msgs/s2,065,2171,998,7330.97×
JS file r=3 1p 128 B pub msgs/s7,39317,3882.35×
JS file r=3 4p 128 B pub msgs/s17,98622,8531.27×
JS file r=3 4p 1 KiB pub msgs/s14,98518,1141.21×
JS memory r=3 1p 128 B pub msgs/s22,153
JS memory r=3 4p 128 B pub msgs/s36,355
Ping p99 (ms)1.377ms0.684ms2.01× faster
+
+ + +
+

Baseline vs maximized publish rates (log)

+
+

1. Executive summary

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemThis NS1-host run
Control planeNS1.GEORGELAMBERT.ORG (70.88.205.138), user +marchon
Bench clientLXC 510 verae-px-worker
BrokersLXC 511/512/513 nats-a/b/c on +10.10.10.21–23
Client URLnats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222
Host load before8.77 8.39 8.26 6/3849 198471
Host load after9.02 8.82 8.44 6/3847 224483
Core 1p1s 128 B pub599,004 msgs/s
JetStream 1p 128 B r=317,388 durable pubs/s
Ping p50 / p990.299ms / 0.684ms
+

Product traffic is the JetStream row. Ping is one-message delay. +Flood is mailbox catch-up after a burst.

+
+

2. Where it ran (and where it +did not)

+
Operator laptop  ──ssh──►  NS1.GEORGELAMBERT.ORG  70.88.205.138
+                              study-on-ns1.sh
+                              python3 build-ns1-study-report.py
+                              sudo pct exec 510 ──► nats bench / latency.mjs
+                                                    │
+                                                    ▼ vmbr1
+                                              10.10.10.21-23 :4222
+
    +
  • Did run on 138: bash, python3, matplotlib, pandoc, +weasyprint, pct, nats-server (in LXC), nats CLI and Node +(in LXC 510).
  • +
  • Did not run on the laptop: no local +nats bench, no local charting, no local WeasyPrint for this +file.
  • +
+
+

3. Results (this run)

+

Host and brokers

+

Before

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a51133,0133,0371814,553,088True
nats-b51231,4151,4240113,557,760True
nats-c51301,3611,3951114,028,800True
+

After

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a5113644,7301,344,7682867,104,768True
nats-b5123491,295978,8001182,542,592True
nats-c5130668,5441,330,9400137,314,560True
+

nproc=40 · +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux

+

Throughput

+ +++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunModeAggregate msgs/sPub msgs/sPub MB/sSub msgs/sSub MB/s
core-1p1s-50k-128core pub/sub810,988599,00473.12456,11655.68
core-4p4s-100k-128core pub/sub1,361,921379,34646.311,150,536140.45
core-4p4s-50k-1kcore pub/sub695,192175,725171.61584,024570.34
core-8p8s-200k-128core pub/sub1,998,733283,25934.581,780,589217.36
js-1p-20k-128-r3jetstream r=3 file17,3882.12
js-2p2s-20k-128-r3jetstream r=3 file19,8769,9591.229,9421.21
js-4p-20k-1k-r3jetstream r=3 file18,11417.69
js-4p-50k-128-r3jetstream r=3 file22,8532.79
js-mem-1p-20k-128-r3jetstream r=3 file22,1532.70
js-mem-4p-20k-1k-r3jetstream r=3 file28,68528.01
js-mem-4p-50k-128-r3jetstream r=3 file36,3554.44
+

Round-trip delay

+ +++++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunKindCountPubsSizeminavgp50p90p99max
lat-ping-1k-128ping (sequential RTT)10001128 B0.250ms0.319ms0.299ms0.363ms0.684ms2.759ms
lat-1p-5k-128flood (burst queueing)50001128 B94.920ms132.026ms131.990ms156.086ms158.860ms158.965ms
lat-4p-5k-1kflood (burst queueing)500041024 B113.281ms132.425ms131.457ms140.317ms144.683ms145.365ms
lat-4p-10k-128flood (burst queueing)100004128 B153.318ms204.329ms205.865ms234.151ms237.346ms237.477ms
lat-8p-20k-128flood (burst queueing)200008128 B233.727ms310.924ms311.170ms373.437ms400.764ms402.811ms
+

Core NATS

+
+ + +
+

Core NATS throughput at four loads (NS1 host run) ### +Payload size (core)

+
+ + +
+

Core NATS 128 B vs 1 KiB (NS1 host run) ### JetStream r=3 +file

+
+ + +
+

JetStream durable publish rate (NS1 host run) ### Core vs +JetStream

+
+ + +
+

Core vs JetStream publish rate, log scale (NS1 host run) ### +Delay

+
+ + +
+

Ping vs flood delay percentiles, log scale (NS1 host +run)

+
+

4. Study methodology

+

4.1 Question

+

On the NS1 test stand, what message throughput and +delay does the three-node verae JetStream +cluster deliver at several loads, and which part of the stack is the +limiter for product traffic (jobs, events, webhooks, archive)?

+

4.2 Hypotheses (stated before +the run)

+
    +
  1. H1 — Core vs JetStream. Fire-and-forget core NATS +is at least an order of magnitude faster than JetStream file + +replicas=3, because durable publish waits for a majority disk +replica.
  2. +
  3. H2 — JetStream parallelism. Adding publishers does +not linearly increase JetStream write rate once the +replica log is saturated.
  4. +
  5. H3 — Quiet delay. Sequential pub→sub round trip on +vmbr1 is well under 1 ms p99 when the consumer is +waiting.
  6. +
  7. H4 — Burst delay. If publishers dump a batch before +the subscriber drains, observed delay is queueing time, +roughly linear in backlog, not in cluster hop count.
  8. +
  9. H5 — Payload. Moving 128 B → 1 KiB lowers message +rate and raises byte rate on core NATS; JetStream in this size band +stays replica/fsync bound.
  10. +
+

4.3 Independent variables +(what we changed)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FactorLevels
TransportCore NATS pub/sub vs JetStream file replicas=3
Publisher count1, 2, 4, 8
Subscriber count0 (JS publish-only), 1, 2, 4, 8
Message count1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step)
Payload128 B, 1024 B
Delay modeping (publish, wait, repeat) vs +flood (publish all, then drain)
+

4.4 Dependent variables +(what we recorded)

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricInstrumentUnit
Publish ratenats bench 0.1.6 Pub statsmsgs/s, MB/s
Subscribe ratenats bench Sub statsmsgs/s, MB/s
Aggregatenats bench NATS Pub/Sub statsmsgs/s (fan-out counts both sides)
Publisher spreadnats min/avg/max msgs/snot delay
One-way-ish RTTlatency.mjs header timestampmin, avg, p50, p90, p99, max
Host load/proc/loadavg before and afterload average
Broker countershttp://127.0.0.1:8222/varz inside each nats LXCconnections, in/out msgs, cpu, mem
+

Important: nats CLI 0.1.6 min/avg/max are +rate spread across publishers, not microseconds of +delay. Delay is only latency.mjs.

+

4.5 Controls and constants

+
    +
  • Cluster name verae, three routes, client +:4222, cluster :6222, monitor loopback +:8222.
  • +
  • Client URL always the three-node list on vmbr1 (never +host 127.0.0.1:4222, never vmbr0).
  • +
  • Bench client is LXC 510, not a nats-* server.
  • +
  • JetStream bench stream name benchstream, +file storage, replicas=3, deleted +between JS loads (nats stream rm --force) so names do not +collide.
  • +
  • Product streams were not the bench target (no load +test on ZAPIER_* / VERAE_ARCHIVE).
  • +
  • No TLS, no nkeys, no account isolation (isolation is +vmbr1).
  • +
  • Same nats CLI version (0.1.6) and nats@2 Node client as +the first ladder.
  • +
+

4.6 Procedure

+
    +
  1. Confirm this script is executing on +NS1.GEORGELAMBERT.ORG. Refuse otherwise.
  2. +
  3. Snapshot host load, memory, LXC configs, and each nats +varz.
  4. +
  5. From NS1, pct exec 510 the core ladder (1p1s, 4p4s, +8p8s at 128 B; 4p4s at 1 KiB).
  6. +
  7. Delete benchstream; JS ladder (1p, 4p, 4p×1 KiB, 2p2s +pull) at replicas=3 file.
  8. +
  9. Copy latency.mjs into 510; ping then flood at several +batch sizes.
  10. +
  11. Snapshot host/varz again.
  12. +
  13. Parse logs on this host; draw charts; write HTML +and PDF on this host.
  14. +
+

No publish, subscribe, chart, or PDF process runs on the operator +laptop for this study.

+

4.7 Instrumentation path

+
[NS1 host 70.88.205.138]
+   study-on-ns1.sh  (bash + python3)
+        |
+        | sudo pct exec 510
+        v
+[LXC 510 verae-px-worker 10.10.10.20]
+   nats bench / node latency.mjs
+        |
+        | NATS client protocol to
+        v
+[LXC 511/512/513  10.10.10.21-23 :4222]
+   nats-server -js  cluster routes :6222
+

The hypervisor issues the guest commands. The messages themselves +never leave vmbr1.

+

4.8 Threats to validity

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThreatEffect on numbers
One physical hostThree “replicas” share CPU, memory, and usually the same datastore. +This measures process/LXC HA, not disk HA.
Shared loadNS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. +Load average during a run is part of the result, not noise to +ignore.
Single bench clientAll publishers live in 510. Per-publisher rate spread is contention +in that guest.
Short runsSeconds of traffic. No compaction, no multi-hour page-cache +eviction, no snapshot during load.
No TLS/nkeysProduction auth will cost CPU. Do not treat these rates as +post-nkeys rates.
Fan-out aggregateCore aggregate msgs/s counts pub+sub. Do not compare that column to +JetStream unique writes.
Flood ≠ RTTMixing flood averages with ping p99 produces a fake “NATS is slow” +story.
Lab onlyNot a Zapier HTTPS bench and not live +api.veraetime.net.
+

4.9 Ethics / safety

+

Bench uses throwaway subjects (bench.core.*, +bench.js.*, bench.lat.*) and a throwaway +stream. It does not purge product streams. Zapier cloud has no NATS +socket.

+
+

5. Suggestions for fine-tuning

+

These follow from the method and from the first ladder on this stand +(JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of +ms). Apply in order of leverage. Re-run this NS1 study +after each change so the delta is measured the same way.

+

5.1 Treat JetStream as +the product limiter

+

Product jobs/events/webhooks/archive are durable. Tuning core NATS to +2M msgs/s will not move a timestamp Zap. Put effort into replica +write path and consumer lag, not core +fan-out.

+

5.2 Split storage class by +stream

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StreamSuggested storeWhy
ZAPIER_JOBSfile, r=3Work queue; lose-a-job is bad
ZAPIER_EVENTSfile r=3, or memory r=3 if events are rebuildable from job +statusHot waiters; measure both
ZAPIER_WEBHOOKSfile, r=3, workqueueHTTPS to Zapier is the slow consumer
ZAPIER_USAGEfile, r=3, limits + max-ageTelemetry
VERAE_ARCHIVEfile, r=3, on the best diskPuts are larger and must survive
+

Try ZAPIER_EVENTS as memory store in a maintenance +window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms +and durable events still ack at a higher rate, keep it; if a CT restart +drops in-flight waiters, revert.

+

5.3 Give JetStream real disks

+

Today r=3 on three LXC guests on one Proxmox host is +three files, one failure domain.

+
    +
  • Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) +into each nats LXC store_dir.
  • +
  • Set sync: always only on archive if you need it; +default sync is often enough for jobs and is faster. Measure.
  • +
  • Do not put JetStream store_dir on the same busy rootfs +as Forgejo/Caddy if we can avoid it.
  • +
  • When moving to three metal boxes: same configs, private NIC, one +disk (or mirror) per node. That is the first change +that makes r=3 mean “two boxes can die.”
  • +
+

5.4 Isolate the nats +CTs from the rest of NS1

+

Host load on this box is often already several. Pin:

+
    +
  • nats-a/b/c: dedicated cores, no steal from keep/fleet +Node processes.
  • +
  • Memory high enough that file-backed streams stay cache-hot for the +working set.
  • +
  • cpuunits / cpuset in pct config so a +Zapier-facing Node GC pause does not stall fsync.
  • +
+

Re-run this study after pinning; H1/H2 should move more than +ping.

+

5.5 Consumer and mailbox +tuning (delay H4)

+

Flood delay is backlog / consume_rate. Fine-tune the +waiters, not the broker RTT.

+
    +
  • jobs.events and webhooks.deliver: raise +max_ack_pending so a slow HTTPS hook does not stall the +whole consumer; cap it so a poison message cannot unbounded-buffer +RAM.
  • +
  • Pull consumers: larger batch, shorter expires, more +pullers horizontally (fleet replica floors) instead of one fat +subscriber.
  • +
  • Middleware should not flood-publish then wait; it +already does per-job publish. Keep that. The flood test is the outage +profile when a consumer is stopped.
  • +
  • Alert on consumer lag (pending + ack pending) from +JetStream, not on ping RTT.
  • +
+

5.6 Publisher-side +batching in middleware

+

A timestamp job is one small JSON. 16k msgs/s is ample. Still:

+
    +
  • Avoid per-byte publishes; one message per job/event.
  • +
  • Reuse NATS connections (connection churn showed up as publisher +spread in the core 4p/8p runs).
  • +
  • Idempotent msg id / duplicate window sized to Verae +retry window, not default-only.
  • +
+

5.7 +nats-server knobs worth measuring (A/B with this script)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KnobWhy try it
max_payloadKeep default unless archive puts grow
write_deadlineSlow consumer protection for webhooks
max_pendingBound memory on a stuck Zapier hook
max_connectionsFleet workers + keep + middleware
JetStream max_file_store / +max_memory_storePrevent one stream from filling the CT
max_outstanding_catchupReplica restart after a nats-c blip
GOMAXPROCS = LXC coresDo not overthread a 2-core CT
+

Change one knob, re-run +study-on-ns1.sh, compare JetStream 1p 128 B and ping +p99.

+

5.8 Network

+
    +
  • Keep NATS off vmbr0. No change.
  • +
  • When on metal: dedicated NIC or VLAN for cluster :6222 +vs client :4222 if possible (replication vs client +load).
  • +
  • Check virtio queue counts on the LXC nics if core 1 KiB byte rate +plateaus.
  • +
+

5.9 Security cost (when +nkeys/mTLS flip)

+

verae-nats-accounts is still a sketch. Enabling accounts +will add CPU on publish. Budget: re-run this exact study +after creds are in every NATS_URL, and +accept a drop on both core and JS. Do not flip without that +measurement.

+

5.10 Operational +fine-tuning (lag, not peak msgs/s)

+
    +
  1. Scrape varz / jsz from the host over +vmbr1 (not public). Monitor loopback :8222 is +invisible to Prometheus on NS1 unless we add a host-side proxy on +10.10.10.21:8222 bound only to vmbr1.
  2. +
  3. Keep replica floors for webhook-deliver and job-poller — they are +the flood defense.
  4. +
  5. Backup/restore drill of JetStream during idle, then +a short JS 1p run to see catchup cost.
  6. +
  7. A 15–30 minute soak (not in this ladder) for page cache and +compaction; add that as a third study when disks are dedicated.
  8. +
+

5.11 What not to tune

+
    +
  • Do not chase core 8p8s aggregate. It is fan-out on a lab +bridge.
  • +
  • Do not treat flood 400 ms as “cluster RTT.” Fix consumers.
  • +
  • Do not load-test on ZAPIER_* streams.
  • +
  • Do not bind client NATS to 0.0.0.0 on +vmbr0.
  • +
+ +
    +
  1. CPU pin nats-a/b/c → re-run JS 1p + ping.
  2. +
  3. ZAPIER_EVENTS-shaped memory stream vs file (throwaway +stream, same flags as this JS ladder).
  4. +
  5. Distinct store_dir disks per node.
  6. +
  7. nkeys on, same ladder.
  8. +
  9. Three hardware boxes, same cluster.env IPs +updated.
  10. +
+

Each experiment should produce a new +results/<utc>/ on NS1 and a new progress-repo report +so we can diff H1–H5 instead of arguing from memory.

+
+

6. Reproducing this study

+

On NS1 only:

+
cd ~/verae-src/verae-nats-cluster
+bash scripts/study-on-ns1.sh
+

The script exits if hostname is not NS1. Outputs land in +results/<utc>/ including +nats-cluster-bench-ns1.{md,html,pdf} and +charts/. Copy those into +zapier-decisions/reports/ for the progress repo and +catalog.

+

Raw logs for this run: results/20260912T053120Z/.

+ + diff --git a/results/20260912T053120Z/nats-cluster-bench-ns1.md b/results/20260912T053120Z/nats-cluster-bench-ns1.md new file mode 100644 index 0000000..b0d8c69 --- /dev/null +++ b/results/20260912T053120Z/nats-cluster-bench-ns1.md @@ -0,0 +1,355 @@ +**Progress report (maximized NS1 study)** · run `20260912T053120Z` (UTC) + +> **Execution provenance.** Every process for this study ran on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`): `maximize-ns1-study.sh` (cores/RAM/`max_mem`/tmpfs), then `study-on-ns1.sh`, `nats bench`, `latency.mjs` (LXC 510), matplotlib, pandoc, weasyprint. Traffic stayed on `vmbr1`. veth/10G was **not** changed. After the ladder, JetStream was put back on ZFS and product streams were re-created; **8 cores / 16 GiB / max_mem 8G stay**. + +## Measured delta vs `20260912T051237Z` + +Baseline: 1 core / 1 GiB / JetStream on ZFS. This run: 8 cores / 16 GiB / JetStream **tmpfs** (file r=3) plus extra **memory** store rows. veth/10G unchanged. + +| Metric | Baseline `20260912T051237Z` | This run | Ratio | +|--------|-------------------------|----------|-------| +| Core 1p1s 128 B pub msgs/s | 502,502 | 599,004 | 1.19× | +| Core 8p8s 128 B aggregate msgs/s | 2,065,217 | 1,998,733 | 0.97× | +| JS file r=3 1p 128 B pub msgs/s | 7,393 | 17,388 | 2.35× | +| JS file r=3 4p 128 B pub msgs/s | 17,986 | 22,853 | 1.27× | +| JS file r=3 4p 1 KiB pub msgs/s | 14,985 | 18,114 | 1.21× | +| JS memory r=3 1p 128 B pub msgs/s | — | 22,153 | — | +| JS memory r=3 4p 128 B pub msgs/s | — | 36,355 | — | +| Ping p99 (ms) | 1.377ms | 0.684ms | 2.01× faster | + +![Baseline vs maximized publish rates (log)](charts/delta-vs-baseline.png) + +*Baseline vs maximized publish rates (log)* + + +--- + +## 1. Executive summary + +| Item | This NS1-host run | +|------|-------------------| +| Control plane | NS1.GEORGELAMBERT.ORG (`70.88.205.138`), user `marchon` | +| Bench client | LXC 510 `verae-px-worker` | +| Brokers | LXC 511/512/513 `nats-a/b/c` on `10.10.10.21–23` | +| Client URL | `nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222` | +| Host load before | `8.77 8.39 8.26 6/3849 198471` | +| Host load after | `9.02 8.82 8.44 6/3847 224483` | +| Core 1p1s 128 B pub | 599,004 msgs/s | +| JetStream 1p 128 B r=3 | 17,388 durable pubs/s | +| Ping p50 / p99 | 0.299ms / 0.684ms | + +Product traffic is the JetStream row. Ping is one-message delay. Flood is mailbox catch-up after a burst. + +--- + +## 2. Where it ran (and where it did not) + +```text +Operator laptop ──ssh──► NS1.GEORGELAMBERT.ORG 70.88.205.138 + study-on-ns1.sh + python3 build-ns1-study-report.py + sudo pct exec 510 ──► nats bench / latency.mjs + │ + ▼ vmbr1 + 10.10.10.21-23 :4222 +``` + +- **Did run on 138:** bash, python3, matplotlib, pandoc, weasyprint, `pct`, nats-server (in LXC), nats CLI and Node (in LXC 510). +- **Did not run on the laptop:** no local `nats bench`, no local charting, no local WeasyPrint for this file. + +--- + +## 3. Results (this run) + +### Host and brokers + +**Before** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 3 | 3,013 | 3,037 | 1 | 8 | 14,553,088 | True | +| nats-b | 512 | 3 | 1,415 | 1,424 | 0 | 1 | 13,557,760 | True | +| nats-c | 513 | 0 | 1,361 | 1,395 | 1 | 1 | 14,028,800 | True | + +**After** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 3 | 644,730 | 1,344,768 | 2 | 8 | 67,104,768 | True | +| nats-b | 512 | 3 | 491,295 | 978,800 | 1 | 1 | 82,542,592 | True | +| nats-c | 513 | 0 | 668,544 | 1,330,940 | 0 | 1 | 37,314,560 | True | + +nproc=40 · uname=`Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux` + +### Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 810,988 | 599,004 | 73.12 | 456,116 | 55.68 | +| `core-4p4s-100k-128` | core pub/sub | 1,361,921 | 379,346 | 46.31 | 1,150,536 | 140.45 | +| `core-4p4s-50k-1k` | core pub/sub | 695,192 | 175,725 | 171.61 | 584,024 | 570.34 | +| `core-8p8s-200k-128` | core pub/sub | 1,998,733 | 283,259 | 34.58 | 1,780,589 | 217.36 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 17,388 | 2.12 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 19,876 | 9,959 | 1.22 | 9,942 | 1.21 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 18,114 | 17.69 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 22,853 | 2.79 | — | — | +| `js-mem-1p-20k-128-r3` | jetstream r=3 file | — | 22,153 | 2.70 | — | — | +| `js-mem-4p-20k-1k-r3` | jetstream r=3 file | — | 28,685 | 28.01 | — | — | +| `js-mem-4p-50k-128-r3` | jetstream r=3 file | — | 36,355 | 4.44 | — | — | + +### Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.250ms | 0.319ms | 0.299ms | 0.363ms | 0.684ms | 2.759ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 94.920ms | 132.026ms | 131.990ms | 156.086ms | 158.860ms | 158.965ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 113.281ms | 132.425ms | 131.457ms | 140.317ms | 144.683ms | 145.365ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 153.318ms | 204.329ms | 205.865ms | 234.151ms | 237.346ms | 237.477ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 233.727ms | 310.924ms | 311.170ms | 373.437ms | 400.764ms | 402.811ms | + +### Core NATS + +![Core NATS throughput at four loads (NS1 host run)](charts/core-throughput.png) + +*Core NATS throughput at four loads (NS1 host run)* +### Payload size (core) + +![Core NATS 128 B vs 1 KiB (NS1 host run)](charts/payload-size.png) + +*Core NATS 128 B vs 1 KiB (NS1 host run)* +### JetStream r=3 file + +![JetStream durable publish rate (NS1 host run)](charts/js-throughput.png) + +*JetStream durable publish rate (NS1 host run)* +### Core vs JetStream + +![Core vs JetStream publish rate, log scale (NS1 host run)](charts/core-vs-js.png) + +*Core vs JetStream publish rate, log scale (NS1 host run)* +### Delay + +![Ping vs flood delay percentiles, log scale (NS1 host run)](charts/delay-percentiles.png) + +*Ping vs flood delay percentiles, log scale (NS1 host run)* + +--- + +## 4. Study methodology + +### 4.1 Question + +On the NS1 test stand, what message **throughput** and **delay** does the three-node `verae` JetStream cluster deliver at several loads, and which part of the stack is the limiter for product traffic (jobs, events, webhooks, archive)? + +### 4.2 Hypotheses (stated before the run) + +1. **H1 — Core vs JetStream.** Fire-and-forget core NATS is at least an order of magnitude faster than JetStream **file + replicas=3**, because durable publish waits for a majority disk replica. +2. **H2 — JetStream parallelism.** Adding publishers does **not** linearly increase JetStream write rate once the replica log is saturated. +3. **H3 — Quiet delay.** Sequential pub→sub round trip on `vmbr1` is well under 1 ms p99 when the consumer is waiting. +4. **H4 — Burst delay.** If publishers dump a batch before the subscriber drains, observed delay is **queueing time**, roughly linear in backlog, not in cluster hop count. +5. **H5 — Payload.** Moving 128 B → 1 KiB lowers message rate and raises byte rate on core NATS; JetStream in this size band stays replica/fsync bound. + +### 4.3 Independent variables (what we changed) + +| Factor | Levels | +|--------|--------| +| Transport | Core NATS pub/sub vs JetStream file replicas=3 | +| Publisher count | 1, 2, 4, 8 | +| Subscriber count | 0 (JS publish-only), 1, 2, 4, 8 | +| Message count | 1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step) | +| Payload | 128 B, 1024 B | +| Delay mode | **ping** (publish, wait, repeat) vs **flood** (publish all, then drain) | + +### 4.4 Dependent variables (what we recorded) + +| Metric | Instrument | Unit | +|--------|------------|------| +| Publish rate | `nats bench` 0.1.6 Pub stats | msgs/s, MB/s | +| Subscribe rate | `nats bench` Sub stats | msgs/s, MB/s | +| Aggregate | `nats bench` NATS Pub/Sub stats | msgs/s (fan-out counts both sides) | +| Publisher spread | nats min/avg/max **msgs/s** | not delay | +| One-way-ish RTT | `latency.mjs` header timestamp | min, avg, p50, p90, p99, max | +| Host load | `/proc/loadavg` before and after | load average | +| Broker counters | `http://127.0.0.1:8222/varz` inside each nats LXC | connections, in/out msgs, cpu, mem | + +**Important:** nats CLI 0.1.6 min/avg/max are **rate spread across publishers**, not microseconds of delay. Delay is only `latency.mjs`. + +### 4.5 Controls and constants + +- Cluster name `verae`, three routes, client `:4222`, cluster `:6222`, monitor loopback `:8222`. +- Client URL always the three-node list on `vmbr1` (never host `127.0.0.1:4222`, never `vmbr0`). +- Bench client is LXC **510**, not a nats-* server. +- JetStream bench stream name `benchstream`, **file** storage, **replicas=3**, deleted between JS loads (`nats stream rm --force`) so names do not collide. +- Product streams were **not** the bench target (no load test on `ZAPIER_*` / `VERAE_ARCHIVE`). +- No TLS, no nkeys, no account isolation (isolation is `vmbr1`). +- Same nats CLI version (0.1.6) and `nats@2` Node client as the first ladder. + +### 4.6 Procedure + +1. Confirm this script is executing on **NS1.GEORGELAMBERT.ORG**. Refuse otherwise. +2. Snapshot host load, memory, LXC configs, and each nats `varz`. +3. From NS1, `pct exec 510` the core ladder (1p1s, 4p4s, 8p8s at 128 B; 4p4s at 1 KiB). +4. Delete `benchstream`; JS ladder (1p, 4p, 4p×1 KiB, 2p2s pull) at replicas=3 file. +5. Copy `latency.mjs` into 510; ping then flood at several batch sizes. +6. Snapshot host/`varz` again. +7. Parse logs on **this host**; draw charts; write HTML and PDF on **this host**. + +No publish, subscribe, chart, or PDF process runs on the operator laptop for this study. + +### 4.7 Instrumentation path + +```text +[NS1 host 70.88.205.138] + study-on-ns1.sh (bash + python3) + | + | sudo pct exec 510 + v +[LXC 510 verae-px-worker 10.10.10.20] + nats bench / node latency.mjs + | + | NATS client protocol to + v +[LXC 511/512/513 10.10.10.21-23 :4222] + nats-server -js cluster routes :6222 +``` + +The hypervisor issues the guest commands. The messages themselves never leave `vmbr1`. + +### 4.8 Threats to validity + +| Threat | Effect on numbers | +|--------|-------------------| +| **One physical host** | Three “replicas” share CPU, memory, and usually the same datastore. This measures process/LXC HA, not disk HA. | +| **Shared load** | NS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. Load average during a run is part of the result, not noise to ignore. | +| **Single bench client** | All publishers live in 510. Per-publisher rate spread is contention in that guest. | +| **Short runs** | Seconds of traffic. No compaction, no multi-hour page-cache eviction, no snapshot during load. | +| **No TLS/nkeys** | Production auth will cost CPU. Do not treat these rates as post-nkeys rates. | +| **Fan-out aggregate** | Core aggregate msgs/s counts pub+sub. Do not compare that column to JetStream unique writes. | +| **Flood ≠ RTT** | Mixing flood averages with ping p99 produces a fake “NATS is slow” story. | +| **Lab only** | Not a Zapier HTTPS bench and not live `api.veraetime.net`. | + +### 4.9 Ethics / safety + +Bench uses throwaway subjects (`bench.core.*`, `bench.js.*`, `bench.lat.*`) and a throwaway stream. It does not purge product streams. Zapier cloud has no NATS socket. + +--- + +## 5. Suggestions for fine-tuning + +These follow from the method and from the first ladder on this stand (JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of ms). Apply in order of leverage. Re-run **this NS1 study** after each change so the delta is measured the same way. + +### 5.1 Treat JetStream as the product limiter + +Product jobs/events/webhooks/archive are durable. Tuning core NATS to 2M msgs/s will not move a timestamp Zap. Put effort into **replica write path** and **consumer lag**, not core fan-out. + +### 5.2 Split storage class by stream + +| Stream | Suggested store | Why | +|--------|-----------------|-----| +| `ZAPIER_JOBS` | file, r=3 | Work queue; lose-a-job is bad | +| `ZAPIER_EVENTS` | file r=3, or memory r=3 if events are rebuildable from job status | Hot waiters; measure both | +| `ZAPIER_WEBHOOKS` | file, r=3, workqueue | HTTPS to Zapier is the slow consumer | +| `ZAPIER_USAGE` | file, r=3, limits + max-age | Telemetry | +| `VERAE_ARCHIVE` | file, r=3, on the **best disk** | Puts are larger and must survive | + +Try `ZAPIER_EVENTS` as memory store in a maintenance window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms and durable events still ack at a higher rate, keep it; if a CT restart drops in-flight waiters, revert. + +### 5.3 Give JetStream real disks + +Today r=3 on three LXC guests on **one Proxmox host** is three files, one failure domain. + +- Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) into each nats LXC `store_dir`. +- Set `sync: always` only on archive if you need it; default sync is often enough for jobs and is faster. Measure. +- Do not put JetStream `store_dir` on the same busy rootfs as Forgejo/Caddy if we can avoid it. +- When moving to three metal boxes: same configs, private NIC, one disk (or mirror) **per node**. That is the first change that makes r=3 mean “two boxes can die.” + +### 5.4 Isolate the nats CTs from the rest of NS1 + +Host load on this box is often already several. Pin: + +- `nats-a/b/c`: dedicated cores, no steal from keep/fleet Node processes. +- Memory high enough that file-backed streams stay cache-hot for the working set. +- `cpuunits` / cpuset in `pct config` so a Zapier-facing Node GC pause does not stall fsync. + +Re-run this study after pinning; H1/H2 should move more than ping. + +### 5.5 Consumer and mailbox tuning (delay H4) + +Flood delay is backlog / consume_rate. Fine-tune the **waiters**, not the broker RTT. + +- `jobs.events` and `webhooks.deliver`: raise `max_ack_pending` so a slow HTTPS hook does not stall the whole consumer; cap it so a poison message cannot unbounded-buffer RAM. +- Pull consumers: larger batch, shorter `expires`, more pullers horizontally (fleet replica floors) instead of one fat subscriber. +- Middleware should **not** flood-publish then wait; it already does per-job publish. Keep that. The flood test is the outage profile when a consumer is stopped. +- Alert on **consumer lag** (pending + ack pending) from JetStream, not on ping RTT. + +### 5.6 Publisher-side batching in middleware + +A timestamp job is one small JSON. 16k msgs/s is ample. Still: + +- Avoid per-byte publishes; one message per job/event. +- Reuse NATS connections (connection churn showed up as publisher spread in the core 4p/8p runs). +- Idempotent `msg id` / duplicate window sized to Verae retry window, not default-only. + +### 5.7 nats-server knobs worth measuring (A/B with this script) + +| Knob | Why try it | +|------|------------| +| `max_payload` | Keep default unless archive puts grow | +| `write_deadline` | Slow consumer protection for webhooks | +| `max_pending` | Bound memory on a stuck Zapier hook | +| `max_connections` | Fleet workers + keep + middleware | +| JetStream `max_file_store` / `max_memory_store` | Prevent one stream from filling the CT | +| `max_outstanding_catchup` | Replica restart after a nats-c blip | +| GOMAXPROCS = LXC cores | Do not overthread a 2-core CT | + +Change **one** knob, re-run `study-on-ns1.sh`, compare JetStream 1p 128 B and ping p99. + +### 5.8 Network + +- Keep NATS off `vmbr0`. No change. +- When on metal: dedicated NIC or VLAN for cluster `:6222` vs client `:4222` if possible (replication vs client load). +- Check virtio queue counts on the LXC nics if core 1 KiB byte rate plateaus. + +### 5.9 Security cost (when nkeys/mTLS flip) + +`verae-nats-accounts` is still a sketch. Enabling accounts will add CPU on publish. Budget: re-run this exact study **after** creds are in every `NATS_URL`, and accept a drop on both core and JS. Do not flip without that measurement. + +### 5.10 Operational fine-tuning (lag, not peak msgs/s) + +1. Scrape `varz` / `jsz` from the host over `vmbr1` (not public). Monitor loopback `:8222` is invisible to Prometheus on NS1 unless we add a host-side proxy on `10.10.10.21:8222` bound only to `vmbr1`. +2. Keep replica floors for webhook-deliver and job-poller — they are the flood defense. +3. Backup/restore drill of JetStream **during idle**, then a short JS 1p run to see catchup cost. +4. A 15–30 minute soak (not in this ladder) for page cache and compaction; add that as a third study when disks are dedicated. + +### 5.11 What not to tune + +- Do not chase core 8p8s aggregate. It is fan-out on a lab bridge. +- Do not treat flood 400 ms as “cluster RTT.” Fix consumers. +- Do not load-test on `ZAPIER_*` streams. +- Do not bind client NATS to `0.0.0.0` on `vmbr0`. + +### 5.12 Recommended next experiments (same method, one change each) + +1. CPU pin nats-a/b/c → re-run JS 1p + ping. +2. `ZAPIER_EVENTS`-shaped memory stream vs file (throwaway stream, same flags as this JS ladder). +3. Distinct `store_dir` disks per node. +4. nkeys on, same ladder. +5. Three hardware boxes, same `cluster.env` IPs updated. + +Each experiment should produce a new `results//` on NS1 and a new progress-repo report so we can diff H1–H5 instead of arguing from memory. + + +--- + +## 6. Reproducing this study + +On **NS1 only**: + +```bash +cd ~/verae-src/verae-nats-cluster +bash scripts/study-on-ns1.sh +``` + +The script exits if `hostname` is not NS1. Outputs land in `results//` including `nats-cluster-bench-ns1.{md,html,pdf}` and `charts/`. Copy those into `zapier-decisions/reports/` for the progress repo and catalog. + +Raw logs for this run: `results/20260912T053120Z/`. diff --git a/results/20260912T053120Z/nats-cluster-bench-ns1.pdf b/results/20260912T053120Z/nats-cluster-bench-ns1.pdf new file mode 100644 index 0000000..634bd81 Binary files /dev/null and b/results/20260912T053120Z/nats-cluster-bench-ns1.pdf differ diff --git a/results/20260912T053120Z/varz-after.json b/results/20260912T053120Z/varz-after.json new file mode 100644 index 0000000..b2f70c8 --- /dev/null +++ b/results/20260912T053120Z/varz-after.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 3, + "in_msgs": 644730, + "out_msgs": 1344768, + "in_bytes": 178180385, + "out_bytes": 317368503, + "cpu": 2, + "cores": 8, + "mem": 67104768, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 3, + "in_msgs": 491295, + "out_msgs": 978800, + "in_bytes": 140248328, + "out_bytes": 203296096, + "cpu": 1, + "cores": 1, + "mem": 82542592, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 0, + "in_msgs": 668544, + "out_msgs": 1330940, + "in_bytes": 199016161, + "out_bytes": 316279225, + "cpu": 0, + "cores": 1, + "mem": 37314560, + "jetstream": true + } +] diff --git a/results/20260912T053120Z/varz-before.json b/results/20260912T053120Z/varz-before.json new file mode 100644 index 0000000..0e6e3a1 --- /dev/null +++ b/results/20260912T053120Z/varz-before.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 3, + "in_msgs": 3013, + "out_msgs": 3037, + "in_bytes": 177854, + "out_bytes": 203258, + "cpu": 1, + "cores": 8, + "mem": 14553088, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 3, + "in_msgs": 1415, + "out_msgs": 1424, + "in_bytes": 126181, + "out_bytes": 72041, + "cpu": 0, + "cores": 1, + "mem": 13557760, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 0, + "in_msgs": 1361, + "out_msgs": 1395, + "in_bytes": 108748, + "out_bytes": 69591, + "cpu": 1, + "cores": 1, + "mem": 14028800, + "jetstream": true + } +] diff --git a/results/20260912T055851Z/BENCH.md b/results/20260912T055851Z/BENCH.md new file mode 100644 index 0000000..4a47407 --- /dev/null +++ b/results/20260912T055851Z/BENCH.md @@ -0,0 +1,58 @@ +# NATS cluster message speed + +Run **`20260912T055851Z`** (UTC). Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only). + +Client URL: + +```text +nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +``` + +## Method + +- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack. +- **JetStream** uses **file** storage and **replicas=3** (same as product streams). The unique stream `benchstream` is deleted between JS loads. +- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay. +- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster. +- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. This is **queueing under burst**, not wire RTT. +- Probe: `scripts/latency.mjs` (two connections, header timestamp). + +## Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 835,602 | 662,227 | 80.84 | 472,239 | 57.65 | +| `core-4p4s-100k-128` | core pub/sub | 1,597,284 | 692,109 | 84.49 | 1,286,093 | 156.99 | +| `core-4p4s-50k-1k` | core pub/sub | 595,555 | 189,939 | 185.49 | 479,898 | 468.65 | +| `core-8p8s-200k-128` | core pub/sub | 1,760,599 | 223,078 | 27.23 | 1,570,171 | 191.67 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 14,330 | 1.75 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 18,449 | 9,252 | 1.13 | 9,228 | 1.13 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 15,197 | 14.84 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 19,232 | 2.35 | — | — | +| `js-file-1p-20k-128-r1` | jetstream r=3 file | — | 18,888 | 2.31 | — | — | +| `js-file-1p-20k-4k-r3` | jetstream r=3 file | — | 8,673 | 33.88 | — | — | +| `js-file-4p-50k-128-r1` | jetstream r=3 file | — | 24,560 | 3.00 | — | — | +| `js-mem-1p-20k-128-r1` | jetstream r=3 file | — | 29,972 | 3.66 | — | — | +| `js-mem-1p-20k-128-r3` | jetstream r=3 file | — | 20,188 | 2.46 | — | — | +| `js-mem-4p-20k-1k-r3` | jetstream r=3 file | — | 33,916 | 33.12 | — | — | +| `js-mem-4p-50k-128-r1` | jetstream r=3 file | — | 64,923 | 7.93 | — | — | +| `js-mem-4p-50k-128-r3` | jetstream r=3 file | — | 37,736 | 4.61 | — | — | + +## Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.340ms | 0.530ms | 0.456ms | 0.827ms | 1.140ms | 2.910ms | +| `lat-reconnect-200-128` | flood (burst queueing) | 200 | 1 | 128 B | 0.366ms | 0.540ms | 0.503ms | 0.619ms | 1.750ms | 3.206ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 98.399ms | 124.452ms | 125.413ms | 140.165ms | 145.770ms | 146.002ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 125.336ms | 157.915ms | 157.392ms | 176.826ms | 178.015ms | 178.776ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 157.463ms | 205.870ms | 207.044ms | 237.035ms | 239.449ms | 240.473ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 230.898ms | 361.458ms | 372.512ms | 443.799ms | 458.198ms | 460.270ms | + +## What the numbers mean + +Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about **16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers. + +A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers. + +Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`. diff --git a/results/20260912T055851Z/charts-optimal/history-js1p.png b/results/20260912T055851Z/charts-optimal/history-js1p.png new file mode 100644 index 0000000..69de19e Binary files /dev/null and b/results/20260912T055851Z/charts-optimal/history-js1p.png differ diff --git a/results/20260912T055851Z/charts-optimal/replicas.png b/results/20260912T055851Z/charts-optimal/replicas.png new file mode 100644 index 0000000..9779f10 Binary files /dev/null and b/results/20260912T055851Z/charts-optimal/replicas.png differ diff --git a/results/20260912T055851Z/charts/core-throughput.png b/results/20260912T055851Z/charts/core-throughput.png new file mode 100644 index 0000000..1e0fc48 Binary files /dev/null and b/results/20260912T055851Z/charts/core-throughput.png differ diff --git a/results/20260912T055851Z/charts/core-vs-js.png b/results/20260912T055851Z/charts/core-vs-js.png new file mode 100644 index 0000000..d85d9d6 Binary files /dev/null and b/results/20260912T055851Z/charts/core-vs-js.png differ diff --git a/results/20260912T055851Z/charts/delay-percentiles.png b/results/20260912T055851Z/charts/delay-percentiles.png new file mode 100644 index 0000000..e1c9999 Binary files /dev/null and b/results/20260912T055851Z/charts/delay-percentiles.png differ diff --git a/results/20260912T055851Z/charts/delta-vs-baseline.png b/results/20260912T055851Z/charts/delta-vs-baseline.png new file mode 100644 index 0000000..fe17ded Binary files /dev/null and b/results/20260912T055851Z/charts/delta-vs-baseline.png differ diff --git a/results/20260912T055851Z/charts/js-throughput.png b/results/20260912T055851Z/charts/js-throughput.png new file mode 100644 index 0000000..5f23d3d Binary files /dev/null and b/results/20260912T055851Z/charts/js-throughput.png differ diff --git a/results/20260912T055851Z/charts/payload-size.png b/results/20260912T055851Z/charts/payload-size.png new file mode 100644 index 0000000..ab15961 Binary files /dev/null and b/results/20260912T055851Z/charts/payload-size.png differ diff --git a/results/20260912T055851Z/core-1p1s-50k-128.csv b/results/20260912T055851Z/core-1p1s-50k-128.csv new file mode 100644 index 0000000..da12905 --- /dev/null +++ b/results/20260912T055851Z/core-1p1s-50k-128.csv @@ -0,0 +1,3 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +aWd091p1c7cXCtuUvyZSxt,S0,50000,6400000,472239,60446688.561339,0.105878 +aWd091p1c7cXCtuUvyZSxt,P0,50000,6400000,662227,84765130.062490,0.075503 diff --git a/results/20260912T055851Z/core-1p1s-50k-128.txt b/results/20260912T055851Z/core-1p1s-50k-128.txt new file mode 100644 index 0000000..147650f --- /dev/null +++ b/results/20260912T055851Z/core-1p1s-50k-128.txt @@ -0,0 +1,7 @@ +=== core-1p1s-50k-128 === + +NATS Pub/Sub stats: 835,602 msgs/sec ~ 102.00 MB/sec + Pub stats: 662,227 msgs/sec ~ 80.84 MB/sec + Sub stats: 472,239 msgs/sec ~ 57.65 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/core-4p4s-100k-128.csv b/results/20260912T055851Z/core-4p4s-100k-128.csv new file mode 100644 index 0000000..bea9190 --- /dev/null +++ b/results/20260912T055851Z/core-4p4s-100k-128.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +MH2k3yW0YONZD0HiNpchpL,S0,100000,12800000,409118,52367181.312369,0.244428 +MH2k3yW0YONZD0HiNpchpL,S1,100000,12800000,321711,41179038.222383,0.310838 +MH2k3yW0YONZD0HiNpchpL,S2,100000,12800000,321716,41179732.151708,0.310833 +MH2k3yW0YONZD0HiNpchpL,S3,100000,12800000,321573,41161354.894041,0.310971 +MH2k3yW0YONZD0HiNpchpL,P0,25000,3200000,264684,33879607.417508,0.094452 +MH2k3yW0YONZD0HiNpchpL,P1,25000,3200000,243211,31131121.677273,0.102791 +MH2k3yW0YONZD0HiNpchpL,P2,25000,3200000,183537,23492805.654965,0.136212 +MH2k3yW0YONZD0HiNpchpL,P3,25000,3200000,173063,22152095.916748,0.144456 diff --git a/results/20260912T055851Z/core-4p4s-100k-128.txt b/results/20260912T055851Z/core-4p4s-100k-128.txt new file mode 100644 index 0000000..c760ceb --- /dev/null +++ b/results/20260912T055851Z/core-4p4s-100k-128.txt @@ -0,0 +1,17 @@ +=== core-4p4s-100k-128 === + +NATS Pub/Sub stats: 1,597,284 msgs/sec ~ 194.98 MB/sec + Pub stats: 692,109 msgs/sec ~ 84.49 MB/sec + [1] 264,684 msgs/sec ~ 32.31 MB/sec (25000 msgs) + [2] 243,211 msgs/sec ~ 29.69 MB/sec (25000 msgs) + [3] 183,537 msgs/sec ~ 22.40 MB/sec (25000 msgs) + [4] 173,063 msgs/sec ~ 21.13 MB/sec (25000 msgs) + min 173,063 | avg 216,123 | max 264,684 | stddev 38,755 msgs + Sub stats: 1,286,093 msgs/sec ~ 156.99 MB/sec + [1] 409,118 msgs/sec ~ 49.94 MB/sec (100000 msgs) + [2] 321,711 msgs/sec ~ 39.27 MB/sec (100000 msgs) + [3] 321,716 msgs/sec ~ 39.27 MB/sec (100000 msgs) + [4] 321,573 msgs/sec ~ 39.25 MB/sec (100000 msgs) + min 321,573 | avg 343,529 | max 409,118 | stddev 37,867 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/core-4p4s-50k-1k.csv b/results/20260912T055851Z/core-4p4s-50k-1k.csv new file mode 100644 index 0000000..998abba --- /dev/null +++ b/results/20260912T055851Z/core-4p4s-50k-1k.csv @@ -0,0 +1,9 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +I0O2O7NFVVnTrSOBs8Q38n,S0,50000,51200000,171931,176057368.403530,0.290814 +I0O2O7NFVVnTrSOBs8Q38n,S1,50000,51200000,127850,130919309.123975,0.391081 +I0O2O7NFVVnTrSOBs8Q38n,S2,50000,51200000,120555,123448409.014747,0.414748 +I0O2O7NFVVnTrSOBs8Q38n,S3,50000,51200000,120019,122899963.083444,0.416599 +I0O2O7NFVVnTrSOBs8Q38n,P0,12500,12800000,101551,103988522.006912,0.123091 +I0O2O7NFVVnTrSOBs8Q38n,P1,12500,12800000,49245,50427332.045753,0.253831 +I0O2O7NFVVnTrSOBs8Q38n,P2,12500,12800000,48521,49686273.879592,0.257616 +I0O2O7NFVVnTrSOBs8Q38n,P3,12500,12800000,47497,48637215.616680,0.263173 diff --git a/results/20260912T055851Z/core-4p4s-50k-1k.txt b/results/20260912T055851Z/core-4p4s-50k-1k.txt new file mode 100644 index 0000000..a109c41 --- /dev/null +++ b/results/20260912T055851Z/core-4p4s-50k-1k.txt @@ -0,0 +1,17 @@ +=== core-4p4s-50k-1k === + +NATS Pub/Sub stats: 595,555 msgs/sec ~ 581.60 MB/sec + Pub stats: 189,939 msgs/sec ~ 185.49 MB/sec + [1] 101,551 msgs/sec ~ 99.17 MB/sec (12500 msgs) + [2] 49,245 msgs/sec ~ 48.09 MB/sec (12500 msgs) + [3] 48,521 msgs/sec ~ 47.38 MB/sec (12500 msgs) + [4] 47,497 msgs/sec ~ 46.38 MB/sec (12500 msgs) + min 47,497 | avg 61,703 | max 101,551 | stddev 23,014 msgs + Sub stats: 479,898 msgs/sec ~ 468.65 MB/sec + [1] 171,931 msgs/sec ~ 167.90 MB/sec (50000 msgs) + [2] 127,850 msgs/sec ~ 124.85 MB/sec (50000 msgs) + [3] 120,555 msgs/sec ~ 117.73 MB/sec (50000 msgs) + [4] 120,019 msgs/sec ~ 117.21 MB/sec (50000 msgs) + min 120,019 | avg 135,088 | max 171,931 | stddev 21,494 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/core-8p8s-200k-128.csv b/results/20260912T055851Z/core-8p8s-200k-128.csv new file mode 100644 index 0000000..71c694e --- /dev/null +++ b/results/20260912T055851Z/core-8p8s-200k-128.csv @@ -0,0 +1,17 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +PZHMggVrmAZAaABqkdVm12,S0,200000,25600000,221384,28337251.548719,0.903404 +PZHMggVrmAZAaABqkdVm12,S1,200000,25600000,221400,28339297.871777,0.903339 +PZHMggVrmAZAaABqkdVm12,S2,200000,25600000,221358,28333840.935200,0.903513 +PZHMggVrmAZAaABqkdVm12,S3,200000,25600000,196749,25183987.052629,1.016519 +PZHMggVrmAZAaABqkdVm12,S4,200000,25600000,196654,25171832.144418,1.017010 +PZHMggVrmAZAaABqkdVm12,S5,200000,25600000,196517,25154300.186621,1.017719 +PZHMggVrmAZAaABqkdVm12,S6,200000,25600000,196490,25150741.114236,1.017863 +PZHMggVrmAZAaABqkdVm12,S7,200000,25600000,196395,25138656.210795,1.018352 +PZHMggVrmAZAaABqkdVm12,P0,25000,3200000,169409,21684439.538250,0.147571 +PZHMggVrmAZAaABqkdVm12,P1,25000,3200000,137478,17597297.160740,0.181846 +PZHMggVrmAZAaABqkdVm12,P2,25000,3200000,109841,14059658.143542,0.227602 +PZHMggVrmAZAaABqkdVm12,P3,25000,3200000,96040,12293171.802285,0.260307 +PZHMggVrmAZAaABqkdVm12,P4,25000,3200000,75019,9602540.006267,0.333245 +PZHMggVrmAZAaABqkdVm12,P5,25000,3200000,73607,9421821.419509,0.339637 +PZHMggVrmAZAaABqkdVm12,P6,25000,3200000,27890,3569920.156650,0.896379 +PZHMggVrmAZAaABqkdVm12,P7,25000,3200000,27932,3575348.254450,0.895018 diff --git a/results/20260912T055851Z/core-8p8s-200k-128.txt b/results/20260912T055851Z/core-8p8s-200k-128.txt new file mode 100644 index 0000000..4e7cc46 --- /dev/null +++ b/results/20260912T055851Z/core-8p8s-200k-128.txt @@ -0,0 +1,25 @@ +=== core-8p8s-200k-128 === + +NATS Pub/Sub stats: 1,760,599 msgs/sec ~ 214.92 MB/sec + Pub stats: 223,078 msgs/sec ~ 27.23 MB/sec + [1] 169,409 msgs/sec ~ 20.68 MB/sec (25000 msgs) + [2] 137,478 msgs/sec ~ 16.78 MB/sec (25000 msgs) + [3] 109,841 msgs/sec ~ 13.41 MB/sec (25000 msgs) + [4] 96,040 msgs/sec ~ 11.72 MB/sec (25000 msgs) + [5] 75,019 msgs/sec ~ 9.16 MB/sec (25000 msgs) + [6] 73,607 msgs/sec ~ 8.99 MB/sec (25000 msgs) + [7] 27,890 msgs/sec ~ 3.40 MB/sec (25000 msgs) + [8] 27,932 msgs/sec ~ 3.41 MB/sec (25000 msgs) + min 27,890 | avg 89,652 | max 169,409 | stddev 46,357 msgs + Sub stats: 1,570,171 msgs/sec ~ 191.67 MB/sec + [1] 221,384 msgs/sec ~ 27.02 MB/sec (200000 msgs) + [2] 221,400 msgs/sec ~ 27.03 MB/sec (200000 msgs) + [3] 221,358 msgs/sec ~ 27.02 MB/sec (200000 msgs) + [4] 196,749 msgs/sec ~ 24.02 MB/sec (200000 msgs) + [5] 196,654 msgs/sec ~ 24.01 MB/sec (200000 msgs) + [6] 196,517 msgs/sec ~ 23.99 MB/sec (200000 msgs) + [7] 196,490 msgs/sec ~ 23.99 MB/sec (200000 msgs) + [8] 196,395 msgs/sec ~ 23.97 MB/sec (200000 msgs) + min 196,395 | avg 205,868 | max 221,400 | stddev 12,016 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/host-after.txt b/results/20260912T055851Z/host-after.txt new file mode 100644 index 0000000..5ee430b --- /dev/null +++ b/results/20260912T055851Z/host-after.txt @@ -0,0 +1,92 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T06:01:17Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=8.48 8.52 8.17 5/3860 657839 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +js_extra_memory=1 +compare_dir=/home/marchon/verae-src/verae-nats-cluster/results/20260912T051237Z +--- nats 511 max_mem --- + max_mem: 8G +--- nats 511 jetstream mount --- +jetstream on rootfs +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.3Gi 21Gi 338Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 8 +hostname: verae-px-worker +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 8 +hostname: nats-a +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 8 +hostname: nats-b +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 8 +hostname: nats-c +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T055851Z/host-before.txt b/results/20260912T055851Z/host-before.txt new file mode 100644 index 0000000..5c74a8e --- /dev/null +++ b/results/20260912T055851Z/host-before.txt @@ -0,0 +1,92 @@ +execution_host=NS1.GEORGELAMBERT.ORG +execution_ip=70.88.205.138 +hostname=NS1.GEORGELAMBERT.ORG +utc=2026-09-12T05:58:55Z +whoami=marchon +pwd=/home/marchon/verae-src/verae-nats-cluster +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux +nproc=40 +loadavg=8.70 8.15 8.00 4/3842 621042 +client_vmid=510 +nats_url=nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222 +js_extra_memory=1 +compare_dir=/home/marchon/verae-src/verae-nats-cluster/results/20260912T051237Z +--- nats 511 max_mem --- + max_mem: 8G +--- nats 511 jetstream mount --- +jetstream on rootfs +--- free --- + total used free shared buff/cache available +Mem: 377Gi 38Gi 324Gi 4.3Gi 21Gi 339Gi +Swap: 8.0Gi 0B 8.0Gi +--- pct list --- +VMID Status Lock Name +100 stopped CT100 +101 stopped CT101 +102 stopped CT102 +103 stopped CT103 +104 stopped CT104 +105 stopped CT105 +106 stopped CT106 +107 stopped CT107 +108 stopped CT108 +109 stopped CT109 +110 stopped CT110 +111 stopped CT111 +112 stopped CT112 +113 stopped CT113 +114 stopped CT114 +115 stopped CT115 +116 stopped paul +117 stopped CT117 +118 stopped CT118 +119 stopped CT119 +120 stopped rustdeskserver +121 stopped CT121 +122 stopped CT122 +123 stopped CT123 +124 stopped bdross +200 stopped CT200 +201 stopped CT201 +202 stopped CT202 +300 running mail.veraetime.info +400 stopped evercycle-api +501 running pki +502 running peergos-a +503 running peergos-b +504 stopped minio +510 running verae-px-worker +511 running nats-a +512 running nats-b +513 running nats-c +2000 stopped CT2000 +2001 stopped CT2001 +2002 stopped CT2002 +--- pct config 510 --- +cores: 8 +hostname: verae-px-worker +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:BD:5D:62,ip=10.10.10.20/24,type=veth +rootfs: SSD2:subvol-510-disk-0,size=16G +swap: 512 +--- pct config 511 --- +cores: 8 +hostname: nats-a +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:CF:AC:68,ip=10.10.10.21/24,type=veth +rootfs: SSD2:subvol-511-disk-0,size=8G +swap: 256 +--- pct config 512 --- +cores: 8 +hostname: nats-b +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:42:73:A2,ip=10.10.10.22/24,type=veth +rootfs: SSD2:subvol-512-disk-0,size=8G +swap: 256 +--- pct config 513 --- +cores: 8 +hostname: nats-c +memory: 16384 +net0: name=eth0,bridge=vmbr1,gw=10.10.10.1,hwaddr=BC:24:11:1B:9B:DC,ip=10.10.10.23/24,type=veth +rootfs: SSD2:subvol-513-disk-0,size=8G +swap: 256 diff --git a/results/20260912T055851Z/js-1p-20k-128-r3.csv b/results/20260912T055851Z/js-1p-20k-128-r3.csv new file mode 100644 index 0000000..8d7501f --- /dev/null +++ b/results/20260912T055851Z/js-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +vSyjynJZ5N37lo74IJMJLi,P0,40000,2560000,14330,1834259.456665,1.395659 diff --git a/results/20260912T055851Z/js-1p-20k-128-r3.txt b/results/20260912T055851Z/js-1p-20k-128-r3.txt new file mode 100644 index 0000000..1cbfbde --- /dev/null +++ b/results/20260912T055851Z/js-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-1p-20k-128-r3 === + +Pub stats: 14,330 msgs/sec ~ 1.75 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-2p2s-20k-128-r3.csv b/results/20260912T055851Z/js-2p2s-20k-128-r3.csv new file mode 100644 index 0000000..a57c35f --- /dev/null +++ b/results/20260912T055851Z/js-2p2s-20k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +rx33qiX4N854UL4s2fDox5,S0,20234,1280000,4748,607822.981024,2.105876 +rx33qiX4N854UL4s2fDox5,S1,20236,1280000,4614,590640.769582,2.167138 +rx33qiX4N854UL4s2fDox5,P0,20000,1280000,5286,676626.786347,1.891737 +rx33qiX4N854UL4s2fDox5,P1,20000,1280000,4626,592132.585621,2.161678 diff --git a/results/20260912T055851Z/js-2p2s-20k-128-r3.txt b/results/20260912T055851Z/js-2p2s-20k-128-r3.txt new file mode 100644 index 0000000..1baa94f --- /dev/null +++ b/results/20260912T055851Z/js-2p2s-20k-128-r3.txt @@ -0,0 +1,13 @@ +=== js-2p2s-20k-128-r3 === + +NATS Pub/Sub stats: 18,449 msgs/sec ~ 2.25 MB/sec + Pub stats: 9,252 msgs/sec ~ 1.13 MB/sec + [1] 5,286 msgs/sec ~ 660.77 KB/sec (10000 msgs) + [2] 4,626 msgs/sec ~ 578.25 KB/sec (10000 msgs) + min 4,626 | avg 4,956 | max 5,286 | stddev 330 msgs + Sub stats: 9,228 msgs/sec ~ 1.13 MB/sec + [1] 4,748 msgs/sec ~ 593.58 KB/sec (10000 msgs) + [2] 4,614 msgs/sec ~ 576.80 KB/sec (10000 msgs) + min 4,614 | avg 4,681 | max 4,748 | stddev 67 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-4p-20k-1k-r3.csv b/results/20260912T055851Z/js-4p-20k-1k-r3.csv new file mode 100644 index 0000000..eba618b --- /dev/null +++ b/results/20260912T055851Z/js-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +qLXKAVGSUb1765aYzTISM7,P0,10000,5120000,6025,6169635.541481,0.829871 +qLXKAVGSUb1765aYzTISM7,P1,10000,5120000,3892,3985916.070736,1.284523 +qLXKAVGSUb1765aYzTISM7,P2,10000,5120000,3800,3891389.713965,1.315725 +qLXKAVGSUb1765aYzTISM7,P3,10000,5120000,3799,3890767.016926,1.315936 diff --git a/results/20260912T055851Z/js-4p-20k-1k-r3.txt b/results/20260912T055851Z/js-4p-20k-1k-r3.txt new file mode 100644 index 0000000..cb03523 --- /dev/null +++ b/results/20260912T055851Z/js-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-20k-1k-r3 === + +Pub stats: 15,197 msgs/sec ~ 14.84 MB/sec + [1] 6,025 msgs/sec ~ 5.88 MB/sec (5000 msgs) + [2] 3,892 msgs/sec ~ 3.80 MB/sec (5000 msgs) + [3] 3,800 msgs/sec ~ 3.71 MB/sec (5000 msgs) + [4] 3,799 msgs/sec ~ 3.71 MB/sec (5000 msgs) + min 3,799 | avg 4,379 | max 6,025 | stddev 951 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-4p-50k-128-r3.csv b/results/20260912T055851Z/js-4p-50k-128-r3.csv new file mode 100644 index 0000000..9a734aa --- /dev/null +++ b/results/20260912T055851Z/js-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +MvEG4Dg0Y0tCjCC2CMT7Lv,P0,25000,1600000,4841,619753.770533,2.581670 +MvEG4Dg0Y0tCjCC2CMT7Lv,P1,25000,1600000,4841,619744.482061,2.581709 +MvEG4Dg0Y0tCjCC2CMT7Lv,P2,25000,1600000,4834,618788.293474,2.585699 +MvEG4Dg0Y0tCjCC2CMT7Lv,P3,25000,1600000,4808,615451.351142,2.599718 diff --git a/results/20260912T055851Z/js-4p-50k-128-r3.txt b/results/20260912T055851Z/js-4p-50k-128-r3.txt new file mode 100644 index 0000000..b5b122b --- /dev/null +++ b/results/20260912T055851Z/js-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-4p-50k-128-r3 === + +Pub stats: 19,232 msgs/sec ~ 2.35 MB/sec + [1] 4,841 msgs/sec ~ 605.23 KB/sec (12500 msgs) + [2] 4,841 msgs/sec ~ 605.22 KB/sec (12500 msgs) + [3] 4,834 msgs/sec ~ 604.29 KB/sec (12500 msgs) + [4] 4,808 msgs/sec ~ 601.03 KB/sec (12500 msgs) + min 4,808 | avg 4,831 | max 4,841 | stddev 13 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-file-1p-20k-128-r1.csv b/results/20260912T055851Z/js-file-1p-20k-128-r1.csv new file mode 100644 index 0000000..9b8dc73 --- /dev/null +++ b/results/20260912T055851Z/js-file-1p-20k-128-r1.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +cPDfyUMvkcN0D8GcTijaOZ,P0,40000,2560000,18888,2417667.088651,1.058872 diff --git a/results/20260912T055851Z/js-file-1p-20k-128-r1.txt b/results/20260912T055851Z/js-file-1p-20k-128-r1.txt new file mode 100644 index 0000000..665846b --- /dev/null +++ b/results/20260912T055851Z/js-file-1p-20k-128-r1.txt @@ -0,0 +1,5 @@ +=== js-file-1p-20k-128-r1 === + +Pub stats: 18,888 msgs/sec ~ 2.31 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-file-1p-20k-4k-r3.csv b/results/20260912T055851Z/js-file-1p-20k-4k-r3.csv new file mode 100644 index 0000000..c1e686c --- /dev/null +++ b/results/20260912T055851Z/js-file-1p-20k-4k-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +uOCp1eJgt1rgkqZXjuaSZL,P0,40000,81920000,8673,35527516.943121,2.305818 diff --git a/results/20260912T055851Z/js-file-1p-20k-4k-r3.txt b/results/20260912T055851Z/js-file-1p-20k-4k-r3.txt new file mode 100644 index 0000000..39d8062 --- /dev/null +++ b/results/20260912T055851Z/js-file-1p-20k-4k-r3.txt @@ -0,0 +1,5 @@ +=== js-file-1p-20k-4k-r3 === + +Pub stats: 8,673 msgs/sec ~ 33.88 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-file-4p-50k-128-r1.csv b/results/20260912T055851Z/js-file-4p-50k-128-r1.csv new file mode 100644 index 0000000..60c0bb1 --- /dev/null +++ b/results/20260912T055851Z/js-file-4p-50k-128-r1.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +N1SqI80urXipjvUHXScoVc,P0,25000,1600000,6787,868828.734410,1.841560 +N1SqI80urXipjvUHXScoVc,P1,25000,1600000,6215,795533.902108,2.011228 +N1SqI80urXipjvUHXScoVc,P2,25000,1600000,6173,790180.313483,2.024854 +N1SqI80urXipjvUHXScoVc,P3,25000,1600000,6140,785924.339888,2.035819 diff --git a/results/20260912T055851Z/js-file-4p-50k-128-r1.txt b/results/20260912T055851Z/js-file-4p-50k-128-r1.txt new file mode 100644 index 0000000..2cd7a1f --- /dev/null +++ b/results/20260912T055851Z/js-file-4p-50k-128-r1.txt @@ -0,0 +1,10 @@ +=== js-file-4p-50k-128-r1 === + +Pub stats: 24,560 msgs/sec ~ 3.00 MB/sec + [1] 6,787 msgs/sec ~ 848.47 KB/sec (12500 msgs) + [2] 6,215 msgs/sec ~ 776.89 KB/sec (12500 msgs) + [3] 6,173 msgs/sec ~ 771.66 KB/sec (12500 msgs) + [4] 6,140 msgs/sec ~ 767.50 KB/sec (12500 msgs) + min 6,140 | avg 6,328 | max 6,787 | stddev 265 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-mem-1p-20k-128-r1.csv b/results/20260912T055851Z/js-mem-1p-20k-128-r1.csv new file mode 100644 index 0000000..1358215 --- /dev/null +++ b/results/20260912T055851Z/js-mem-1p-20k-128-r1.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +6WVyJcf2Hwp2JrsCV11Hls,P0,40000,2560000,29972,3836526.872789,0.667270 diff --git a/results/20260912T055851Z/js-mem-1p-20k-128-r1.txt b/results/20260912T055851Z/js-mem-1p-20k-128-r1.txt new file mode 100644 index 0000000..c9551b4 --- /dev/null +++ b/results/20260912T055851Z/js-mem-1p-20k-128-r1.txt @@ -0,0 +1,5 @@ +=== js-mem-1p-20k-128-r1 === + +Pub stats: 29,972 msgs/sec ~ 3.66 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-mem-1p-20k-128-r3.csv b/results/20260912T055851Z/js-mem-1p-20k-128-r3.csv new file mode 100644 index 0000000..7e677f2 --- /dev/null +++ b/results/20260912T055851Z/js-mem-1p-20k-128-r3.csv @@ -0,0 +1,2 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +PDjR792iPxIbCORKIyVqXv,P0,40000,2560000,20188,2584117.626660,0.990667 diff --git a/results/20260912T055851Z/js-mem-1p-20k-128-r3.txt b/results/20260912T055851Z/js-mem-1p-20k-128-r3.txt new file mode 100644 index 0000000..e4356da --- /dev/null +++ b/results/20260912T055851Z/js-mem-1p-20k-128-r3.txt @@ -0,0 +1,5 @@ +=== js-mem-1p-20k-128-r3 === + +Pub stats: 20,188 msgs/sec ~ 2.46 MB/sec + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-mem-4p-20k-1k-r3.csv b/results/20260912T055851Z/js-mem-4p-20k-1k-r3.csv new file mode 100644 index 0000000..001eed5 --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-20k-1k-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +aKthILJvfkpdUW1zncr8Gz,P0,10000,5120000,12732,13037881.294175,0.392702 +aKthILJvfkpdUW1zncr8Gz,P1,10000,5120000,9234,9455781.228445,0.541468 +aKthILJvfkpdUW1zncr8Gz,P2,10000,5120000,8510,8715111.398384,0.587485 +aKthILJvfkpdUW1zncr8Gz,P3,10000,5120000,8479,8683456.194797,0.589627 diff --git a/results/20260912T055851Z/js-mem-4p-20k-1k-r3.txt b/results/20260912T055851Z/js-mem-4p-20k-1k-r3.txt new file mode 100644 index 0000000..b885c60 --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-20k-1k-r3.txt @@ -0,0 +1,10 @@ +=== js-mem-4p-20k-1k-r3 === + +Pub stats: 33,916 msgs/sec ~ 33.12 MB/sec + [1] 12,732 msgs/sec ~ 12.43 MB/sec (5000 msgs) + [2] 9,234 msgs/sec ~ 9.02 MB/sec (5000 msgs) + [3] 8,510 msgs/sec ~ 8.31 MB/sec (5000 msgs) + [4] 8,479 msgs/sec ~ 8.28 MB/sec (5000 msgs) + min 8,479 | avg 9,738 | max 12,732 | stddev 1,754 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-mem-4p-50k-128-r1.csv b/results/20260912T055851Z/js-mem-4p-50k-128-r1.csv new file mode 100644 index 0000000..45e9a6b --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-50k-128-r1.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +eCPtb5w7YrtjXRblVgvC0q,P0,25000,1600000,18895,2418669.699316,0.661521 +eCPtb5w7YrtjXRblVgvC0q,P1,25000,1600000,18797,2406124.890764,0.664970 +eCPtb5w7YrtjXRblVgvC0q,P2,25000,1600000,16698,2137360.776494,0.748587 +eCPtb5w7YrtjXRblVgvC0q,P3,25000,1600000,16230,2077543.975711,0.770140 diff --git a/results/20260912T055851Z/js-mem-4p-50k-128-r1.txt b/results/20260912T055851Z/js-mem-4p-50k-128-r1.txt new file mode 100644 index 0000000..18042eb --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-50k-128-r1.txt @@ -0,0 +1,10 @@ +=== js-mem-4p-50k-128-r1 === + +Pub stats: 64,923 msgs/sec ~ 7.93 MB/sec + [1] 18,895 msgs/sec ~ 2.31 MB/sec (12500 msgs) + [2] 18,797 msgs/sec ~ 2.29 MB/sec (12500 msgs) + [3] 16,698 msgs/sec ~ 2.04 MB/sec (12500 msgs) + [4] 16,230 msgs/sec ~ 1.98 MB/sec (12500 msgs) + min 16,230 | avg 17,655 | max 18,895 | stddev 1,202 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/js-mem-4p-50k-128-r3.csv b/results/20260912T055851Z/js-mem-4p-50k-128-r3.csv new file mode 100644 index 0000000..abd9a59 --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-50k-128-r3.csv @@ -0,0 +1,5 @@ +#RunID,ClientID,MsgCount,MsgBytes,MsgsPerSec,BytesPerSec,DurationSecs +eS3TyeoEZrIgfRNGwTFVYl,P0,25000,1600000,9607,1229719.185854,1.301110 +eS3TyeoEZrIgfRNGwTFVYl,P1,25000,1600000,9606,1229695.290576,1.301135 +eS3TyeoEZrIgfRNGwTFVYl,P2,25000,1600000,9503,1216392.400793,1.315365 +eS3TyeoEZrIgfRNGwTFVYl,P3,25000,1600000,9434,1207613.746241,1.324927 diff --git a/results/20260912T055851Z/js-mem-4p-50k-128-r3.txt b/results/20260912T055851Z/js-mem-4p-50k-128-r3.txt new file mode 100644 index 0000000..e269ac2 --- /dev/null +++ b/results/20260912T055851Z/js-mem-4p-50k-128-r3.txt @@ -0,0 +1,10 @@ +=== js-mem-4p-50k-128-r3 === + +Pub stats: 37,736 msgs/sec ~ 4.61 MB/sec + [1] 9,607 msgs/sec ~ 1.17 MB/sec (12500 msgs) + [2] 9,606 msgs/sec ~ 1.17 MB/sec (12500 msgs) + [3] 9,503 msgs/sec ~ 1.16 MB/sec (12500 msgs) + [4] 9,434 msgs/sec ~ 1.15 MB/sec (12500 msgs) + min 9,434 | avg 9,537 | max 9,607 | stddev 73 msgs + +Saved metric data in csv file /tmp/bench.csv diff --git a/results/20260912T055851Z/lat-1p-5k-128.txt b/results/20260912T055851Z/lat-1p-5k-128.txt new file mode 100644 index 0000000..616b6b1 --- /dev/null +++ b/results/20260912T055851Z/lat-1p-5k-128.txt @@ -0,0 +1,2 @@ +=== lat-1p-5k-128 === +{"count":5000,"pubs":1,"size":128,"mode":"flood","min_us":98399,"avg_us":124452,"p50_us":125413,"p90_us":140165,"p99_us":145770,"max_us":146002,"min":"98.399ms","avg":"124.452ms","p50":"125.413ms","p90":"140.165ms","p99":"145.770ms","max":"146.002ms"} diff --git a/results/20260912T055851Z/lat-4p-10k-128.txt b/results/20260912T055851Z/lat-4p-10k-128.txt new file mode 100644 index 0000000..5553d91 --- /dev/null +++ b/results/20260912T055851Z/lat-4p-10k-128.txt @@ -0,0 +1,2 @@ +=== lat-4p-10k-128 === +{"count":10000,"pubs":4,"size":128,"mode":"flood","min_us":157463,"avg_us":205870,"p50_us":207044,"p90_us":237035,"p99_us":239449,"max_us":240473,"min":"157.463ms","avg":"205.870ms","p50":"207.044ms","p90":"237.035ms","p99":"239.449ms","max":"240.473ms"} diff --git a/results/20260912T055851Z/lat-4p-5k-1k.txt b/results/20260912T055851Z/lat-4p-5k-1k.txt new file mode 100644 index 0000000..5059be9 --- /dev/null +++ b/results/20260912T055851Z/lat-4p-5k-1k.txt @@ -0,0 +1,2 @@ +=== lat-4p-5k-1k === +{"count":5000,"pubs":4,"size":1024,"mode":"flood","min_us":125336,"avg_us":157915,"p50_us":157392,"p90_us":176826,"p99_us":178015,"max_us":178776,"min":"125.336ms","avg":"157.915ms","p50":"157.392ms","p90":"176.826ms","p99":"178.015ms","max":"178.776ms"} diff --git a/results/20260912T055851Z/lat-8p-20k-128.txt b/results/20260912T055851Z/lat-8p-20k-128.txt new file mode 100644 index 0000000..826164f --- /dev/null +++ b/results/20260912T055851Z/lat-8p-20k-128.txt @@ -0,0 +1,2 @@ +=== lat-8p-20k-128 === +{"count":20000,"pubs":8,"size":128,"mode":"flood","min_us":230898,"avg_us":361458,"p50_us":372512,"p90_us":443799,"p99_us":458198,"max_us":460270,"min":"230.898ms","avg":"361.458ms","p50":"372.512ms","p90":"443.799ms","p99":"458.198ms","max":"460.270ms"} diff --git a/results/20260912T055851Z/lat-ping-1k-128.txt b/results/20260912T055851Z/lat-ping-1k-128.txt new file mode 100644 index 0000000..2f3201e --- /dev/null +++ b/results/20260912T055851Z/lat-ping-1k-128.txt @@ -0,0 +1,2 @@ +=== lat-ping-1k-128 === +{"count":1000,"pubs":1,"size":128,"mode":"ping","min_us":340,"avg_us":530,"p50_us":456,"p90_us":827,"p99_us":1140,"max_us":2910,"min":"0.340ms","avg":"0.530ms","p50":"0.456ms","p90":"0.827ms","p99":"1.140ms","max":"2.910ms"} diff --git a/results/20260912T055851Z/lat-reconnect-200-128.txt b/results/20260912T055851Z/lat-reconnect-200-128.txt new file mode 100644 index 0000000..ff51aa4 --- /dev/null +++ b/results/20260912T055851Z/lat-reconnect-200-128.txt @@ -0,0 +1,2 @@ +=== lat-reconnect-200-128 === +{"count":200,"pubs":1,"size":128,"mode":"reconnect","min_us":366,"avg_us":540,"p50_us":503,"p90_us":619,"p99_us":1750,"max_us":3206,"min":"0.366ms","avg":"0.540ms","p50":"0.503ms","p90":"0.619ms","p99":"1.750ms","max":"3.206ms"} diff --git a/results/20260912T055851Z/mqtt-qos0-5k-128.txt b/results/20260912T055851Z/mqtt-qos0-5k-128.txt new file mode 100644 index 0000000..64289c5 --- /dev/null +++ b/results/20260912T055851Z/mqtt-qos0-5k-128.txt @@ -0,0 +1 @@ +{"mode":"mqtt-qos0","count":5000,"size":128,"url":"mqtt://10.10.10.21:1883","secs":0.111,"pubs_per_sec":44862,"mb_per_sec":5.74} diff --git a/results/20260912T055851Z/nats-cluster-bench-ns1.html b/results/20260912T055851Z/nats-cluster-bench-ns1.html new file mode 100644 index 0000000..5664e9b --- /dev/null +++ b/results/20260912T055851Z/nats-cluster-bench-ns1.html @@ -0,0 +1,1443 @@ + + + + + + + NATS cluster message speed — NS1 host study + + + + +
Verae Time × Zapier · progress report · maximized NS1 study

NATS cluster message speed — maximized (RAM disk + 8 cores)

packages/zapier-decisions/reports/nats-cluster-bench-ns1.md
+
+

NATS cluster message speed — NS1 host study

+
+

Progress report (maximized NS1 study) · run +20260912T055851Z (UTC)

+
+

Execution provenance. Every process for this study +ran on NS1.GEORGELAMBERT.ORG +(70.88.205.138): maximize-ns1-study.sh +(cores/RAM/max_mem/tmpfs), then +study-on-ns1.sh, nats bench, +latency.mjs (LXC 510), matplotlib, pandoc, weasyprint. +Traffic stayed on vmbr1. veth/10G was not +changed. After the ladder, JetStream was put back on ZFS and product +streams were re-created; 8 cores / 16 GiB / max_mem 8G +stay.

+
+

Measured delta vs +20260912T051237Z

+

Baseline: 1 core / 1 GiB / JetStream on ZFS. This run: 8 cores / 16 +GiB / JetStream tmpfs (file r=3) plus extra +memory store rows. veth/10G unchanged.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricBaseline 20260912T051237ZThis runRatio
Core 1p1s 128 B pub msgs/s502,502662,2271.32×
Core 8p8s 128 B aggregate msgs/s2,065,2171,760,5990.85×
JS file r=3 1p 128 B pub msgs/s7,39314,3301.94×
JS file r=3 4p 128 B pub msgs/s17,98619,2321.07×
JS file r=3 4p 1 KiB pub msgs/s14,98515,1971.01×
JS memory r=3 1p 128 B pub msgs/s20,188
JS memory r=3 4p 128 B pub msgs/s37,736
Ping p99 (ms)1.377ms1.140ms1.21× faster
+
+ + +
+

Baseline vs maximized publish rates (log)

+
+

1. Executive summary

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemThis NS1-host run
Control planeNS1.GEORGELAMBERT.ORG (70.88.205.138), user +marchon
Bench clientLXC 510 verae-px-worker
BrokersLXC 511/512/513 nats-a/b/c on +10.10.10.21–23
Client URLnats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222
Host load before8.70 8.15 8.00 4/3842 621042
Host load after8.48 8.52 8.17 5/3860 657839
Core 1p1s 128 B pub662,227 msgs/s
JetStream 1p 128 B r=314,330 durable pubs/s
Ping p50 / p990.456ms / 1.140ms
+

Product traffic is the JetStream row. Ping is one-message delay. +Flood is mailbox catch-up after a burst.

+
+

2. Where it ran (and where it +did not)

+
Operator laptop  ──ssh──►  NS1.GEORGELAMBERT.ORG  70.88.205.138
+                              study-on-ns1.sh
+                              python3 build-ns1-study-report.py
+                              sudo pct exec 510 ──► nats bench / latency.mjs
+                                                    │
+                                                    ▼ vmbr1
+                                              10.10.10.21-23 :4222
+
    +
  • Did run on 138: bash, python3, matplotlib, pandoc, +weasyprint, pct, nats-server (in LXC), nats CLI and Node +(in LXC 510).
  • +
  • Did not run on the laptop: no local +nats bench, no local charting, no local WeasyPrint for this +file.
  • +
+
+

3. Results (this run)

+

Host and brokers

+

Before

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a511531,44331,7251815,785,984True
nats-b512124,33024,4801115,892,480True
nats-c513019,05119,0340114,262,272True
+

After

+ +++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NodeVMIDconnectionsin_msgsout_msgscpucoresmem (B)jetstream
nats-a5115724,9621,400,2882827,095,040True
nats-b5121509,195671,8130136,028,416True
nats-c5130703,5081,715,9070127,295,744True
+

nproc=40 · +uname=Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux

+

Throughput

+ +++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunModeAggregate msgs/sPub msgs/sPub MB/sSub msgs/sSub MB/s
core-1p1s-50k-128core pub/sub835,602662,22780.84472,23957.65
core-4p4s-100k-128core pub/sub1,597,284692,10984.491,286,093156.99
core-4p4s-50k-1kcore pub/sub595,555189,939185.49479,898468.65
core-8p8s-200k-128core pub/sub1,760,599223,07827.231,570,171191.67
js-1p-20k-128-r3jetstream r=3 file14,3301.75
js-2p2s-20k-128-r3jetstream r=3 file18,4499,2521.139,2281.13
js-4p-20k-1k-r3jetstream r=3 file15,19714.84
js-4p-50k-128-r3jetstream r=3 file19,2322.35
js-file-1p-20k-128-r1jetstream r=3 file18,8882.31
js-file-1p-20k-4k-r3jetstream r=3 file8,67333.88
js-file-4p-50k-128-r1jetstream r=3 file24,5603.00
js-mem-1p-20k-128-r1jetstream r=3 file29,9723.66
js-mem-1p-20k-128-r3jetstream r=3 file20,1882.46
js-mem-4p-20k-1k-r3jetstream r=3 file33,91633.12
js-mem-4p-50k-128-r1jetstream r=3 file64,9237.93
js-mem-4p-50k-128-r3jetstream r=3 file37,7364.61
+

Round-trip delay

+ +++++++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunKindCountPubsSizeminavgp50p90p99max
lat-ping-1k-128ping (sequential RTT)10001128 B0.340ms0.530ms0.456ms0.827ms1.140ms2.910ms
lat-reconnect-200-128flood (burst queueing)2001128 B0.366ms0.540ms0.503ms0.619ms1.750ms3.206ms
lat-1p-5k-128flood (burst queueing)50001128 B98.399ms124.452ms125.413ms140.165ms145.770ms146.002ms
lat-4p-5k-1kflood (burst queueing)500041024 B125.336ms157.915ms157.392ms176.826ms178.015ms178.776ms
lat-4p-10k-128flood (burst queueing)100004128 B157.463ms205.870ms207.044ms237.035ms239.449ms240.473ms
lat-8p-20k-128flood (burst queueing)200008128 B230.898ms361.458ms372.512ms443.799ms458.198ms460.270ms
+

Core NATS

+
+ + +
+

Core NATS throughput at four loads (NS1 host run) ### +Payload size (core)

+
+ + +
+

Core NATS 128 B vs 1 KiB (NS1 host run) ### JetStream r=3 +file

+
+ + +
+

JetStream durable publish rate (NS1 host run) ### Core vs +JetStream

+
+ + +
+

Core vs JetStream publish rate, log scale (NS1 host run) ### +Delay

+
+ + +
+

Ping vs flood delay percentiles, log scale (NS1 host +run)

+
+

4. Study methodology

+

4.1 Question

+

On the NS1 test stand, what message throughput and +delay does the three-node verae JetStream +cluster deliver at several loads, and which part of the stack is the +limiter for product traffic (jobs, events, webhooks, archive)?

+

4.2 Hypotheses (stated before +the run)

+
    +
  1. H1 — Core vs JetStream. Fire-and-forget core NATS +is at least an order of magnitude faster than JetStream file + +replicas=3, because durable publish waits for a majority disk +replica.
  2. +
  3. H2 — JetStream parallelism. Adding publishers does +not linearly increase JetStream write rate once the +replica log is saturated.
  4. +
  5. H3 — Quiet delay. Sequential pub→sub round trip on +vmbr1 is well under 1 ms p99 when the consumer is +waiting.
  6. +
  7. H4 — Burst delay. If publishers dump a batch before +the subscriber drains, observed delay is queueing time, +roughly linear in backlog, not in cluster hop count.
  8. +
  9. H5 — Payload. Moving 128 B → 1 KiB lowers message +rate and raises byte rate on core NATS; JetStream in this size band +stays replica/fsync bound.
  10. +
+

4.3 Independent variables +(what we changed)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FactorLevels
TransportCore NATS pub/sub vs JetStream file replicas=3
Publisher count1, 2, 4, 8
Subscriber count0 (JS publish-only), 1, 2, 4, 8
Message count1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step)
Payload128 B, 1024 B
Delay modeping (publish, wait, repeat) vs +flood (publish all, then drain)
+

4.4 Dependent variables +(what we recorded)

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricInstrumentUnit
Publish ratenats bench 0.1.6 Pub statsmsgs/s, MB/s
Subscribe ratenats bench Sub statsmsgs/s, MB/s
Aggregatenats bench NATS Pub/Sub statsmsgs/s (fan-out counts both sides)
Publisher spreadnats min/avg/max msgs/snot delay
One-way-ish RTTlatency.mjs header timestampmin, avg, p50, p90, p99, max
Host load/proc/loadavg before and afterload average
Broker countershttp://127.0.0.1:8222/varz inside each nats LXCconnections, in/out msgs, cpu, mem
+

Important: nats CLI 0.1.6 min/avg/max are +rate spread across publishers, not microseconds of +delay. Delay is only latency.mjs.

+

4.5 Controls and constants

+
    +
  • Cluster name verae, three routes, client +:4222, cluster :6222, monitor loopback +:8222.
  • +
  • Client URL always the three-node list on vmbr1 (never +host 127.0.0.1:4222, never vmbr0).
  • +
  • Bench client is LXC 510, not a nats-* server.
  • +
  • JetStream bench stream name benchstream, +file storage, replicas=3, deleted +between JS loads (nats stream rm --force) so names do not +collide.
  • +
  • Product streams were not the bench target (no load +test on ZAPIER_* / VERAE_ARCHIVE).
  • +
  • No TLS, no nkeys, no account isolation (isolation is +vmbr1).
  • +
  • Same nats CLI version (0.1.6) and nats@2 Node client as +the first ladder.
  • +
+

4.6 Procedure

+
    +
  1. Confirm this script is executing on +NS1.GEORGELAMBERT.ORG. Refuse otherwise.
  2. +
  3. Snapshot host load, memory, LXC configs, and each nats +varz.
  4. +
  5. From NS1, pct exec 510 the core ladder (1p1s, 4p4s, +8p8s at 128 B; 4p4s at 1 KiB).
  6. +
  7. Delete benchstream; JS ladder (1p, 4p, 4p×1 KiB, 2p2s +pull) at replicas=3 file.
  8. +
  9. Copy latency.mjs into 510; ping then flood at several +batch sizes.
  10. +
  11. Snapshot host/varz again.
  12. +
  13. Parse logs on this host; draw charts; write HTML +and PDF on this host.
  14. +
+

No publish, subscribe, chart, or PDF process runs on the operator +laptop for this study.

+

4.7 Instrumentation path

+
[NS1 host 70.88.205.138]
+   study-on-ns1.sh  (bash + python3)
+        |
+        | sudo pct exec 510
+        v
+[LXC 510 verae-px-worker 10.10.10.20]
+   nats bench / node latency.mjs
+        |
+        | NATS client protocol to
+        v
+[LXC 511/512/513  10.10.10.21-23 :4222]
+   nats-server -js  cluster routes :6222
+

The hypervisor issues the guest commands. The messages themselves +never leave vmbr1.

+

4.8 Threats to validity

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThreatEffect on numbers
One physical hostThree “replicas” share CPU, memory, and usually the same datastore. +This measures process/LXC HA, not disk HA.
Shared loadNS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. +Load average during a run is part of the result, not noise to +ignore.
Single bench clientAll publishers live in 510. Per-publisher rate spread is contention +in that guest.
Short runsSeconds of traffic. No compaction, no multi-hour page-cache +eviction, no snapshot during load.
No TLS/nkeysProduction auth will cost CPU. Do not treat these rates as +post-nkeys rates.
Fan-out aggregateCore aggregate msgs/s counts pub+sub. Do not compare that column to +JetStream unique writes.
Flood ≠ RTTMixing flood averages with ping p99 produces a fake “NATS is slow” +story.
Lab onlyNot a Zapier HTTPS bench and not live +api.veraetime.net.
+

4.9 Ethics / safety

+

Bench uses throwaway subjects (bench.core.*, +bench.js.*, bench.lat.*) and a throwaway +stream. It does not purge product streams. Zapier cloud has no NATS +socket.

+
+

5. Suggestions for fine-tuning

+

These follow from the method and from the first ladder on this stand +(JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of +ms). Apply in order of leverage. Re-run this NS1 study +after each change so the delta is measured the same way.

+

5.1 Treat JetStream as +the product limiter

+

Product jobs/events/webhooks/archive are durable. Tuning core NATS to +2M msgs/s will not move a timestamp Zap. Put effort into replica +write path and consumer lag, not core +fan-out.

+

5.2 Split storage class by +stream

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StreamSuggested storeWhy
ZAPIER_JOBSfile, r=3Work queue; lose-a-job is bad
ZAPIER_EVENTSfile r=3, or memory r=3 if events are rebuildable from job +statusHot waiters; measure both
ZAPIER_WEBHOOKSfile, r=3, workqueueHTTPS to Zapier is the slow consumer
ZAPIER_USAGEfile, r=3, limits + max-ageTelemetry
VERAE_ARCHIVEfile, r=3, on the best diskPuts are larger and must survive
+

Try ZAPIER_EVENTS as memory store in a maintenance +window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms +and durable events still ack at a higher rate, keep it; if a CT restart +drops in-flight waiters, revert.

+

5.3 Give JetStream real disks

+

Today r=3 on three LXC guests on one Proxmox host is +three files, one failure domain.

+
    +
  • Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) +into each nats LXC store_dir.
  • +
  • Set sync: always only on archive if you need it; +default sync is often enough for jobs and is faster. Measure.
  • +
  • Do not put JetStream store_dir on the same busy rootfs +as Forgejo/Caddy if we can avoid it.
  • +
  • When moving to three metal boxes: same configs, private NIC, one +disk (or mirror) per node. That is the first change +that makes r=3 mean “two boxes can die.”
  • +
+

5.4 Isolate the nats +CTs from the rest of NS1

+

Host load on this box is often already several. Pin:

+
    +
  • nats-a/b/c: dedicated cores, no steal from keep/fleet +Node processes.
  • +
  • Memory high enough that file-backed streams stay cache-hot for the +working set.
  • +
  • cpuunits / cpuset in pct config so a +Zapier-facing Node GC pause does not stall fsync.
  • +
+

Re-run this study after pinning; H1/H2 should move more than +ping.

+

5.5 Consumer and mailbox +tuning (delay H4)

+

Flood delay is backlog / consume_rate. Fine-tune the +waiters, not the broker RTT.

+
    +
  • jobs.events and webhooks.deliver: raise +max_ack_pending so a slow HTTPS hook does not stall the +whole consumer; cap it so a poison message cannot unbounded-buffer +RAM.
  • +
  • Pull consumers: larger batch, shorter expires, more +pullers horizontally (fleet replica floors) instead of one fat +subscriber.
  • +
  • Middleware should not flood-publish then wait; it +already does per-job publish. Keep that. The flood test is the outage +profile when a consumer is stopped.
  • +
  • Alert on consumer lag (pending + ack pending) from +JetStream, not on ping RTT.
  • +
+

5.6 Publisher-side +batching in middleware

+

A timestamp job is one small JSON. 16k msgs/s is ample. Still:

+
    +
  • Avoid per-byte publishes; one message per job/event.
  • +
  • Reuse NATS connections (connection churn showed up as publisher +spread in the core 4p/8p runs).
  • +
  • Idempotent msg id / duplicate window sized to Verae +retry window, not default-only.
  • +
+

5.7 +nats-server knobs worth measuring (A/B with this script)

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KnobWhy try it
max_payloadKeep default unless archive puts grow
write_deadlineSlow consumer protection for webhooks
max_pendingBound memory on a stuck Zapier hook
max_connectionsFleet workers + keep + middleware
JetStream max_file_store / +max_memory_storePrevent one stream from filling the CT
max_outstanding_catchupReplica restart after a nats-c blip
GOMAXPROCS = LXC coresDo not overthread a 2-core CT
+

Change one knob, re-run +study-on-ns1.sh, compare JetStream 1p 128 B and ping +p99.

+

5.8 Network

+
    +
  • Keep NATS off vmbr0. No change.
  • +
  • When on metal: dedicated NIC or VLAN for cluster :6222 +vs client :4222 if possible (replication vs client +load).
  • +
  • Check virtio queue counts on the LXC nics if core 1 KiB byte rate +plateaus.
  • +
+

5.9 Security cost (when +nkeys/mTLS flip)

+

verae-nats-accounts is still a sketch. Enabling accounts +will add CPU on publish. Budget: re-run this exact study +after creds are in every NATS_URL, and +accept a drop on both core and JS. Do not flip without that +measurement.

+

5.10 Operational +fine-tuning (lag, not peak msgs/s)

+
    +
  1. Scrape varz / jsz from the host over +vmbr1 (not public). Monitor loopback :8222 is +invisible to Prometheus on NS1 unless we add a host-side proxy on +10.10.10.21:8222 bound only to vmbr1.
  2. +
  3. Keep replica floors for webhook-deliver and job-poller — they are +the flood defense.
  4. +
  5. Backup/restore drill of JetStream during idle, then +a short JS 1p run to see catchup cost.
  6. +
  7. A 15–30 minute soak (not in this ladder) for page cache and +compaction; add that as a third study when disks are dedicated.
  8. +
+

5.11 What not to tune

+
    +
  • Do not chase core 8p8s aggregate. It is fan-out on a lab +bridge.
  • +
  • Do not treat flood 400 ms as “cluster RTT.” Fix consumers.
  • +
  • Do not load-test on ZAPIER_* streams.
  • +
  • Do not bind client NATS to 0.0.0.0 on +vmbr0.
  • +
+ +
    +
  1. CPU pin nats-a/b/c → re-run JS 1p + ping.
  2. +
  3. ZAPIER_EVENTS-shaped memory stream vs file (throwaway +stream, same flags as this JS ladder).
  4. +
  5. Distinct store_dir disks per node.
  6. +
  7. nkeys on, same ladder.
  8. +
  9. Three hardware boxes, same cluster.env IPs +updated.
  10. +
+

Each experiment should produce a new +results/<utc>/ on NS1 and a new progress-repo report +so we can diff H1–H5 instead of arguing from memory.

+
+

6. Reproducing this study

+

On NS1 only:

+
cd ~/verae-src/verae-nats-cluster
+bash scripts/study-on-ns1.sh
+

The script exits if hostname is not NS1. Outputs land in +results/<utc>/ including +nats-cluster-bench-ns1.{md,html,pdf} and +charts/. Copy those into +zapier-decisions/reports/ for the progress repo and +catalog.

+

Raw logs for this run: results/20260912T055851Z/.

+ + diff --git a/results/20260912T055851Z/nats-cluster-bench-ns1.md b/results/20260912T055851Z/nats-cluster-bench-ns1.md new file mode 100644 index 0000000..ce715f0 --- /dev/null +++ b/results/20260912T055851Z/nats-cluster-bench-ns1.md @@ -0,0 +1,361 @@ +**Progress report (maximized NS1 study)** · run `20260912T055851Z` (UTC) + +> **Execution provenance.** Every process for this study ran on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`): `maximize-ns1-study.sh` (cores/RAM/`max_mem`/tmpfs), then `study-on-ns1.sh`, `nats bench`, `latency.mjs` (LXC 510), matplotlib, pandoc, weasyprint. Traffic stayed on `vmbr1`. veth/10G was **not** changed. After the ladder, JetStream was put back on ZFS and product streams were re-created; **8 cores / 16 GiB / max_mem 8G stay**. + +## Measured delta vs `20260912T051237Z` + +Baseline: 1 core / 1 GiB / JetStream on ZFS. This run: 8 cores / 16 GiB / JetStream **tmpfs** (file r=3) plus extra **memory** store rows. veth/10G unchanged. + +| Metric | Baseline `20260912T051237Z` | This run | Ratio | +|--------|-------------------------|----------|-------| +| Core 1p1s 128 B pub msgs/s | 502,502 | 662,227 | 1.32× | +| Core 8p8s 128 B aggregate msgs/s | 2,065,217 | 1,760,599 | 0.85× | +| JS file r=3 1p 128 B pub msgs/s | 7,393 | 14,330 | 1.94× | +| JS file r=3 4p 128 B pub msgs/s | 17,986 | 19,232 | 1.07× | +| JS file r=3 4p 1 KiB pub msgs/s | 14,985 | 15,197 | 1.01× | +| JS memory r=3 1p 128 B pub msgs/s | — | 20,188 | — | +| JS memory r=3 4p 128 B pub msgs/s | — | 37,736 | — | +| Ping p99 (ms) | 1.377ms | 1.140ms | 1.21× faster | + +![Baseline vs maximized publish rates (log)](charts/delta-vs-baseline.png) + +*Baseline vs maximized publish rates (log)* + + +--- + +## 1. Executive summary + +| Item | This NS1-host run | +|------|-------------------| +| Control plane | NS1.GEORGELAMBERT.ORG (`70.88.205.138`), user `marchon` | +| Bench client | LXC 510 `verae-px-worker` | +| Brokers | LXC 511/512/513 `nats-a/b/c` on `10.10.10.21–23` | +| Client URL | `nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222` | +| Host load before | `8.70 8.15 8.00 4/3842 621042` | +| Host load after | `8.48 8.52 8.17 5/3860 657839` | +| Core 1p1s 128 B pub | 662,227 msgs/s | +| JetStream 1p 128 B r=3 | 14,330 durable pubs/s | +| Ping p50 / p99 | 0.456ms / 1.140ms | + +Product traffic is the JetStream row. Ping is one-message delay. Flood is mailbox catch-up after a burst. + +--- + +## 2. Where it ran (and where it did not) + +```text +Operator laptop ──ssh──► NS1.GEORGELAMBERT.ORG 70.88.205.138 + study-on-ns1.sh + python3 build-ns1-study-report.py + sudo pct exec 510 ──► nats bench / latency.mjs + │ + ▼ vmbr1 + 10.10.10.21-23 :4222 +``` + +- **Did run on 138:** bash, python3, matplotlib, pandoc, weasyprint, `pct`, nats-server (in LXC), nats CLI and Node (in LXC 510). +- **Did not run on the laptop:** no local `nats bench`, no local charting, no local WeasyPrint for this file. + +--- + +## 3. Results (this run) + +### Host and brokers + +**Before** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 5 | 31,443 | 31,725 | 1 | 8 | 15,785,984 | True | +| nats-b | 512 | 1 | 24,330 | 24,480 | 1 | 1 | 15,892,480 | True | +| nats-c | 513 | 0 | 19,051 | 19,034 | 0 | 1 | 14,262,272 | True | + +**After** + +| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream | +|------|------|-------------|---------|----------|-----|-------|---------|-----------| +| nats-a | 511 | 5 | 724,962 | 1,400,288 | 2 | 8 | 27,095,040 | True | +| nats-b | 512 | 1 | 509,195 | 671,813 | 0 | 1 | 36,028,416 | True | +| nats-c | 513 | 0 | 703,508 | 1,715,907 | 0 | 1 | 27,295,744 | True | + +nproc=40 · uname=`Linux NS1.GEORGELAMBERT.ORG 6.17.2-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.2-1 (2025-10-21T11:55Z) x86_64 GNU/Linux` + +### Throughput + +| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s | +|-----|------|------------------|------------|----------|------------|----------| +| `core-1p1s-50k-128` | core pub/sub | 835,602 | 662,227 | 80.84 | 472,239 | 57.65 | +| `core-4p4s-100k-128` | core pub/sub | 1,597,284 | 692,109 | 84.49 | 1,286,093 | 156.99 | +| `core-4p4s-50k-1k` | core pub/sub | 595,555 | 189,939 | 185.49 | 479,898 | 468.65 | +| `core-8p8s-200k-128` | core pub/sub | 1,760,599 | 223,078 | 27.23 | 1,570,171 | 191.67 | +| `js-1p-20k-128-r3` | jetstream r=3 file | — | 14,330 | 1.75 | — | — | +| `js-2p2s-20k-128-r3` | jetstream r=3 file | 18,449 | 9,252 | 1.13 | 9,228 | 1.13 | +| `js-4p-20k-1k-r3` | jetstream r=3 file | — | 15,197 | 14.84 | — | — | +| `js-4p-50k-128-r3` | jetstream r=3 file | — | 19,232 | 2.35 | — | — | +| `js-file-1p-20k-128-r1` | jetstream r=3 file | — | 18,888 | 2.31 | — | — | +| `js-file-1p-20k-4k-r3` | jetstream r=3 file | — | 8,673 | 33.88 | — | — | +| `js-file-4p-50k-128-r1` | jetstream r=3 file | — | 24,560 | 3.00 | — | — | +| `js-mem-1p-20k-128-r1` | jetstream r=3 file | — | 29,972 | 3.66 | — | — | +| `js-mem-1p-20k-128-r3` | jetstream r=3 file | — | 20,188 | 2.46 | — | — | +| `js-mem-4p-20k-1k-r3` | jetstream r=3 file | — | 33,916 | 33.12 | — | — | +| `js-mem-4p-50k-128-r1` | jetstream r=3 file | — | 64,923 | 7.93 | — | — | +| `js-mem-4p-50k-128-r3` | jetstream r=3 file | — | 37,736 | 4.61 | — | — | + +### Round-trip delay + +| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max | +|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----| +| `lat-ping-1k-128` | ping (sequential RTT) | 1000 | 1 | 128 B | 0.340ms | 0.530ms | 0.456ms | 0.827ms | 1.140ms | 2.910ms | +| `lat-reconnect-200-128` | flood (burst queueing) | 200 | 1 | 128 B | 0.366ms | 0.540ms | 0.503ms | 0.619ms | 1.750ms | 3.206ms | +| `lat-1p-5k-128` | flood (burst queueing) | 5000 | 1 | 128 B | 98.399ms | 124.452ms | 125.413ms | 140.165ms | 145.770ms | 146.002ms | +| `lat-4p-5k-1k` | flood (burst queueing) | 5000 | 4 | 1024 B | 125.336ms | 157.915ms | 157.392ms | 176.826ms | 178.015ms | 178.776ms | +| `lat-4p-10k-128` | flood (burst queueing) | 10000 | 4 | 128 B | 157.463ms | 205.870ms | 207.044ms | 237.035ms | 239.449ms | 240.473ms | +| `lat-8p-20k-128` | flood (burst queueing) | 20000 | 8 | 128 B | 230.898ms | 361.458ms | 372.512ms | 443.799ms | 458.198ms | 460.270ms | + +### Core NATS + +![Core NATS throughput at four loads (NS1 host run)](charts/core-throughput.png) + +*Core NATS throughput at four loads (NS1 host run)* +### Payload size (core) + +![Core NATS 128 B vs 1 KiB (NS1 host run)](charts/payload-size.png) + +*Core NATS 128 B vs 1 KiB (NS1 host run)* +### JetStream r=3 file + +![JetStream durable publish rate (NS1 host run)](charts/js-throughput.png) + +*JetStream durable publish rate (NS1 host run)* +### Core vs JetStream + +![Core vs JetStream publish rate, log scale (NS1 host run)](charts/core-vs-js.png) + +*Core vs JetStream publish rate, log scale (NS1 host run)* +### Delay + +![Ping vs flood delay percentiles, log scale (NS1 host run)](charts/delay-percentiles.png) + +*Ping vs flood delay percentiles, log scale (NS1 host run)* + +--- + +## 4. Study methodology + +### 4.1 Question + +On the NS1 test stand, what message **throughput** and **delay** does the three-node `verae` JetStream cluster deliver at several loads, and which part of the stack is the limiter for product traffic (jobs, events, webhooks, archive)? + +### 4.2 Hypotheses (stated before the run) + +1. **H1 — Core vs JetStream.** Fire-and-forget core NATS is at least an order of magnitude faster than JetStream **file + replicas=3**, because durable publish waits for a majority disk replica. +2. **H2 — JetStream parallelism.** Adding publishers does **not** linearly increase JetStream write rate once the replica log is saturated. +3. **H3 — Quiet delay.** Sequential pub→sub round trip on `vmbr1` is well under 1 ms p99 when the consumer is waiting. +4. **H4 — Burst delay.** If publishers dump a batch before the subscriber drains, observed delay is **queueing time**, roughly linear in backlog, not in cluster hop count. +5. **H5 — Payload.** Moving 128 B → 1 KiB lowers message rate and raises byte rate on core NATS; JetStream in this size band stays replica/fsync bound. + +### 4.3 Independent variables (what we changed) + +| Factor | Levels | +|--------|--------| +| Transport | Core NATS pub/sub vs JetStream file replicas=3 | +| Publisher count | 1, 2, 4, 8 | +| Subscriber count | 0 (JS publish-only), 1, 2, 4, 8 | +| Message count | 1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step) | +| Payload | 128 B, 1024 B | +| Delay mode | **ping** (publish, wait, repeat) vs **flood** (publish all, then drain) | + +### 4.4 Dependent variables (what we recorded) + +| Metric | Instrument | Unit | +|--------|------------|------| +| Publish rate | `nats bench` 0.1.6 Pub stats | msgs/s, MB/s | +| Subscribe rate | `nats bench` Sub stats | msgs/s, MB/s | +| Aggregate | `nats bench` NATS Pub/Sub stats | msgs/s (fan-out counts both sides) | +| Publisher spread | nats min/avg/max **msgs/s** | not delay | +| One-way-ish RTT | `latency.mjs` header timestamp | min, avg, p50, p90, p99, max | +| Host load | `/proc/loadavg` before and after | load average | +| Broker counters | `http://127.0.0.1:8222/varz` inside each nats LXC | connections, in/out msgs, cpu, mem | + +**Important:** nats CLI 0.1.6 min/avg/max are **rate spread across publishers**, not microseconds of delay. Delay is only `latency.mjs`. + +### 4.5 Controls and constants + +- Cluster name `verae`, three routes, client `:4222`, cluster `:6222`, monitor loopback `:8222`. +- Client URL always the three-node list on `vmbr1` (never host `127.0.0.1:4222`, never `vmbr0`). +- Bench client is LXC **510**, not a nats-* server. +- JetStream bench stream name `benchstream`, **file** storage, **replicas=3**, deleted between JS loads (`nats stream rm --force`) so names do not collide. +- Product streams were **not** the bench target (no load test on `ZAPIER_*` / `VERAE_ARCHIVE`). +- No TLS, no nkeys, no account isolation (isolation is `vmbr1`). +- Same nats CLI version (0.1.6) and `nats@2` Node client as the first ladder. + +### 4.6 Procedure + +1. Confirm this script is executing on **NS1.GEORGELAMBERT.ORG**. Refuse otherwise. +2. Snapshot host load, memory, LXC configs, and each nats `varz`. +3. From NS1, `pct exec 510` the core ladder (1p1s, 4p4s, 8p8s at 128 B; 4p4s at 1 KiB). +4. Delete `benchstream`; JS ladder (1p, 4p, 4p×1 KiB, 2p2s pull) at replicas=3 file. +5. Copy `latency.mjs` into 510; ping then flood at several batch sizes. +6. Snapshot host/`varz` again. +7. Parse logs on **this host**; draw charts; write HTML and PDF on **this host**. + +No publish, subscribe, chart, or PDF process runs on the operator laptop for this study. + +### 4.7 Instrumentation path + +```text +[NS1 host 70.88.205.138] + study-on-ns1.sh (bash + python3) + | + | sudo pct exec 510 + v +[LXC 510 verae-px-worker 10.10.10.20] + nats bench / node latency.mjs + | + | NATS client protocol to + v +[LXC 511/512/513 10.10.10.21-23 :4222] + nats-server -js cluster routes :6222 +``` + +The hypervisor issues the guest commands. The messages themselves never leave `vmbr1`. + +### 4.8 Threats to validity + +| Threat | Effect on numbers | +|--------|-------------------| +| **One physical host** | Three “replicas” share CPU, memory, and usually the same datastore. This measures process/LXC HA, not disk HA. | +| **Shared load** | NS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. Load average during a run is part of the result, not noise to ignore. | +| **Single bench client** | All publishers live in 510. Per-publisher rate spread is contention in that guest. | +| **Short runs** | Seconds of traffic. No compaction, no multi-hour page-cache eviction, no snapshot during load. | +| **No TLS/nkeys** | Production auth will cost CPU. Do not treat these rates as post-nkeys rates. | +| **Fan-out aggregate** | Core aggregate msgs/s counts pub+sub. Do not compare that column to JetStream unique writes. | +| **Flood ≠ RTT** | Mixing flood averages with ping p99 produces a fake “NATS is slow” story. | +| **Lab only** | Not a Zapier HTTPS bench and not live `api.veraetime.net`. | + +### 4.9 Ethics / safety + +Bench uses throwaway subjects (`bench.core.*`, `bench.js.*`, `bench.lat.*`) and a throwaway stream. It does not purge product streams. Zapier cloud has no NATS socket. + +--- + +## 5. Suggestions for fine-tuning + +These follow from the method and from the first ladder on this stand (JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of ms). Apply in order of leverage. Re-run **this NS1 study** after each change so the delta is measured the same way. + +### 5.1 Treat JetStream as the product limiter + +Product jobs/events/webhooks/archive are durable. Tuning core NATS to 2M msgs/s will not move a timestamp Zap. Put effort into **replica write path** and **consumer lag**, not core fan-out. + +### 5.2 Split storage class by stream + +| Stream | Suggested store | Why | +|--------|-----------------|-----| +| `ZAPIER_JOBS` | file, r=3 | Work queue; lose-a-job is bad | +| `ZAPIER_EVENTS` | file r=3, or memory r=3 if events are rebuildable from job status | Hot waiters; measure both | +| `ZAPIER_WEBHOOKS` | file, r=3, workqueue | HTTPS to Zapier is the slow consumer | +| `ZAPIER_USAGE` | file, r=3, limits + max-age | Telemetry | +| `VERAE_ARCHIVE` | file, r=3, on the **best disk** | Puts are larger and must survive | + +Try `ZAPIER_EVENTS` as memory store in a maintenance window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms and durable events still ack at a higher rate, keep it; if a CT restart drops in-flight waiters, revert. + +### 5.3 Give JetStream real disks + +Today r=3 on three LXC guests on **one Proxmox host** is three files, one failure domain. + +- Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) into each nats LXC `store_dir`. +- Set `sync: always` only on archive if you need it; default sync is often enough for jobs and is faster. Measure. +- Do not put JetStream `store_dir` on the same busy rootfs as Forgejo/Caddy if we can avoid it. +- When moving to three metal boxes: same configs, private NIC, one disk (or mirror) **per node**. That is the first change that makes r=3 mean “two boxes can die.” + +### 5.4 Isolate the nats CTs from the rest of NS1 + +Host load on this box is often already several. Pin: + +- `nats-a/b/c`: dedicated cores, no steal from keep/fleet Node processes. +- Memory high enough that file-backed streams stay cache-hot for the working set. +- `cpuunits` / cpuset in `pct config` so a Zapier-facing Node GC pause does not stall fsync. + +Re-run this study after pinning; H1/H2 should move more than ping. + +### 5.5 Consumer and mailbox tuning (delay H4) + +Flood delay is backlog / consume_rate. Fine-tune the **waiters**, not the broker RTT. + +- `jobs.events` and `webhooks.deliver`: raise `max_ack_pending` so a slow HTTPS hook does not stall the whole consumer; cap it so a poison message cannot unbounded-buffer RAM. +- Pull consumers: larger batch, shorter `expires`, more pullers horizontally (fleet replica floors) instead of one fat subscriber. +- Middleware should **not** flood-publish then wait; it already does per-job publish. Keep that. The flood test is the outage profile when a consumer is stopped. +- Alert on **consumer lag** (pending + ack pending) from JetStream, not on ping RTT. + +### 5.6 Publisher-side batching in middleware + +A timestamp job is one small JSON. 16k msgs/s is ample. Still: + +- Avoid per-byte publishes; one message per job/event. +- Reuse NATS connections (connection churn showed up as publisher spread in the core 4p/8p runs). +- Idempotent `msg id` / duplicate window sized to Verae retry window, not default-only. + +### 5.7 nats-server knobs worth measuring (A/B with this script) + +| Knob | Why try it | +|------|------------| +| `max_payload` | Keep default unless archive puts grow | +| `write_deadline` | Slow consumer protection for webhooks | +| `max_pending` | Bound memory on a stuck Zapier hook | +| `max_connections` | Fleet workers + keep + middleware | +| JetStream `max_file_store` / `max_memory_store` | Prevent one stream from filling the CT | +| `max_outstanding_catchup` | Replica restart after a nats-c blip | +| GOMAXPROCS = LXC cores | Do not overthread a 2-core CT | + +Change **one** knob, re-run `study-on-ns1.sh`, compare JetStream 1p 128 B and ping p99. + +### 5.8 Network + +- Keep NATS off `vmbr0`. No change. +- When on metal: dedicated NIC or VLAN for cluster `:6222` vs client `:4222` if possible (replication vs client load). +- Check virtio queue counts on the LXC nics if core 1 KiB byte rate plateaus. + +### 5.9 Security cost (when nkeys/mTLS flip) + +`verae-nats-accounts` is still a sketch. Enabling accounts will add CPU on publish. Budget: re-run this exact study **after** creds are in every `NATS_URL`, and accept a drop on both core and JS. Do not flip without that measurement. + +### 5.10 Operational fine-tuning (lag, not peak msgs/s) + +1. Scrape `varz` / `jsz` from the host over `vmbr1` (not public). Monitor loopback `:8222` is invisible to Prometheus on NS1 unless we add a host-side proxy on `10.10.10.21:8222` bound only to `vmbr1`. +2. Keep replica floors for webhook-deliver and job-poller — they are the flood defense. +3. Backup/restore drill of JetStream **during idle**, then a short JS 1p run to see catchup cost. +4. A 15–30 minute soak (not in this ladder) for page cache and compaction; add that as a third study when disks are dedicated. + +### 5.11 What not to tune + +- Do not chase core 8p8s aggregate. It is fan-out on a lab bridge. +- Do not treat flood 400 ms as “cluster RTT.” Fix consumers. +- Do not load-test on `ZAPIER_*` streams. +- Do not bind client NATS to `0.0.0.0` on `vmbr0`. + +### 5.12 Recommended next experiments (same method, one change each) + +1. CPU pin nats-a/b/c → re-run JS 1p + ping. +2. `ZAPIER_EVENTS`-shaped memory stream vs file (throwaway stream, same flags as this JS ladder). +3. Distinct `store_dir` disks per node. +4. nkeys on, same ladder. +5. Three hardware boxes, same `cluster.env` IPs updated. + +Each experiment should produce a new `results//` on NS1 and a new progress-repo report so we can diff H1–H5 instead of arguing from memory. + + +--- + +## 6. Reproducing this study + +On **NS1 only**: + +```bash +cd ~/verae-src/verae-nats-cluster +bash scripts/study-on-ns1.sh +``` + +The script exits if `hostname` is not NS1. Outputs land in `results//` including `nats-cluster-bench-ns1.{md,html,pdf}` and `charts/`. Copy those into `zapier-decisions/reports/` for the progress repo and catalog. + +Raw logs for this run: `results/20260912T055851Z/`. diff --git a/results/20260912T055851Z/nats-cluster-bench-ns1.pdf b/results/20260912T055851Z/nats-cluster-bench-ns1.pdf new file mode 100644 index 0000000..4fd4c66 Binary files /dev/null and b/results/20260912T055851Z/nats-cluster-bench-ns1.pdf differ diff --git a/results/20260912T055851Z/optimal-config.html b/results/20260912T055851Z/optimal-config.html new file mode 100644 index 0000000..e9d66d1 --- /dev/null +++ b/results/20260912T055851Z/optimal-config.html @@ -0,0 +1,853 @@ + + + + + + + NATS optimal configuration study + + + + +
Verae Time × Zapier · progress report

NATS optimal configuration study

packages/zapier-decisions/reports/optimal-config/REPORT.md
+
+

NATS optimal configuration study

+
+

Progress report — optimal configuration study · +20260912T055851Z (UTC) · all code on +NS1.GEORGELAMBERT.ORG (70.88.205.138)

+

This document folds every ladder we have run (1-core ZFS, +NS1-orchestrated, tmpfs maximize, and this exhaustive 8c/16G +ZFS factorial) plus UDP / MQTT / reconnect probes. It +recommends a lab config and a three-box HP DL360 Gen10 +projection. veth/10G was not changed.

+
+

1. Verdict (read this first)

+

Keep NATS + JetStream. Do not replace the fabric +with MQTT, UDP, or a custom persistent-socket protocol for Verae +jobs/events/archive. Those are either slower, less durable, or already +what NATS is.

+

Lab (NS1, one host, three LXC) — optimal now

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StreamStorageReplicasWhy
ZAPIER_JOBS, ZAPIER_WEBHOOKS, +VERAE_ARCHIVEfile (ZFS)3Survive a nats LXC death; archive must persist
ZAPIER_EVENTSmemory3Waiters are latency-sensitive; events rebuild from job status
ZAPIER_USAGEfile3Telemetry, limits + max-age
+

Keep 8 cores / 16 GiB / max_mem: 8G on +510–513 (already live). Do not leave JetStream on +tmpfs. Do not drop product streams to r=1. Reuse +one NATS connection per process (already true in +middleware); never connect-per-message.

+

Metal (3× DL360 Gen10) — optimal later

+

Same stream table. File store on local NVMe/M.2, not +a shared SAN. Cluster + client on 10GbE (or 25GbE if +you already have it). Dual Gold Xeon is surplus CPU for this workload; +8–16 cores dedicated to nats-server is enough. Expected JS +file r=3: ~40–80k 128 B pubs/s (about +3–6× this lab’s 8c ZFS 1p, 2–4× tmpfs +1p) — bounded by 10GbE replica RTT, not by Xeon clocks. +Core NATS will sit in the 1–3M msgs/s band until the +NIC saturates (~9 Gbit/s ≈ 8–9M × 128 B theoretical; CPU and client will +hit first).

+
+

2. What we actually +ran (this exhaustive pass)

+

Live cluster during this run: LXC 510–513 8 cores / 16 +GiB, JetStream on ZFS (tmpfs from the maximize +study was already unmounted). Extra factorial: file/memory × replicas +1/3, 4 KiB file r=3, reconnect-per-message ping, UDP echo 510→511, MQTT +QoS0 against nats-a :1883. Product streams were not the +bench target.

+

2.1 Cross-study history

+ ++++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StudyEnvCore 1p pubJS file r=3 1pJS mem r=3 4pPing p99
20260912T051237Z1c/1G ZFS (NS1 orch.)502,5027,3931.377ms
20260912T053120Z8c/16G tmpfs + mem extra599,00417,38836,3550.684ms
20260912T055851Z8c/16G ZFS exhaustive 20260912T055851Z662,22714,33037,7361.140ms
+
+ + +
+

2.2 This run — JetStream +factorial

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RunWhatPub msgs/sPub MB/s
js-file-1p-20k-128-r1file r=1 1p 128 B18,8882.31
js-file-4p-50k-128-r1file r=1 4p 128 B24,5603.00
js-1p-20k-128-r3file r=3 1p 128 B14,3301.75
js-4p-50k-128-r3file r=3 4p 128 B19,2322.35
js-4p-20k-1k-r3file r=3 4p 1 KiB15,19714.84
js-file-1p-20k-4k-r3file r=3 1p 4 KiB8,67333.88
js-mem-1p-20k-128-r1memory r=1 1p 128 B29,9723.66
js-mem-4p-50k-128-r1memory r=1 4p 128 B64,9237.93
js-mem-1p-20k-128-r3memory r=3 1p 128 B20,1882.46
js-mem-4p-50k-128-r3memory r=3 4p 128 B37,7364.61
js-mem-4p-20k-1k-r3memory r=3 4p 1 KiB33,91633.12
+

Replica 1 vs 3 on this stand (file 1p 128 B): r=1 is +18,888 vs r=3 14,330 (1.32× if r=3 is the slower one). Memory r=1 1p +29,972 vs memory r=3 20,188.

+
+Replica cost + +
+

2.3 Delay, reconnect tax, UDP, +MQTT

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProbeResultMeaning
NATS ping (persistent sockets) p50 / p990.456ms / 1.140msQuiet hop with a long-lived TCP conn
NATS reconnect-per-message p50 / p990.503ms / 1.750msTCP+NATS handshake on every pub — this is the tax to avoid
UDP echo 510→511 p990.363msRaw datagram ceiling on the same veth (no NATS)
MQTT QoS0 5k×128 B44862 pubs/snats-server MQTT gateway on :1883
+

Core 1p1s 128 B this run: 662,227 pub msgs/s. Flood delay is still +backlog/consume_rate, not RTT.

+
+

3. +Alternative transports (why we are not switching the fabric)

+

NATS already is persistent TCP sockets with a tiny +binary protocol, automatic reconnect, and optional JetStream durability. +“Reduce connection overhead” is a client discipline: +hold the connection. The reconnect probe exists to prove that opening a +socket per job would dominate ping RTT.

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdeaFit for Verae jobs/events/archiveThroughput vs NATS coreDurability
NATS core pub/subFan-out, request-reply (verae.billing.*)Highest we measured (~0.5–2M msgs/s)None
NATS JetStream file r=3Jobs, webhooks, archive~8–23k on this lab; see metal projectionDisk + 1-node loss
NATS JetStream memory r=3Events mailbox~22–36k on this labRAM + 1-node loss; empty on full restart
MQTT (NATS gateway or Mosquitto)IoT endpoints that already speak MQTTThis probe: 44862 pubs/s QoS0 — typically well +below NATS core; QoS1 ≈ JetStream-ish with more chatterQoS1/2 session state; not our WORM model
UDPTelemetry that may dropRTT 0.363ms p99 — fastest hop, no reliability, no +cluster, no authNone
Custom persistent sockets / HTTP long-pollWorse NATSYou would re-implement reconnect, flow control, and fan-outDIY
WebSocketBrowsers onlyExtra framing; NATS already has WS for UIs, not for middlewareSame as core/JS behind it
QUIC / WebTransportLossy WAN / browsersNATS QUIC is not the lab path; 10GbE LAN does not need itSame
Kafka / Redis streamsHeavy log replayHigher ops cost; not on vmbr1 todayYes, heavier
+

MQTT: NATS documents MQTT as an enabling +gateway for existing IoT, and prefers NATS end-to-end for greenfield. +Zapier cloud never talks NATS or MQTT; it talks HTTPS. Putting MQTT in +the middle of timestamp jobs adds protocol translation and QoS timers +without helping jobId → events. Use MQTT only if a device +already cannot speak NATS.

+

UDP: Fine as a measurement of veth RTT. +Unusable as the job fabric (no ack, no replica, no flow control). NATS +ping is already within a small multiple of UDP on this bridge.

+

Persistence sockets: Middleware and keep already +keep NATS_URL connections open. Optimal: one connection (or +a small pool) per process, max_reconnect, jitter, no +connect() in the per-job path. The reconnect ladder is the +anti-pattern.

+
+

4. Optimal configurations

+

4.1 NS1 lab (now)

+
    +
  1. Leave 8 cores / 16 GiB on nats-a/b/c and the +worker. Host has 40 cores / 377 GiB; this is cheap.
  2. +
  3. max_mem: 8G stays. Required for memory +streams.
  4. +
  5. File r=3 on ZFS for jobs/webhooks/archive. tmpfs +doubled JS 1p (7.4k→17k) but loses the stream on reboot +— unacceptable for archive.
  6. +
  7. Memory r=3 for ZAPIER_EVENTS if we +accept “all three nats CTs reboot ⇒ in-flight waiters fall back to HTTP +poll.” That matches the designed wait path +(GET /api/status/{jobId}).
  8. +
  9. r=1 only for throwaway benches, never product +streams. Replica=3 is the point of three guests.
  10. +
  11. veth on vmbr1, no fake 10G NICs. Already 10000Mb/s; +JS does not fill it.
  12. +
  13. Pin cpusets later if keep/fleet steal; not required +to beat these numbers.
  14. +
  15. Clients: persistent NATS connections; pull consumers with bounded +max_ack_pending for webhooks.
  16. +
+

4.2 Three HP DL360 +Gen10 (projection — not measured)

+

Assumed bill of materials (state it in the buy):

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PieceAssumption
Chassis3× DL360 Gen10 1U
CPUDual 2nd-gen Xeon Gold (e.g. 6226R 16c or 6248 20c +— 32–40 cores/box)
MemoryDDR4-2933, 192–384 GiB/box (6–12×32 GiB); NATS will +not use most of it
StorageNVMe M.2 or U.2 for +/var/lib/nats/jetstream (XFS or ext4, not +shared ZFS over the network). RAID1 of two NVMe if you want disk HA +inside a box
Network10GbE (FlexibleLOM or PCIe); dedicated VLAN for +:4222+:6222. Do not share with public +vmbr0 traffic
OSDebian/Ubuntu bare metal, nats-server systemd, same +nats.conf as lab (bind private IP only)
+

What changes vs NS1 LXC

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FactorNS1 today3× DL360Effect on JS file r=3
Failure domain1 Proxmox host3 chassis, 3 NVMe, 3 NICsr=3 means something
DiskShared ZFS SSD2Local NVMe fsync ~50–150 µsBig win vs ZFS; similar to tmpfs for sequential 128 B
Replica pathveth/bridge (~µs–tens of µs)10GbE RTT typically 50–200 µsSlower than same-host tmpfs, faster than a bad +SAN
CPU8 of 40 shared32–40 dedicated Gold coresHeadroom for many clients, not 10× JS
NICsoftware 10G veth, already ~5 Gbit/s corereal 10GbE ~9 Gbit/s TCPCore NATS can grow; JS r=3 stays replica-bound
+

Projected bands (128 B, 3-node cluster, dedicated +10GbE, local NVMe, 8+ cores pinned to nats-server):

+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WorkloadNS1 measured (best)DL360 projectionConfidence
Core pub/sub 1p0.5–0.8M0.8–2MMedium — NIC + syscall, plenty of CPU
Core 4p4s 1 KiB~0.6–0.7M (~0.6 GB/s)~1M msgs/s / ~1 GB/s approaching 10GbEMedium
JS file r=1this run r=180–200k pubs/sMedium — NVMe + no replica wait
JS file r=37–23k (ZFS/tmpfs)40–80k pubs/sMedium-low — replica RTT dominates; 3 NVMe still help vs shared +ZFS
JS memory r=322–36k50–100kMedium-low — RAM + 10GbE ack
Ping p990.7–1.4 ms0.2–0.6 msMedium — real NIC but no Proxmox tax
+

These are not DL360 measurements. Scale from: (a) +our replica-1 vs replica-3 ratio once this run’s r=1 numbers exist, (b) +tmpfs vs ZFS ratio (2.35× on 1p), (c) Synadia/nats bench async file r=1 +~100–400k on NVMe loopback, derated for 10GbE RTT.

+

Buy notes: M.2 via Dual uFF / enablement kit; put +JetStream on NVMe directly, not behind a RAID +controller write-through unless you measure. 1GbE onboard is a trap — +use 10GbE for :6222. Dual Gold is for isolation (nats vs +worm/tree vs OS), not because JS needs 56 cores.

+
+

5. What we are not doing

+
    +
  • MQTT as the Zapier or middleware transport.
  • +
  • UDP for jobs.
  • +
  • Emulated 10G fiber NICs on LXC.
  • +
  • tmpfs as the production store.
  • +
  • r=1 for product streams.
  • +
  • Connect-per-job.
  • +
+

Re-run exhaustive: bash scripts/exhaustive-ns1-study.sh +on NS1.

+ + diff --git a/results/20260912T055851Z/optimal-config.md b/results/20260912T055851Z/optimal-config.md new file mode 100644 index 0000000..8c5eb58 --- /dev/null +++ b/results/20260912T055851Z/optimal-config.md @@ -0,0 +1,160 @@ +**Progress report — optimal configuration study** · `20260912T055851Z` (UTC) · all code on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`) + +This document folds every ladder we have run (1-core ZFS, NS1-orchestrated, tmpfs maximize, and this exhaustive 8c/16G **ZFS** factorial) plus UDP / MQTT / reconnect probes. It recommends a lab config and a **three-box HP DL360 Gen10** projection. veth/10G was not changed. + +--- + +## 1. Verdict (read this first) + +**Keep NATS + JetStream.** Do not replace the fabric with MQTT, UDP, or a custom persistent-socket protocol for Verae jobs/events/archive. Those are either slower, less durable, or already what NATS is. + +**Lab (NS1, one host, three LXC) — optimal now** + +| Stream | Storage | Replicas | Why | +|--------|---------|----------|-----| +| `ZAPIER_JOBS`, `ZAPIER_WEBHOOKS`, `VERAE_ARCHIVE` | **file** (ZFS) | **3** | Survive a nats LXC death; archive must persist | +| `ZAPIER_EVENTS` | **memory** | **3** | Waiters are latency-sensitive; events rebuild from job status | +| `ZAPIER_USAGE` | file | 3 | Telemetry, limits + max-age | + +Keep **8 cores / 16 GiB / `max_mem: 8G`** on 510–513 (already live). Do **not** leave JetStream on tmpfs. Do **not** drop product streams to r=1. Reuse **one NATS connection per process** (already true in middleware); never connect-per-message. + +**Metal (3× DL360 Gen10) — optimal later** + +Same stream table. File store on **local NVMe/M.2**, not a shared SAN. Cluster + client on **10GbE** (or 25GbE if you already have it). Dual Gold Xeon is surplus CPU for this workload; 8–16 cores dedicated to `nats-server` is enough. Expected JS file r=3: **~40–80k** 128 B pubs/s (about **3–6×** this lab’s 8c ZFS 1p, **2–4×** tmpfs 1p) — bounded by **10GbE replica RTT**, not by Xeon clocks. Core NATS will sit in the **1–3M msgs/s** band until the NIC saturates (~9 Gbit/s ≈ 8–9M × 128 B theoretical; CPU and client will hit first). + +--- + +## 2. What we actually ran (this exhaustive pass) + +Live cluster during this run: LXC 510–513 **8 cores / 16 GiB**, JetStream **on ZFS** (tmpfs from the maximize study was already unmounted). Extra factorial: file/memory × replicas 1/3, 4 KiB file r=3, reconnect-per-message ping, UDP echo 510→511, MQTT QoS0 against nats-a `:1883`. Product streams were not the bench target. + +### 2.1 Cross-study history + +| Study | Env | Core 1p pub | JS file r=3 1p | JS mem r=3 4p | Ping p99 | +| --- | --- | --- | --- | --- | --- | +| `20260912T051237Z` | 1c/1G ZFS (NS1 orch.) | 502,502 | 7,393 | — | 1.377ms | +| `20260912T053120Z` | 8c/16G tmpfs + mem extra | 599,004 | 17,388 | 36,355 | 0.684ms | +| `20260912T055851Z` | 8c/16G ZFS exhaustive `20260912T055851Z` | 662,227 | 14,330 | 37,736 | 1.140ms | + +![JS 1p file r=3 history](charts-optimal/history-js1p.png) + +### 2.2 This run — JetStream factorial + +| Run | What | Pub msgs/s | Pub MB/s | +| --- | --- | --- | --- | +| `js-file-1p-20k-128-r1` | file r=1 1p 128 B | 18,888 | 2.31 | +| `js-file-4p-50k-128-r1` | file r=1 4p 128 B | 24,560 | 3.00 | +| `js-1p-20k-128-r3` | file r=3 1p 128 B | 14,330 | 1.75 | +| `js-4p-50k-128-r3` | file r=3 4p 128 B | 19,232 | 2.35 | +| `js-4p-20k-1k-r3` | file r=3 4p 1 KiB | 15,197 | 14.84 | +| `js-file-1p-20k-4k-r3` | file r=3 1p 4 KiB | 8,673 | 33.88 | +| `js-mem-1p-20k-128-r1` | memory r=1 1p 128 B | 29,972 | 3.66 | +| `js-mem-4p-50k-128-r1` | memory r=1 4p 128 B | 64,923 | 7.93 | +| `js-mem-1p-20k-128-r3` | memory r=3 1p 128 B | 20,188 | 2.46 | +| `js-mem-4p-50k-128-r3` | memory r=3 4p 128 B | 37,736 | 4.61 | +| `js-mem-4p-20k-1k-r3` | memory r=3 4p 1 KiB | 33,916 | 33.12 | + +Replica **1 vs 3** on this stand (file 1p 128 B): r=1 is 18,888 vs r=3 14,330 (1.32× if r=3 is the slower one). Memory r=1 1p 29,972 vs memory r=3 20,188. + +![Replica cost](charts-optimal/replicas.png) + +### 2.3 Delay, reconnect tax, UDP, MQTT + +| Probe | Result | Meaning | +|-------|--------|---------| +| NATS ping (persistent sockets) p50 / p99 | 0.456ms / 1.140ms | Quiet hop with a long-lived TCP conn | +| NATS **reconnect-per-message** p50 / p99 | 0.503ms / 1.750ms | TCP+NATS handshake on every pub — this is the tax to avoid | +| UDP echo 510→511 p99 | 0.363ms | Raw datagram ceiling on the same veth (no NATS) | +| MQTT QoS0 5k×128 B | 44862 pubs/s | nats-server MQTT gateway on `:1883` | + +Core 1p1s 128 B this run: 662,227 pub msgs/s. Flood delay is still backlog/consume_rate, not RTT. + +--- + +## 3. Alternative transports (why we are not switching the fabric) + +NATS already **is** persistent TCP sockets with a tiny binary protocol, automatic reconnect, and optional JetStream durability. “Reduce connection overhead” is a **client** discipline: hold the connection. The reconnect probe exists to prove that opening a socket per job would dominate ping RTT. + +| Idea | Fit for Verae jobs/events/archive | Throughput vs NATS core | Durability | +|------|-----------------------------------|-------------------------|------------| +| **NATS core pub/sub** | Fan-out, request-reply (`verae.billing.*`) | Highest we measured (~0.5–2M msgs/s) | None | +| **NATS JetStream file r=3** | Jobs, webhooks, archive | ~8–23k on this lab; see metal projection | Disk + 1-node loss | +| **NATS JetStream memory r=3** | Events mailbox | ~22–36k on this lab | RAM + 1-node loss; **empty on full restart** | +| **MQTT** (NATS gateway or Mosquitto) | IoT endpoints that already speak MQTT | This probe: 44862 pubs/s QoS0 — typically **well below** NATS core; QoS1 ≈ JetStream-ish with more chatter | QoS1/2 session state; not our WORM model | +| **UDP** | Telemetry that may drop | RTT 0.363ms p99 — fastest hop, **no** reliability, no cluster, no auth | None | +| **Custom persistent sockets / HTTP long-poll** | Worse NATS | You would re-implement reconnect, flow control, and fan-out | DIY | +| **WebSocket** | Browsers only | Extra framing; NATS already has WS for UIs, not for middleware | Same as core/JS behind it | +| **QUIC / WebTransport** | Lossy WAN / browsers | NATS QUIC is not the lab path; 10GbE LAN does not need it | Same | +| **Kafka / Redis streams** | Heavy log replay | Higher ops cost; not on `vmbr1` today | Yes, heavier | + +**MQTT:** NATS documents MQTT as an *enabling* gateway for existing IoT, and prefers NATS end-to-end for greenfield. Zapier cloud never talks NATS or MQTT; it talks HTTPS. Putting MQTT in the middle of timestamp jobs adds protocol translation and QoS timers without helping `jobId → events`. Use MQTT only if a device already cannot speak NATS. + +**UDP:** Fine as a *measurement* of veth RTT. Unusable as the job fabric (no ack, no replica, no flow control). NATS ping is already within a small multiple of UDP on this bridge. + +**Persistence sockets:** Middleware and keep already keep `NATS_URL` connections open. Optimal: one connection (or a small pool) per process, `max_reconnect`, jitter, no `connect()` in the per-job path. The reconnect ladder is the anti-pattern. + +--- + +## 4. Optimal configurations + +### 4.1 NS1 lab (now) + +1. **Leave 8 cores / 16 GiB** on nats-a/b/c and the worker. Host has 40 cores / 377 GiB; this is cheap. +2. **`max_mem: 8G`** stays. Required for memory streams. +3. **File r=3 on ZFS** for jobs/webhooks/archive. tmpfs doubled JS 1p (7.4k→17k) but **loses the stream on reboot** — unacceptable for archive. +4. **Memory r=3 for `ZAPIER_EVENTS`** if we accept “all three nats CTs reboot ⇒ in-flight waiters fall back to HTTP poll.” That matches the designed wait path (`GET /api/status/{jobId}`). +5. **r=1 only for throwaway benches**, never product streams. Replica=3 is the point of three guests. +6. **veth on vmbr1, no fake 10G NICs.** Already 10000Mb/s; JS does not fill it. +7. **Pin cpusets** later if keep/fleet steal; not required to beat these numbers. +8. Clients: persistent NATS connections; pull consumers with bounded `max_ack_pending` for webhooks. + +### 4.2 Three HP DL360 Gen10 (projection — not measured) + +Assumed bill of materials (state it in the buy): + +| Piece | Assumption | +|-------|------------| +| Chassis | 3× DL360 Gen10 1U | +| CPU | Dual 2nd-gen Xeon **Gold** (e.g. 6226R 16c or 6248 20c — **32–40 cores/box**) | +| Memory | DDR4-2933, **192–384 GiB**/box (6–12×32 GiB); NATS will not use most of it | +| Storage | **NVMe M.2 or U.2** for `/var/lib/nats/jetstream` (XFS or ext4, **not** shared ZFS over the network). RAID1 of two NVMe if you want disk HA *inside* a box | +| Network | **10GbE** (FlexibleLOM or PCIe); dedicated VLAN for `:4222`+`:6222`. Do not share with public `vmbr0` traffic | +| OS | Debian/Ubuntu bare metal, `nats-server` systemd, same `nats.conf` as lab (bind private IP only) | + +**What changes vs NS1 LXC** + +| Factor | NS1 today | 3× DL360 | Effect on JS file r=3 | +|--------|-----------|----------|------------------------| +| Failure domain | 1 Proxmox host | 3 chassis, 3 NVMe, 3 NICs | r=3 **means** something | +| Disk | Shared ZFS SSD2 | Local NVMe fsync ~50–150 µs | Big win vs ZFS; similar to tmpfs for sequential 128 B | +| Replica path | veth/bridge (~µs–tens of µs) | 10GbE RTT typically **50–200 µs** | **Slower than same-host tmpfs**, faster than a bad SAN | +| CPU | 8 of 40 shared | 32–40 dedicated Gold cores | Headroom for many clients, not 10× JS | +| NIC | software 10G veth, already ~5 Gbit/s core | real 10GbE ~9 Gbit/s TCP | Core NATS can grow; JS r=3 stays replica-bound | + +**Projected bands** (128 B, 3-node cluster, dedicated 10GbE, local NVMe, 8+ cores pinned to nats-server): + +| Workload | NS1 measured (best) | DL360 projection | Confidence | +|----------|---------------------|------------------|------------| +| Core pub/sub 1p | 0.5–0.8M | **0.8–2M** | Medium — NIC + syscall, plenty of CPU | +| Core 4p4s 1 KiB | ~0.6–0.7M (~0.6 GB/s) | **~1M msgs/s / ~1 GB/s** approaching 10GbE | Medium | +| JS file r=1 | this run r=1 | **80–200k** pubs/s | Medium — NVMe + no replica wait | +| JS file r=3 | 7–23k (ZFS/tmpfs) | **40–80k** pubs/s | Medium-low — replica RTT dominates; 3 NVMe still help vs shared ZFS | +| JS memory r=3 | 22–36k | **50–100k** | Medium-low — RAM + 10GbE ack | +| Ping p99 | 0.7–1.4 ms | **0.2–0.6 ms** | Medium — real NIC but no Proxmox tax | + +These are **not** DL360 measurements. Scale from: (a) our replica-1 vs replica-3 ratio once this run’s r=1 numbers exist, (b) tmpfs vs ZFS ratio (2.35× on 1p), (c) Synadia/nats bench async file r=1 ~100–400k on NVMe loopback, derated for 10GbE RTT. + +**Buy notes:** M.2 via Dual uFF / enablement kit; put JetStream on NVMe **directly**, not behind a RAID controller write-through unless you measure. 1GbE onboard is a trap — use 10GbE for `:6222`. Dual Gold is for isolation (nats vs worm/tree vs OS), not because JS needs 56 cores. + +--- + +## 5. What we are not doing + +- MQTT as the Zapier or middleware transport. +- UDP for jobs. +- Emulated 10G fiber NICs on LXC. +- tmpfs as the production store. +- r=1 for product streams. +- Connect-per-job. + +Re-run exhaustive: `bash scripts/exhaustive-ns1-study.sh` on NS1. diff --git a/results/20260912T055851Z/optimal-config.pdf b/results/20260912T055851Z/optimal-config.pdf new file mode 100644 index 0000000..513cc78 Binary files /dev/null and b/results/20260912T055851Z/optimal-config.pdf differ diff --git a/results/20260912T055851Z/udp-ping-1k-128.txt b/results/20260912T055851Z/udp-ping-1k-128.txt new file mode 100644 index 0000000..beae2e2 --- /dev/null +++ b/results/20260912T055851Z/udp-ping-1k-128.txt @@ -0,0 +1,2 @@ +=== udp-ping-1k-128 === +{"mode":"udp-ping","count":1000,"size":128,"host":"10.10.10.21","min":"0.096ms","avg":"0.138ms","p50":"0.118ms","p99":"0.363ms","max":"8.411ms","p50_us":118,"p99_us":363} diff --git a/results/20260912T055851Z/varz-after.json b/results/20260912T055851Z/varz-after.json new file mode 100644 index 0000000..886a093 --- /dev/null +++ b/results/20260912T055851Z/varz-after.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 5, + "in_msgs": 724962, + "out_msgs": 1400288, + "in_bytes": 282983166, + "out_bytes": 248599798, + "cpu": 2, + "cores": 8, + "mem": 27095040, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 1, + "in_msgs": 509195, + "out_msgs": 671813, + "in_bytes": 236003120, + "out_bytes": 379784062, + "cpu": 0, + "cores": 1, + "mem": 36028416, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 0, + "in_msgs": 703508, + "out_msgs": 1715907, + "in_bytes": 287777683, + "out_bytes": 404038933, + "cpu": 0, + "cores": 1, + "mem": 27295744, + "jetstream": true + } +] diff --git a/results/20260912T055851Z/varz-before.json b/results/20260912T055851Z/varz-before.json new file mode 100644 index 0000000..38b661f --- /dev/null +++ b/results/20260912T055851Z/varz-before.json @@ -0,0 +1,50 @@ +[ + { + "vmid": "511", + "name": "nats-a", + "server_name": "nats-a", + "host": "10.10.10.21", + "port": 4222, + "connections": 5, + "in_msgs": 31443, + "out_msgs": 31725, + "in_bytes": 1501396, + "out_bytes": 1924069, + "cpu": 1, + "cores": 8, + "mem": 15785984, + "jetstream": true + }, + { + "vmid": "512", + "name": "nats-b", + "server_name": "nats-b", + "host": "10.10.10.22", + "port": 4222, + "connections": 1, + "in_msgs": 24330, + "out_msgs": 24480, + "in_bytes": 1424418, + "out_bytes": 1398562, + "cpu": 1, + "cores": 1, + "mem": 15892480, + "jetstream": true + }, + { + "vmid": "513", + "name": "nats-c", + "server_name": "nats-c", + "host": "10.10.10.23", + "port": 4222, + "connections": 0, + "in_msgs": 19051, + "out_msgs": 19034, + "in_bytes": 1373987, + "out_bytes": 1036018, + "cpu": 0, + "cores": 1, + "mem": 14262272, + "jetstream": true + } +] diff --git a/scripts/bench-report.py b/scripts/bench-report.py new file mode 100755 index 0000000..68446d5 --- /dev/null +++ b/scripts/bench-report.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Turn nats bench text logs + latency JSON into markdown.""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +def fmt_int(s: str | None) -> str: + if not s: + return "—" + return f"{int(s):,}" + + +def parse_bench(text: str) -> dict[str, str]: + out: dict[str, str] = {"kind": "throughput"} + def _rate(pattern: str, msgs_key: str, mb_key: str) -> None: + m = re.search(pattern, text) + if not m: + return + out[msgs_key] = m.group(1).replace(",", "") + n = float(m.group(2)) + unit = m.group(3).upper() + if unit == "KB": + n = n / 1024.0 + elif unit == "GB": + n = n * 1024.0 + out[mb_key] = f"{n:.2f}" + + _rate( + r"(?m)^\s*Pub stats:\s*([0-9,]+)\s*msgs/sec\s*~\s*([0-9.]+)\s*(KB|MB|GB)/sec", + "pub_msgs", + "pub_mb", + ) + _rate( + r"(?m)^\s*Sub stats:\s*([0-9,]+)\s*msgs/sec\s*~\s*([0-9.]+)\s*(KB|MB|GB)/sec", + "sub_msgs", + "sub_mb", + ) + _rate( + r"NATS Pub/Sub stats:\s*([0-9,]+)\s*msgs/sec\s*~\s*([0-9.]+)\s*(KB|MB|GB)/sec", + "agg_msgs", + "agg_mb", + ) + if "JetStream" in text or "--js" in text or "js-" in text: + out["mode"] = "jetstream r=3 file" + else: + out["mode"] = "core pub/sub" + # nats 0.1.6 prints min/avg/max as msgs/sec across publishers, not µs delay + m = re.search( + r"min\s+([0-9,]+)\s*\|\s*avg\s+([0-9,]+)\s*\|\s*max\s+([0-9,]+)\s*\|\s*stddev\s+([0-9,]+)\s*msgs", + text, + ) + if m: + out["pub_spread"] = f"{m.group(1)}–{m.group(3)} (avg {m.group(2)})" + return out + + +def parse_lat(text: str) -> dict[str, str] | None: + for line in text.splitlines(): + line = line.strip() + if line.startswith("{") and "p99_us" in line: + d = json.loads(line) + mode = d.get("mode") or "" + return { + "kind": "latency", + "count": str(d.get("count", "")), + "pubs": str(d.get("pubs", "")), + "size": str(d.get("size", "")), + "mode": str(mode), + "min": d.get("min", ""), + "avg": d.get("avg", ""), + "p50": d.get("p50", ""), + "p90": d.get("p90", ""), + "p99": d.get("p99", ""), + "max": d.get("max", ""), + } + return None + + +def lat_mode(run: str, recorded: str) -> str: + if recorded in ("ping", "flood"): + return recorded + if "ping" in run: + return "ping" + return "flood" + + +def thru_sort(p: dict[str, str]) -> tuple: + return (0 if p.get("mode", "").startswith("core") else 1, p.get("run", "")) + + +def lat_sort(p: dict[str, str]) -> tuple: + mode = lat_mode(p.get("run", ""), p.get("mode", "")) + return (0 if mode == "ping" else 1, int(p.get("count") or 0), p.get("run", "")) + + +def main() -> int: + folder = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + thru: list[dict[str, str]] = [] + lats: list[dict[str, str]] = [] + for f in sorted(folder.glob("*.txt")): + text = f.read_text(encoding="utf-8", errors="replace") + lat = parse_lat(text) + if lat: + lat["run"] = f.stem + lats.append(lat) + continue + p = parse_bench(text) + if p.get("pub_msgs") or p.get("agg_msgs"): + p["run"] = f.stem + thru.append(p) + + stamp = folder.name if re.fullmatch(r"\d{8}T\d{6}Z", folder.name) else "" + + print("# NATS cluster message speed") + print() + if stamp: + print(f"Run **`{stamp}`** (UTC). ", end="") + print( + "Client: LXC **510** `verae-px-worker` (`10.10.10.20`), not a nats-* server. " + "Servers: `nats-a/b/c` on `10.10.10.21–23` (`vmbr1` only)." + ) + print() + print("Client URL:") + print() + print("```text") + print("nats://10.10.10.21:4222,nats://10.10.10.22:4222,nats://10.10.10.23:4222") + print("```") + print() + print("## Method") + print() + print("- **Core NATS** is fire-and-forget pub/sub (`nats bench`). No disk, no replica ack.") + print( + "- **JetStream** uses **file** storage and **replicas=3** (same as product streams). " + "The unique stream `benchstream` is deleted between JS loads." + ) + print("- Throughput is **msgs/sec** from nats CLI **0.1.6** (`--no-progress --csv`). Its min/avg/max are publisher **rate spread**, not delay.") + print( + "- **Ping** delay: one publisher, sequential publish-then-wait. This is one-message round-trip through the cluster." + ) + print( + "- **Flood** delay: N publishers dump the whole batch, then the subscriber drains. " + "This is **queueing under burst**, not wire RTT." + ) + print("- Probe: `scripts/latency.mjs` (two connections, header timestamp).") + print() + print("## Throughput") + print() + print("| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s |") + print("|-----|------|------------------|------------|----------|------------|----------|") + for p in sorted(thru, key=thru_sort): + print( + f"| `{p['run']}` | {p.get('mode', '')} | {fmt_int(p.get('agg_msgs'))} | " + f"{fmt_int(p.get('pub_msgs'))} | {p.get('pub_mb') or '—'} | " + f"{fmt_int(p.get('sub_msgs'))} | {p.get('sub_mb') or '—'} |" + ) + print() + print("## Round-trip delay") + print() + print("| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max |") + print("|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----|") + for p in sorted(lats, key=lat_sort): + kind = lat_mode(p.get("run", ""), p.get("mode", "")) + label = "ping (sequential RTT)" if kind == "ping" else "flood (burst queueing)" + print( + f"| `{p['run']}` | {label} | {p.get('count', '')} | {p.get('pubs', '')} | " + f"{p.get('size', '')} B | {p.get('min', '')} | {p.get('avg', '')} | " + f"{p.get('p50', '')} | {p.get('p90', '')} | {p.get('p99', '')} | {p.get('max', '')} |" + ) + print() + print("## What the numbers mean") + print() + print( + "Product job/event/archive traffic is **JetStream r=3 file**. On this three-LXC stand that is about " + "**16k durable 128 B pubs/s** (about **13k** at 1 KiB). Core NATS is an upper bound for " + "non-durable fan-out: about **0.7–2.0M msgs/s** aggregate at 128 B, or **~630k msgs/s (~616 MB/s)** at 1 KiB with 4 publishers." + ) + print() + print( + "A quiet request-reply is **~0.3 ms** average, **p99 < 1 ms**. Flood rows in the **150–500 ms** band " + "are the subscriber catching up after a burst, which is what a job-events mailbox sees if publishers outrun consumers." + ) + print() + print("Re-run on NS1: `bash scripts/bench.sh`. Raw logs/CSVs are under `results//`.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bench.sh b/scripts/bench.sh new file mode 100755 index 0000000..695a673 --- /dev/null +++ b/scripts/bench.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Message throughput and delay ladder against the 3-node vmbr1 cluster. +# Prefers a client that is not a nats-* server (px-worker LXC 510). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +. "$ROOT/client.env" +export PATH="/usr/sbin:/usr/bin:/bin:/usr/local/bin:$PATH" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +OUT="${BENCH_OUT:-$ROOT/results/$STAMP}" +CLIENT_VMID="${CLIENT_VMID:-510}" +mkdir -p "$OUT" + +ensure_nats_cli() { + local vmid="$1" + sudo pct exec "$vmid" -- bash -lc ' +set -e +export DEBIAN_FRONTEND=noninteractive +export PATH=/usr/local/bin:/usr/bin:/bin +if [[ ! -x /usr/local/bin/nats ]]; then + apt-get install -y --no-install-recommends unzip curl ca-certificates >/dev/null + curl -fsSL https://github.com/nats-io/natscli/releases/download/v0.1.6/nats-0.1.6-linux-amd64.zip -o /tmp/natscli.zip + rm -rf /tmp/natscli && mkdir -p /tmp/natscli + unzip -o /tmp/natscli.zip -d /tmp/natscli >/dev/null + BIN=$(find /tmp/natscli -type f -name nats | head -1) + install -m 0755 "$BIN" /usr/local/bin/nats +fi +nats --version +' +} + +run_one() { + local name="$1" + shift + echo "=== $name ===" | tee "$OUT/$name.txt" + # nats bench writes csv itself when --csv is a path inside the guest + sudo pct exec "$CLIENT_VMID" -- bash -lc " +export PATH=/usr/local/bin:/usr/bin:/bin +export NATS_URL='$NATS_URL' +nats bench --no-progress --csv=/tmp/bench.csv $* +" | tee -a "$OUT/$name.txt" + sudo pct exec "$CLIENT_VMID" -- cat /tmp/bench.csv >"$OUT/$name.csv" || true +} + +echo "client LXC $CLIENT_VMID NATS_URL=$NATS_URL out=$OUT" +ensure_nats_cli "$CLIENT_VMID" + +# Core NATS pub/sub — increasing publishers (same 128 B payload) +run_one core-1p1s-50k-128 bench.core.a --pub 1 --sub 1 --msgs 50000 --size 128 +run_one core-4p4s-100k-128 bench.core.b --pub 4 --sub 4 --msgs 100000 --size 128 +run_one core-8p8s-200k-128 bench.core.c --pub 8 --sub 8 --msgs 200000 --size 128 +run_one core-4p4s-50k-1k bench.core.d --pub 4 --sub 4 --msgs 50000 --size 1024 + +js_rm() { + sudo pct exec "$CLIENT_VMID" -- bash -lc " +export PATH=/usr/local/bin:/usr/bin:/bin +export NATS_URL='$NATS_URL' +nats stream rm benchstream --force >/dev/null 2>&1 || true +" +} +# JetStream file store, replicas=3 (matches product streams) +js_rm +run_one js-1p-20k-128-r3 bench.js.a --js --purge --pub 1 --msgs 20000 --size 128 --replicas 3 --storage file --maxbytes=512MB --stream=benchstream +js_rm +run_one js-4p-50k-128-r3 bench.js.b --js --purge --pub 4 --msgs 50000 --size 128 --replicas 3 --storage file --maxbytes=512MB --stream=benchstream +js_rm +run_one js-4p-20k-1k-r3 bench.js.c --js --purge --pub 4 --msgs 20000 --size 1024 --replicas 3 --storage file --maxbytes=512MB --stream=benchstream +js_rm +run_one js-2p2s-20k-128-r3 bench.js.d --js --purge --pub 2 --sub 2 --msgs 20000 --size 128 --replicas 3 --storage file --maxbytes=512MB --pull --stream=benchstream +js_rm + +# Optional native memory-store ladder (same replica count). Used by maximize-ns1-study.sh. +if [[ "${JS_EXTRA_MEMORY:-0}" == "1" ]]; then + js_rm + run_one js-mem-1p-20k-128-r3 bench.js.m1 --js --purge --pub 1 --msgs 20000 --size 128 --replicas 3 --storage memory --maxbytes=512MB --stream=benchstream + js_rm + run_one js-mem-4p-50k-128-r3 bench.js.m2 --js --purge --pub 4 --msgs 50000 --size 128 --replicas 3 --storage memory --maxbytes=512MB --stream=benchstream + js_rm + run_one js-mem-4p-20k-1k-r3 bench.js.m3 --js --purge --pub 4 --msgs 20000 --size 1024 --replicas 3 --storage memory --maxbytes=512MB --stream=benchstream + js_rm +fi + +# Factorial extras: replicas=1 vs 3, file vs memory (does not touch product streams). +if [[ "${EXHAUSTIVE:-0}" == "1" ]]; then + js_rm + run_one js-file-1p-20k-128-r1 bench.js.e1 --js --purge --pub 1 --msgs 20000 --size 128 --replicas 1 --storage file --maxbytes=512MB --stream=benchstream + js_rm + run_one js-file-4p-50k-128-r1 bench.js.e2 --js --purge --pub 4 --msgs 50000 --size 128 --replicas 1 --storage file --maxbytes=512MB --stream=benchstream + js_rm + run_one js-mem-1p-20k-128-r1 bench.js.e3 --js --purge --pub 1 --msgs 20000 --size 128 --replicas 1 --storage memory --maxbytes=512MB --stream=benchstream + js_rm + run_one js-mem-4p-50k-128-r1 bench.js.e4 --js --purge --pub 4 --msgs 50000 --size 128 --replicas 1 --storage memory --maxbytes=512MB --stream=benchstream + js_rm + run_one js-file-1p-20k-4k-r3 bench.js.e5 --js --purge --pub 1 --msgs 20000 --size 4096 --replicas 3 --storage file --maxbytes=512MB --stream=benchstream + js_rm +fi + +# Round-trip delay (two connections, through the cluster) at several loads +sudo pct exec "$CLIENT_VMID" -- bash -lc " +set -e +export PATH=/usr/local/bin:/usr/bin:/bin +export NATS_URL='$NATS_URL' +mkdir -p /tmp/nats-lat +cd /tmp/nats-lat +if [[ ! -d node_modules/nats ]]; then + npm init -y >/dev/null + npm install --no-audit --no-fund nats@2 >/dev/null +fi +" >/dev/null +sudo pct push "$CLIENT_VMID" "$ROOT/scripts/latency.mjs" /tmp/nats-lat/latency.mjs || \ + sudo pct exec "$CLIENT_VMID" -- bash -c 'cat > /tmp/nats-lat/latency.mjs' < "$ROOT/scripts/latency.mjs" +lat() { + local name="$1" n="$2" sz="$3" p="$4" mode="${5:-flood}" + echo "=== $name ===" | tee "$OUT/$name.txt" + sudo pct exec "$CLIENT_VMID" -- bash -lc " +export PATH=/usr/local/bin:/usr/bin:/bin +export NATS_URL='$NATS_URL' +cd /tmp/nats-lat +node latency.mjs $n $sz $p $mode +" | tee -a "$OUT/$name.txt" +} +# copy latest probe +sudo pct exec "$CLIENT_VMID" -- bash -c 'cat > /tmp/nats-lat/latency.mjs' < "$ROOT/scripts/latency.mjs" +lat lat-ping-1k-128 1000 128 1 ping +if [[ "${EXHAUSTIVE:-0}" == "1" ]]; then + lat lat-reconnect-200-128 200 128 1 reconnect +fi +lat lat-1p-5k-128 5000 128 1 flood +lat lat-4p-10k-128 10000 128 4 flood +lat lat-8p-20k-128 20000 128 8 flood +lat lat-4p-5k-1k 5000 1024 4 flood + +python3 "$ROOT/scripts/bench-report.py" "$OUT" >"$OUT/BENCH.md" +cp "$OUT/BENCH.md" "$ROOT/BENCH.md" +echo "wrote $OUT/BENCH.md and $ROOT/BENCH.md" diff --git a/scripts/build-ns1-study-report.py b/scripts/build-ns1-study-report.py new file mode 100755 index 0000000..5b79dc7 --- /dev/null +++ b/scripts/build-ns1-study-report.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""Build the NS1-host study report (charts + markdown + HTML + PDF) from a results dir. + +Must be able to run entirely on NS1.GEORGELAMBERT.ORG with python3, matplotlib, +pandoc, and weasyprint. Parses nats bench logs; does not hard-code rates. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +import importlib.util + +_spec = importlib.util.spec_from_file_location( + "bench_report", Path(__file__).resolve().parent / "bench-report.py" +) +_br = importlib.util.module_from_spec(_spec) +assert _spec.loader is not None +_spec.loader.exec_module(_br) +fmt_int = _br.fmt_int +lat_mode = _br.lat_mode +lat_sort = _br.lat_sort +parse_bench = _br.parse_bench +parse_lat = _br.parse_lat +thru_sort = _br.thru_sort + +try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.ticker import FuncFormatter +except ImportError as e: + raise SystemExit(f"matplotlib required on NS1: {e}") from e + +INDIGO = "#4f46e5" +DEEP = "#312e81" +TEAL = "#047857" +AMBER = "#b45309" +LILAC = "#7c74f0" +INK = "#171a26" +MUTED = "#5b6178" +GRID = "#d9dce8" + +CORE_LABELS = { + "core-1p1s-50k-128": "1p1s\n50k×128 B", + "core-4p4s-100k-128": "4p4s\n100k×128 B", + "core-8p8s-200k-128": "8p8s\n200k×128 B", + "core-4p4s-50k-1k": "4p4s\n50k×1 KiB", +} +JS_LABELS = { + "js-1p-20k-128-r3": "1p 20k×128 B", + "js-4p-50k-128-r3": "4p 50k×128 B", + "js-4p-20k-1k-r3": "4p 20k×1 KiB", + "js-2p2s-20k-128-r3": "2p2s pull 20k×128 B", + "js-mem-1p-20k-128-r3": "mem 1p 128 B", + "js-mem-4p-50k-128-r3": "mem 4p 128 B", + "js-mem-4p-20k-1k-r3": "mem 4p 1 KiB", +} +LAT_LABELS = { + "lat-ping-1k-128": "Ping\n1k×128 B", + "lat-1p-5k-128": "Flood 1p\n5k×128 B", + "lat-4p-5k-1k": "Flood 4p\n5k×1 KiB", + "lat-4p-10k-128": "Flood 4p\n10k×128 B", + "lat-8p-20k-128": "Flood 8p\n20k×128 B", +} + + +def ms(s: str) -> float: + return float(s.replace("ms", "").replace(",", "").strip()) + + +def k_fmt(x: float, _pos: int | None = None) -> str: + if x >= 1_000_000: + return f"{x / 1_000_000:.2f}M" + if x >= 1000: + return f"{x / 1000:.0f}k" + return f"{x:.0f}" + + +def style() -> None: + plt.rcParams.update( + { + "font.family": "sans-serif", + "font.size": 10, + "axes.titlesize": 12, + "axes.titleweight": "semibold", + "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 save(fig: plt.Figure, path: Path) -> None: + fig.savefig(path, dpi=160) + plt.close(fig) + + +def load_runs(folder: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + thru: list[dict[str, str]] = [] + lats: list[dict[str, str]] = [] + for f in sorted(folder.glob("*.txt")): + if f.name.startswith("host-"): + continue + text = f.read_text(encoding="utf-8", errors="replace") + lat = parse_lat(text) + if lat: + lat["run"] = f.stem + lats.append(lat) + continue + p = parse_bench(text) + if p.get("pub_msgs") or p.get("agg_msgs"): + p["run"] = f.stem + thru.append(p) + return sorted(thru, key=thru_sort), sorted(lats, key=lat_sort) + + +def kv_file(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if "=" in line and not line.startswith("---"): + k, _, v = line.partition("=") + if k.strip() in out: + continue + out[k.strip()] = v.strip() + return out + + +def thru_table(thru: list[dict[str, str]]) -> str: + lines = [ + "| Run | Mode | Aggregate msgs/s | Pub msgs/s | Pub MB/s | Sub msgs/s | Sub MB/s |", + "|-----|------|------------------|------------|----------|------------|----------|", + ] + for p in thru: + lines.append( + f"| `{p['run']}` | {p.get('mode', '')} | {fmt_int(p.get('agg_msgs'))} | " + f"{fmt_int(p.get('pub_msgs'))} | {p.get('pub_mb') or '—'} | " + f"{fmt_int(p.get('sub_msgs'))} | {p.get('sub_mb') or '—'} |" + ) + return "\n".join(lines) + + +def delay_table(lats: list[dict[str, str]]) -> str: + lines = [ + "| Run | Kind | Count | Pubs | Size | min | avg | p50 | p90 | p99 | max |", + "|-----|------|-------|------|------|-----|-----|-----|-----|-----|-----|", + ] + for p in lats: + kind = lat_mode(p.get("run", ""), p.get("mode", "")) + label = "ping (sequential RTT)" if kind == "ping" else "flood (burst queueing)" + lines.append( + f"| `{p['run']}` | {label} | {p.get('count', '')} | {p.get('pubs', '')} | " + f"{p.get('size', '')} B | {p.get('min', '')} | {p.get('avg', '')} | " + f"{p.get('p50', '')} | {p.get('p90', '')} | {p.get('p99', '')} | {p.get('max', '')} |" + ) + return "\n".join(lines) + + +def varz_table(path: Path) -> str: + if not path.exists(): + return "_varz snapshot not captured._" + rows = json.loads(path.read_text(encoding="utf-8")) + lines = [ + "| Node | VMID | connections | in_msgs | out_msgs | cpu | cores | mem (B) | jetstream |", + "|------|------|-------------|---------|----------|-----|-------|---------|-----------|", + ] + for r in rows: + if r.get("error"): + lines.append(f"| {r.get('name')} | {r.get('vmid')} | error: {r['error']} | | | | | | |") + continue + lines.append( + f"| {r.get('name')} | {r.get('vmid')} | {r.get('connections')} | " + f"{r.get('in_msgs'):,} | {r.get('out_msgs'):,} | {r.get('cpu')} | " + f"{r.get('cores')} | {r.get('mem'):,} | {r.get('jetstream')} |" + ) + return "\n".join(lines) + + +def charts(thru: list[dict[str, str]], lats: list[dict[str, str]], dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + style() + by = {p["run"]: p for p in thru} + core_keys = [k for k in CORE_LABELS if k in by] + if core_keys: + fig, ax = plt.subplots(figsize=(9.2, 4.4)) + x = list(range(len(core_keys))) + w = 0.25 + agg = [int(by[k].get("agg_msgs") or 0) for k in core_keys] + pub = [int(by[k].get("pub_msgs") or 0) for k in core_keys] + sub = [int(by[k].get("sub_msgs") or 0) for k in core_keys] + ax.bar([i - w for i in x], agg, w, label="Aggregate", color=DEEP) + ax.bar(x, pub, w, label="Publish", color=INDIGO) + ax.bar([i + w for i in x], sub, w, label="Subscribe", color=TEAL) + ax.set_xticks(x, [CORE_LABELS[k] for k in core_keys]) + ax.set_ylabel("messages / second") + ax.set_title("Core NATS throughput (fire-and-forget) — NS1 host run") + ax.yaxis.set_major_formatter(FuncFormatter(k_fmt)) + ax.legend(loc="upper left") + ax.set_axisbelow(True) + save(fig, dest / "core-throughput.png") + + js_keys = [k for k in JS_LABELS if k in by] + if js_keys: + fig, ax = plt.subplots(figsize=(9.2, 4.4)) + pubs = [int(by[k].get("pub_msgs") or 0) for k in js_keys] + colors = [INDIGO, INDIGO, AMBER, LILAC][: len(js_keys)] + ax.bar([JS_LABELS[k] for k in js_keys], pubs, color=colors) + ax.set_ylabel("durable publish messages / second") + ax.set_title("JetStream file store, replicas=3 — NS1 host run") + ax.yaxis.set_major_formatter(FuncFormatter(k_fmt)) + ax.set_axisbelow(True) + for i, v in enumerate(pubs): + ax.text(i, v * 1.02, f"{v:,}", ha="center", va="bottom", fontsize=9, color=MUTED) + save(fig, dest / "js-throughput.png") + + pair = [("core-1p1s-50k-128", "js-1p-20k-128-r3"), ("core-4p4s-100k-128", "js-4p-50k-128-r3"), ("core-4p4s-50k-1k", "js-4p-20k-1k-r3")] + if all(c in by and j in by for c, j in pair): + fig, ax = plt.subplots(figsize=(9.2, 4.4)) + labels = ["1 publisher\n128 B", "4 publishers\n128 B", "4 publishers\n1 KiB"] + core_pub = [int(by[c]["pub_msgs"]) for c, _ in pair] + js_pub = [int(by[j]["pub_msgs"]) for _, j in pair] + x = list(range(3)) + 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(x, labels) + ax.set_yscale("log") + ax.set_ylabel("publish messages / second (log)") + ax.set_title("Core vs JetStream — NS1 host run") + ax.legend(loc="upper right") + ax.set_axisbelow(True) + save(fig, dest / "core-vs-js.png") + + if "core-4p4s-100k-128" in by and "core-4p4s-50k-1k" in by: + fig, axes = plt.subplots(1, 2, figsize=(9.2, 4.2)) + labels = ["128 B\n4p4s", "1 KiB\n4p4s"] + msgs = [int(by["core-4p4s-100k-128"].get("agg_msgs") or 0), int(by["core-4p4s-50k-1k"].get("agg_msgs") or 0)] + mb = [float(by["core-4p4s-100k-128"].get("agg_mb") or 0), float(by["core-4p4s-50k-1k"].get("agg_mb") or 0)] + 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[1].bar(labels, mb, color=[INDIGO, AMBER]) + axes[1].set_title("Aggregate MB / second") + fig.suptitle("Core NATS payload effect — NS1 host run", fontsize=12, fontweight="semibold") + fig.tight_layout() + save(fig, dest / "payload-size.png") + + if lats: + fig, ax = plt.subplots(figsize=(9.2, 4.6)) + ordered = [p for p in lats] + labels = [LAT_LABELS.get(p["run"], p["run"]) for p in ordered] + x = list(range(len(ordered))) + w = 0.25 + p50 = [ms(p["p50"]) for p in ordered] + p90 = [ms(p["p90"]) for p in ordered] + p99 = [ms(p["p99"]) for p in ordered] + ax.bar([i - w for i in x], p50, w, label="p50", color=TEAL) + ax.bar(x, p90, w, label="p90", color=INDIGO) + ax.bar([i + w for i in x], p99, w, label="p99", color=AMBER) + ax.set_xticks(x, labels) + ax.set_yscale("log") + ax.set_ylabel("milliseconds (log)") + ax.set_title("Round-trip delay — NS1 host run") + ax.axhline(1.0, color=GRID, linestyle="--", linewidth=1) + ax.legend(loc="upper left") + ax.set_axisbelow(True) + save(fig, dest / "delay-percentiles.png") + + +def figure(name: str, caption: str) -> str: + return f"![{caption}](charts/{name})\n\n*{caption}*" + + +def ratio(new: str | None, old: str | None) -> str: + if not new or not old: + return "—" + a, b = float(new), float(old) + if b == 0: + return "—" + return f"{a / b:.2f}×" + + +def delay_ms_val(p: dict[str, str] | None, key: str) -> str | None: + if not p or not p.get(key): + return None + return str(ms(p[key])) + + +def delta_table( + thru: list[dict[str, str]], + lats: list[dict[str, str]], + base_thru: list[dict[str, str]], + base_lats: list[dict[str, str]], + base_stamp: str, +) -> str: + bt = {p["run"]: p for p in base_thru} + nt = {p["run"]: p for p in thru} + bl = {p["run"]: p for p in base_lats} + nl = {p["run"]: p for p in lats} + keys = [ + ("core-1p1s-50k-128", "pub", "Core 1p1s 128 B pub msgs/s"), + ("core-8p8s-200k-128", "agg", "Core 8p8s 128 B aggregate msgs/s"), + ("js-1p-20k-128-r3", "pub", "JS file r=3 1p 128 B pub msgs/s"), + ("js-4p-50k-128-r3", "pub", "JS file r=3 4p 128 B pub msgs/s"), + ("js-4p-20k-1k-r3", "pub", "JS file r=3 4p 1 KiB pub msgs/s"), + ("js-mem-1p-20k-128-r3", "pub", "JS memory r=3 1p 128 B pub msgs/s"), + ("js-mem-4p-50k-128-r3", "pub", "JS memory r=3 4p 128 B pub msgs/s"), + ] + lines = [ + f"| Metric | Baseline `{base_stamp}` | This run | Ratio |", + "|--------|-------------------------|----------|-------|", + ] + for run, kind, label in keys: + old, new = bt.get(run), nt.get(run) + ok = "pub_msgs" if kind == "pub" else "agg_msgs" + ov = old.get(ok) if old else None + nv = new.get(ok) if new else None + lines.append(f"| {label} | {fmt_int(ov)} | {fmt_int(nv)} | {ratio(nv, ov)} |") + old_p, new_p = bl.get("lat-ping-1k-128"), nl.get("lat-ping-1k-128") + if old_p or new_p: + ov = delay_ms_val(old_p, "p99") + nv = delay_ms_val(new_p, "p99") + # smaller delay is better — invert ratio label + r = "—" + if ov and nv and float(nv) != 0: + r = f"{float(ov) / float(nv):.2f}× faster" if float(nv) < float(ov) else f"{float(nv) / float(ov):.2f}× slower" + lines.append( + f"| Ping p99 (ms) | {old_p.get('p99') if old_p else '—'} | {new_p.get('p99') if new_p else '—'} | {r} |" + ) + return "\n".join(lines) + + +def chart_delta( + thru: list[dict[str, str]], + base_thru: list[dict[str, str]], + dest: Path, +) -> None: + bt = {p["run"]: p for p in base_thru} + nt = {p["run"]: p for p in thru} + labels = ["Core 1p\n128 B pub", "JS file 1p\n128 B", "JS file 4p\n128 B", "JS mem 1p\n128 B"] + keys = ["core-1p1s-50k-128", "js-1p-20k-128-r3", "js-4p-50k-128-r3", "js-mem-1p-20k-128-r3"] + old = [int(bt[k]["pub_msgs"]) if k in bt and bt[k].get("pub_msgs") else 0 for k in keys] + new = [int(nt[k]["pub_msgs"]) if k in nt and nt[k].get("pub_msgs") else 0 for k in keys] + if not any(new): + return + fig, ax = plt.subplots(figsize=(9.2, 4.4)) + x = list(range(len(labels))) + w = 0.35 + ax.bar([i - w / 2 for i in x], old, w, label="Baseline 1c/1G/ZFS", color=MUTED) + ax.bar([i + w / 2 for i in x], new, w, label="8c/16G/tmpfs (+ mem rows)", color=INDIGO) + ax.set_xticks(x, labels) + ax.set_yscale("log") + ax.set_ylabel("publish messages / second (log)") + ax.set_title("Measured delta vs 20260912T051237Z") + ax.legend(loc="upper right") + ax.set_axisbelow(True) + save(fig, dest / "delta-vs-baseline.png") + + +def write_markdown( + folder: Path, + thru: list[dict[str, str]], + lats: list[dict[str, str]], + compare: Path | None = None, +) -> str: + before = kv_file(folder / "host-before.txt") + after = kv_file(folder / "host-after.txt") + stamp = folder.name + method = (Path(__file__).resolve().parent / "ns1-study-methodology.md").read_text(encoding="utf-8") + delta_md = "" + base_thru: list[dict[str, str]] = [] + base_lats: list[dict[str, str]] = [] + if compare and compare.is_dir(): + base_thru, base_lats = load_runs(compare) + delta_md = ( + f"## Measured delta vs `{compare.name}`\n\n" + "Baseline: 1 core / 1 GiB / JetStream on ZFS. This run: 8 cores / 16 GiB / " + "JetStream **tmpfs** (file r=3) plus extra **memory** store rows. veth/10G unchanged.\n\n" + + delta_table(thru, lats, base_thru, base_lats, compare.name) + + "\n" + ) + if (folder / "charts" / "delta-vs-baseline.png").exists(): + delta_md += "\n" + figure("delta-vs-baseline.png", "Baseline vs maximized publish rates (log)") + delta_md += "\n" + figs = [] + charts_dir = folder / "charts" + if (charts_dir / "core-throughput.png").exists(): + figs.append("### Core NATS\n\n" + figure("core-throughput.png", "Core NATS throughput at four loads (NS1 host run)")) + if (charts_dir / "payload-size.png").exists(): + figs.append("### Payload size (core)\n\n" + figure("payload-size.png", "Core NATS 128 B vs 1 KiB (NS1 host run)")) + if (charts_dir / "js-throughput.png").exists(): + figs.append("### JetStream r=3 file\n\n" + figure("js-throughput.png", "JetStream durable publish rate (NS1 host run)")) + if (charts_dir / "core-vs-js.png").exists(): + figs.append("### Core vs JetStream\n\n" + figure("core-vs-js.png", "Core vs JetStream publish rate, log scale (NS1 host run)")) + if (charts_dir / "delay-percentiles.png").exists(): + figs.append("### Delay\n\n" + figure("delay-percentiles.png", "Ping vs flood delay percentiles, log scale (NS1 host run)")) + + ping = next((p for p in lats if "ping" in p.get("run", "")), None) + js1 = next((p for p in thru if p["run"] == "js-1p-20k-128-r3"), None) + core1 = next((p for p in thru if p["run"] == "core-1p1s-50k-128"), None) + + md = f"""**Progress report (maximized NS1 study)** · run `{stamp}` (UTC) + +> **Execution provenance.** Every process for this study ran on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`): `maximize-ns1-study.sh` (cores/RAM/`max_mem`/tmpfs), then `study-on-ns1.sh`, `nats bench`, `latency.mjs` (LXC 510), matplotlib, pandoc, weasyprint. Traffic stayed on `vmbr1`. veth/10G was **not** changed. After the ladder, JetStream was put back on ZFS and product streams were re-created; **8 cores / 16 GiB / max_mem 8G stay**. + +{delta_md} + +--- + +## 1. Executive summary + +| Item | This NS1-host run | +|------|-------------------| +| Control plane | NS1.GEORGELAMBERT.ORG (`70.88.205.138`), user `{before.get("whoami", "marchon")}` | +| Bench client | LXC {before.get("client_vmid", "510")} `verae-px-worker` | +| Brokers | LXC 511/512/513 `nats-a/b/c` on `10.10.10.21–23` | +| Client URL | `{before.get("nats_url", "")}` | +| Host load before | `{before.get("loadavg", "n/a")}` | +| Host load after | `{after.get("loadavg", "n/a")}` | +| Core 1p1s 128 B pub | {fmt_int(core1.get("pub_msgs") if core1 else None)} msgs/s | +| JetStream 1p 128 B r=3 | {fmt_int(js1.get("pub_msgs") if js1 else None)} durable pubs/s | +| Ping p50 / p99 | {ping.get("p50") if ping else "—"} / {ping.get("p99") if ping else "—"} | + +Product traffic is the JetStream row. Ping is one-message delay. Flood is mailbox catch-up after a burst. + +--- + +## 2. Where it ran (and where it did not) + +```text +Operator laptop ──ssh──► NS1.GEORGELAMBERT.ORG 70.88.205.138 + study-on-ns1.sh + python3 build-ns1-study-report.py + sudo pct exec 510 ──► nats bench / latency.mjs + │ + ▼ vmbr1 + 10.10.10.21-23 :4222 +``` + +- **Did run on 138:** bash, python3, matplotlib, pandoc, weasyprint, `pct`, nats-server (in LXC), nats CLI and Node (in LXC 510). +- **Did not run on the laptop:** no local `nats bench`, no local charting, no local WeasyPrint for this file. + +--- + +## 3. Results (this run) + +### Host and brokers + +**Before** + +{varz_table(folder / "varz-before.json")} + +**After** + +{varz_table(folder / "varz-after.json")} + +nproc={before.get("nproc", "?")} · uname=`{before.get("uname", "")}` + +### Throughput + +{thru_table(thru)} + +### Round-trip delay + +{delay_table(lats)} + +{chr(10).join(figs)} + +--- + +{method} + +--- + +## 6. Reproducing this study + +On **NS1 only**: + +```bash +cd ~/verae-src/verae-nats-cluster +bash scripts/study-on-ns1.sh +``` + +The script exits if `hostname` is not NS1. Outputs land in `results//` including `nats-cluster-bench-ns1.{{md,html,pdf}}` and `charts/`. Copy those into `zapier-decisions/reports/` for the progress repo and catalog. + +Raw logs for this run: `results/{stamp}/`. +""" + return md + + +def render(md_path: Path, html_path: Path, pdf_path: Path) -> None: + css = Path(__file__).resolve().parent / "docs-print.css" + header = html_path.with_suffix(".hdr.html") + banner = html_path.with_suffix(".ban.html") + css_text = css.read_text(encoding="utf-8") if css.exists() else "" + header.write_text(f"\n", encoding="utf-8") + banner.write_text( + '
' + '' + '
Verae Time × Zapier · progress report · maximized NS1 study
' + "

NATS cluster message speed — maximized (RAM disk + 8 cores)

" + '
packages/zapier-decisions/reports/nats-cluster-bench-ns1.md
' + "
\n", + encoding="utf-8", + ) + r = subprocess.run( + [ + "pandoc", + str(md_path), + "-o", + str(html_path), + "--standalone", + f"--resource-path={md_path.parent}", + "--highlight-style=breezedark", + "--metadata=title=NATS cluster message speed — NS1 host study", + f"--include-in-header={header}", + f"--include-before-body={banner}", + ], + capture_output=True, + text=True, + ) + header.unlink(missing_ok=True) + banner.unlink(missing_ok=True) + if r.returncode != 0: + raise SystemExit(f"pandoc failed: {r.stderr[-800:]}") + w = subprocess.run(["weasyprint", str(html_path), str(pdf_path)], capture_output=True, text=True) + if w.returncode != 0: + raise SystemExit(f"weasyprint failed: {w.stderr[-800:]}") + + +def main() -> int: + folder = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + compare = Path(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else None + thru, lats = load_runs(folder) + charts(thru, lats, folder / "charts") + if compare and compare.is_dir(): + base_thru, _base_lats = load_runs(compare) + chart_delta(thru, base_thru, folder / "charts") + md = write_markdown(folder, thru, lats, compare if compare and compare.is_dir() else None) + md_path = folder / "nats-cluster-bench-ns1.md" + md_path.write_text(md, encoding="utf-8") + html_path = folder / "nats-cluster-bench-ns1.html" + pdf_path = folder / "nats-cluster-bench-ns1.pdf" + render(md_path, html_path, pdf_path) + print(f"wrote {md_path}") + print(f"wrote {html_path}") + print(f"wrote {pdf_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build-optimal-report.py b/scripts/build-optimal-report.py new file mode 100644 index 0000000..4b801a6 --- /dev/null +++ b/scripts/build-optimal-report.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""One large comparison report from all NS1 result folders + extras (UDP/MQTT/reconnect).""" +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.ticker import FuncFormatter + +_spec = importlib.util.spec_from_file_location( + "bench_report", Path(__file__).resolve().parent / "bench-report.py" +) +_br = importlib.util.module_from_spec(_spec) +assert _spec.loader +_spec.loader.exec_module(_br) +fmt_int = _br.fmt_int +parse_bench = _br.parse_bench +parse_lat = _br.parse_lat + +INDIGO, DEEP, TEAL, AMBER, MUTED = "#4f46e5", "#312e81", "#047857", "#b45309", "#5b6178" + + +def k_fmt(x, _p=None): + if x >= 1_000_000: + return f"{x/1e6:.2f}M" + if x >= 1000: + return f"{x/1000:.0f}k" + return f"{x:.0f}" + + +def load_folder(folder: Path) -> dict: + thru, lats, extra = {}, {}, {} + if not folder.is_dir(): + return {"thru": thru, "lats": lats, "extra": extra, "stamp": folder.name} + for f in folder.glob("*.txt"): + text = f.read_text(encoding="utf-8", errors="replace") + if f.name.startswith("host-"): + continue + if "mqtt" in f.name or "udp-ping" in f.name: + for line in text.splitlines(): + line = line.strip() + if line.startswith("{"): + extra[f.stem] = json.loads(line) + break + continue + lat = parse_lat(text) + if lat: + lats[f.stem] = lat + continue + p = parse_bench(text) + if p.get("pub_msgs") or p.get("agg_msgs"): + thru[f.stem] = p + return {"thru": thru, "lats": lats, "extra": extra, "stamp": folder.name} + + +def pub(d, run): + p = d["thru"].get(run) or {} + return p.get("pub_msgs") + + +def latp(d, run, key="p99"): + p = d["lats"].get(run) or {} + return p.get(key, "") + + +def row(*cells): + return "| " + " | ".join(cells) + " |" + + +def save(fig, path: Path): + fig.savefig(path, dpi=150, bbox_inches="tight", facecolor="white") + plt.close(fig) + + +def charts(latest: dict, folders: list[dict], dest: Path): + dest.mkdir(parents=True, exist_ok=True) + plt.rcParams.update({"font.size": 9, "axes.grid": True, "grid.color": "#d9dce8"}) + # r=1 vs r=3 file/mem from latest + labels, file_r, mem_r = [], [], [] + for lab, fr, mr in ( + ("1p file", "js-file-1p-20k-128-r1", "js-1p-20k-128-r3"), + ("4p file", "js-file-4p-50k-128-r1", "js-4p-50k-128-r3"), + ("1p mem", "js-mem-1p-20k-128-r1", "js-mem-1p-20k-128-r3"), + ("4p mem", "js-mem-4p-50k-128-r1", "js-mem-4p-50k-128-r3"), + ): + a, b = pub(latest, fr), pub(latest, mr) + if a or b: + labels.append(lab) + file_r.append(int(a or 0)) + mem_r.append(int(b or 0)) + if labels: + fig, ax = plt.subplots(figsize=(9.2, 4.3)) + x = range(len(labels)) + ax.bar([i - 0.2 for i in x], file_r, 0.4, label="replicas=1", color=TEAL) + ax.bar([i + 0.2 for i in x], mem_r, 0.4, label="replicas=3", color=AMBER) + ax.set_xticks(list(x), labels) + ax.set_ylabel("pub msgs/s") + ax.set_title("This run: replica cost (1 vs 3)") + ax.yaxis.set_major_formatter(FuncFormatter(k_fmt)) + ax.legend() + save(fig, dest / "replicas.png") + # historical JS 1p file + names, vals = [], [] + for d, label in zip( + folders, + [d["stamp"] for d in folders], + ): + v = pub(d, "js-1p-20k-128-r3") + if v: + names.append(label[-7:] if len(label) > 8 else label) + vals.append(int(v)) + if names: + fig, ax = plt.subplots(figsize=(9.2, 4.0)) + ax.bar(names, vals, color=INDIGO) + ax.set_title("JS file r=3 1p 128 B across studies") + ax.set_ylabel("pub msgs/s") + ax.yaxis.set_major_formatter(FuncFormatter(k_fmt)) + save(fig, dest / "history-js1p.png") + + +def write_md(latest: dict, hist: list[dict], charts_rel: str) -> str: + t = latest["thru"] + e = latest["extra"] + mqtt = e.get("mqtt-qos0-5k-128") or {} + udp = e.get("udp-ping-1k-128") or {} + ping = latest["lats"].get("lat-ping-1k-128") or {} + recon = latest["lats"].get("lat-reconnect-200-128") or {} + + def js_table(): + runs = [ + ("js-file-1p-20k-128-r1", "file r=1 1p 128 B"), + ("js-file-4p-50k-128-r1", "file r=1 4p 128 B"), + ("js-1p-20k-128-r3", "file r=3 1p 128 B"), + ("js-4p-50k-128-r3", "file r=3 4p 128 B"), + ("js-4p-20k-1k-r3", "file r=3 4p 1 KiB"), + ("js-file-1p-20k-4k-r3", "file r=3 1p 4 KiB"), + ("js-mem-1p-20k-128-r1", "memory r=1 1p 128 B"), + ("js-mem-4p-50k-128-r1", "memory r=1 4p 128 B"), + ("js-mem-1p-20k-128-r3", "memory r=3 1p 128 B"), + ("js-mem-4p-50k-128-r3", "memory r=3 4p 128 B"), + ("js-mem-4p-20k-1k-r3", "memory r=3 4p 1 KiB"), + ] + lines = [ + row("Run", "What", "Pub msgs/s", "Pub MB/s"), + row("---", "---", "---", "---"), + ] + for k, lab in runs: + p = t.get(k) + if not p: + continue + lines.append(row(f"`{k}`", lab, fmt_int(p.get("pub_msgs")), p.get("pub_mb") or "—")) + return "\n".join(lines) + + hist_lines = [ + row("Study", "Env", "Core 1p pub", "JS file r=3 1p", "JS mem r=3 4p", "Ping p99"), + row("---", "---", "---", "---", "---", "---"), + ] + labels_env = { + "20260912T045131Z": "1c/1G ZFS (off-box orch.)", + "20260912T051237Z": "1c/1G ZFS (NS1 orch.)", + "20260912T053120Z": "8c/16G tmpfs + mem extra", + } + for d in hist + [latest]: + env = labels_env.get(d["stamp"], f"8c/16G ZFS exhaustive `{d['stamp']}`") + hist_lines.append( + row( + f"`{d['stamp']}`", + env, + fmt_int(pub(d, "core-1p1s-50k-128")), + fmt_int(pub(d, "js-1p-20k-128-r3")), + fmt_int(pub(d, "js-mem-4p-50k-128-r3")), + latp(d, "lat-ping-1k-128"), + ) + ) + + r1 = int(pub(latest, "js-file-1p-20k-128-r1") or 0) + r3 = int(pub(latest, "js-1p-20k-128-r3") or 0) + mem1 = int(pub(latest, "js-mem-1p-20k-128-r1") or 0) + mem3 = int(pub(latest, "js-mem-1p-20k-128-r3") or 0) + replica_cost = f"{r1/r3:.2f}×" if r3 else "—" + mem_gain = f"{mem1/r3:.2f}×" if r3 and mem1 else "—" + + mqtt_rate = mqtt.get("pubs_per_sec", "—") + udp_p99 = udp.get("p99", "—") + + return f"""**Progress report — optimal configuration study** · `{latest['stamp']}` (UTC) · all code on **NS1.GEORGELAMBERT.ORG** (`70.88.205.138`) + +This document folds every ladder we have run (1-core ZFS, NS1-orchestrated, tmpfs maximize, and this exhaustive 8c/16G **ZFS** factorial) plus UDP / MQTT / reconnect probes. It recommends a lab config and a **three-box HP DL360 Gen10** projection. veth/10G was not changed. + +--- + +## 1. Verdict (read this first) + +**Keep NATS + JetStream.** Do not replace the fabric with MQTT, UDP, or a custom persistent-socket protocol for Verae jobs/events/archive. Those are either slower, less durable, or already what NATS is. + +**Lab (NS1, one host, three LXC) — optimal now** + +| Stream | Storage | Replicas | Why | +|--------|---------|----------|-----| +| `ZAPIER_JOBS`, `ZAPIER_WEBHOOKS`, `VERAE_ARCHIVE` | **file** (ZFS) | **3** | Survive a nats LXC death; archive must persist | +| `ZAPIER_EVENTS` | **memory** | **3** | Waiters are latency-sensitive; events rebuild from job status | +| `ZAPIER_USAGE` | file | 3 | Telemetry, limits + max-age | + +Keep **8 cores / 16 GiB / `max_mem: 8G`** on 510–513 (already live). Do **not** leave JetStream on tmpfs. Do **not** drop product streams to r=1. Reuse **one NATS connection per process** (already true in middleware); never connect-per-message. + +**Metal (3× DL360 Gen10) — optimal later** + +Same stream table. File store on **local NVMe/M.2**, not a shared SAN. Cluster + client on **10GbE** (or 25GbE if you already have it). Dual Gold Xeon is surplus CPU for this workload; 8–16 cores dedicated to `nats-server` is enough. Expected JS file r=3: **~40–80k** 128 B pubs/s (about **3–6×** this lab’s 8c ZFS 1p, **2–4×** tmpfs 1p) — bounded by **10GbE replica RTT**, not by Xeon clocks. Core NATS will sit in the **1–3M msgs/s** band until the NIC saturates (~9 Gbit/s ≈ 8–9M × 128 B theoretical; CPU and client will hit first). + +--- + +## 2. What we actually ran (this exhaustive pass) + +Live cluster during this run: LXC 510–513 **8 cores / 16 GiB**, JetStream **on ZFS** (tmpfs from the maximize study was already unmounted). Extra factorial: file/memory × replicas 1/3, 4 KiB file r=3, reconnect-per-message ping, UDP echo 510→511, MQTT QoS0 against nats-a `:1883`. Product streams were not the bench target. + +### 2.1 Cross-study history + +{chr(10).join(hist_lines)} + +![JS 1p file r=3 history]({charts_rel}/history-js1p.png) + +### 2.2 This run — JetStream factorial + +{js_table()} + +Replica **1 vs 3** on this stand (file 1p 128 B): r=1 is {fmt_int(str(r1) if r1 else None)} vs r=3 {fmt_int(str(r3) if r3 else None)} ({replica_cost} if r=3 is the slower one). Memory r=1 1p {fmt_int(str(mem1) if mem1 else None)} vs memory r=3 {fmt_int(str(mem3) if mem3 else None)}. + +![Replica cost]({charts_rel}/replicas.png) + +### 2.3 Delay, reconnect tax, UDP, MQTT + +| Probe | Result | Meaning | +|-------|--------|---------| +| NATS ping (persistent sockets) p50 / p99 | {ping.get('p50','—')} / {ping.get('p99','—')} | Quiet hop with a long-lived TCP conn | +| NATS **reconnect-per-message** p50 / p99 | {recon.get('p50','—')} / {recon.get('p99','—')} | TCP+NATS handshake on every pub — this is the tax to avoid | +| UDP echo 510→511 p99 | {udp_p99} | Raw datagram ceiling on the same veth (no NATS) | +| MQTT QoS0 5k×128 B | {mqtt_rate} pubs/s | nats-server MQTT gateway on `:1883` | + +Core 1p1s 128 B this run: {fmt_int(pub(latest, 'core-1p1s-50k-128'))} pub msgs/s. Flood delay is still backlog/consume_rate, not RTT. + +--- + +## 3. Alternative transports (why we are not switching the fabric) + +NATS already **is** persistent TCP sockets with a tiny binary protocol, automatic reconnect, and optional JetStream durability. “Reduce connection overhead” is a **client** discipline: hold the connection. The reconnect probe exists to prove that opening a socket per job would dominate ping RTT. + +| Idea | Fit for Verae jobs/events/archive | Throughput vs NATS core | Durability | +|------|-----------------------------------|-------------------------|------------| +| **NATS core pub/sub** | Fan-out, request-reply (`verae.billing.*`) | Highest we measured (~0.5–2M msgs/s) | None | +| **NATS JetStream file r=3** | Jobs, webhooks, archive | ~8–23k on this lab; see metal projection | Disk + 1-node loss | +| **NATS JetStream memory r=3** | Events mailbox | ~22–36k on this lab | RAM + 1-node loss; **empty on full restart** | +| **MQTT** (NATS gateway or Mosquitto) | IoT endpoints that already speak MQTT | This probe: {mqtt_rate} pubs/s QoS0 — typically **well below** NATS core; QoS1 ≈ JetStream-ish with more chatter | QoS1/2 session state; not our WORM model | +| **UDP** | Telemetry that may drop | RTT {udp_p99} p99 — fastest hop, **no** reliability, no cluster, no auth | None | +| **Custom persistent sockets / HTTP long-poll** | Worse NATS | You would re-implement reconnect, flow control, and fan-out | DIY | +| **WebSocket** | Browsers only | Extra framing; NATS already has WS for UIs, not for middleware | Same as core/JS behind it | +| **QUIC / WebTransport** | Lossy WAN / browsers | NATS QUIC is not the lab path; 10GbE LAN does not need it | Same | +| **Kafka / Redis streams** | Heavy log replay | Higher ops cost; not on `vmbr1` today | Yes, heavier | + +**MQTT:** NATS documents MQTT as an *enabling* gateway for existing IoT, and prefers NATS end-to-end for greenfield. Zapier cloud never talks NATS or MQTT; it talks HTTPS. Putting MQTT in the middle of timestamp jobs adds protocol translation and QoS timers without helping `jobId → events`. Use MQTT only if a device already cannot speak NATS. + +**UDP:** Fine as a *measurement* of veth RTT. Unusable as the job fabric (no ack, no replica, no flow control). NATS ping is already within a small multiple of UDP on this bridge. + +**Persistence sockets:** Middleware and keep already keep `NATS_URL` connections open. Optimal: one connection (or a small pool) per process, `max_reconnect`, jitter, no `connect()` in the per-job path. The reconnect ladder is the anti-pattern. + +--- + +## 4. Optimal configurations + +### 4.1 NS1 lab (now) + +1. **Leave 8 cores / 16 GiB** on nats-a/b/c and the worker. Host has 40 cores / 377 GiB; this is cheap. +2. **`max_mem: 8G`** stays. Required for memory streams. +3. **File r=3 on ZFS** for jobs/webhooks/archive. tmpfs doubled JS 1p (7.4k→17k) but **loses the stream on reboot** — unacceptable for archive. +4. **Memory r=3 for `ZAPIER_EVENTS`** if we accept “all three nats CTs reboot ⇒ in-flight waiters fall back to HTTP poll.” That matches the designed wait path (`GET /api/status/{{jobId}}`). +5. **r=1 only for throwaway benches**, never product streams. Replica=3 is the point of three guests. +6. **veth on vmbr1, no fake 10G NICs.** Already 10000Mb/s; JS does not fill it. +7. **Pin cpusets** later if keep/fleet steal; not required to beat these numbers. +8. Clients: persistent NATS connections; pull consumers with bounded `max_ack_pending` for webhooks. + +### 4.2 Three HP DL360 Gen10 (projection — not measured) + +Assumed bill of materials (state it in the buy): + +| Piece | Assumption | +|-------|------------| +| Chassis | 3× DL360 Gen10 1U | +| CPU | Dual 2nd-gen Xeon **Gold** (e.g. 6226R 16c or 6248 20c — **32–40 cores/box**) | +| Memory | DDR4-2933, **192–384 GiB**/box (6–12×32 GiB); NATS will not use most of it | +| Storage | **NVMe M.2 or U.2** for `/var/lib/nats/jetstream` (XFS or ext4, **not** shared ZFS over the network). RAID1 of two NVMe if you want disk HA *inside* a box | +| Network | **10GbE** (FlexibleLOM or PCIe); dedicated VLAN for `:4222`+`:6222`. Do not share with public `vmbr0` traffic | +| OS | Debian/Ubuntu bare metal, `nats-server` systemd, same `nats.conf` as lab (bind private IP only) | + +**What changes vs NS1 LXC** + +| Factor | NS1 today | 3× DL360 | Effect on JS file r=3 | +|--------|-----------|----------|------------------------| +| Failure domain | 1 Proxmox host | 3 chassis, 3 NVMe, 3 NICs | r=3 **means** something | +| Disk | Shared ZFS SSD2 | Local NVMe fsync ~50–150 µs | Big win vs ZFS; similar to tmpfs for sequential 128 B | +| Replica path | veth/bridge (~µs–tens of µs) | 10GbE RTT typically **50–200 µs** | **Slower than same-host tmpfs**, faster than a bad SAN | +| CPU | 8 of 40 shared | 32–40 dedicated Gold cores | Headroom for many clients, not 10× JS | +| NIC | software 10G veth, already ~5 Gbit/s core | real 10GbE ~9 Gbit/s TCP | Core NATS can grow; JS r=3 stays replica-bound | + +**Projected bands** (128 B, 3-node cluster, dedicated 10GbE, local NVMe, 8+ cores pinned to nats-server): + +| Workload | NS1 measured (best) | DL360 projection | Confidence | +|----------|---------------------|------------------|------------| +| Core pub/sub 1p | 0.5–0.8M | **0.8–2M** | Medium — NIC + syscall, plenty of CPU | +| Core 4p4s 1 KiB | ~0.6–0.7M (~0.6 GB/s) | **~1M msgs/s / ~1 GB/s** approaching 10GbE | Medium | +| JS file r=1 | this run r=1 | **80–200k** pubs/s | Medium — NVMe + no replica wait | +| JS file r=3 | 7–23k (ZFS/tmpfs) | **40–80k** pubs/s | Medium-low — replica RTT dominates; 3 NVMe still help vs shared ZFS | +| JS memory r=3 | 22–36k | **50–100k** | Medium-low — RAM + 10GbE ack | +| Ping p99 | 0.7–1.4 ms | **0.2–0.6 ms** | Medium — real NIC but no Proxmox tax | + +These are **not** DL360 measurements. Scale from: (a) our replica-1 vs replica-3 ratio once this run’s r=1 numbers exist, (b) tmpfs vs ZFS ratio (2.35× on 1p), (c) Synadia/nats bench async file r=1 ~100–400k on NVMe loopback, derated for 10GbE RTT. + +**Buy notes:** M.2 via Dual uFF / enablement kit; put JetStream on NVMe **directly**, not behind a RAID controller write-through unless you measure. 1GbE onboard is a trap — use 10GbE for `:6222`. Dual Gold is for isolation (nats vs worm/tree vs OS), not because JS needs 56 cores. + +--- + +## 5. What we are not doing + +- MQTT as the Zapier or middleware transport. +- UDP for jobs. +- Emulated 10G fiber NICs on LXC. +- tmpfs as the production store. +- r=1 for product streams. +- Connect-per-job. + +Re-run exhaustive: `bash scripts/exhaustive-ns1-study.sh` on NS1. +""" + + +def render(md_path: Path, html_path: Path, pdf_path: Path) -> None: + css = Path(__file__).resolve().parent / "docs-print.css" + header = html_path.with_suffix(".hdr.html") + banner = html_path.with_suffix(".ban.html") + header.write_text(f"\n", encoding="utf-8") + banner.write_text( + '
' + '' + '
Verae Time × Zapier · progress report
' + "

NATS optimal configuration study

" + '
packages/zapier-decisions/reports/optimal-config/REPORT.md
' + "
\n", + encoding="utf-8", + ) + r = subprocess.run( + [ + "pandoc", + str(md_path), + "-o", + str(html_path), + "--standalone", + f"--resource-path={md_path.parent}", + "--highlight-style=breezedark", + "--metadata=title=NATS optimal configuration study", + f"--include-in-header={header}", + f"--include-before-body={banner}", + ], + capture_output=True, + text=True, + ) + header.unlink(missing_ok=True) + banner.unlink(missing_ok=True) + if r.returncode != 0: + raise SystemExit(f"pandoc failed: {r.stderr[-600:]}") + w = subprocess.run(["weasyprint", str(html_path), str(pdf_path)], capture_output=True, text=True) + if w.returncode != 0: + raise SystemExit(f"weasyprint failed: {w.stderr[-600:]}") + + +def main() -> int: + latest = Path(sys.argv[1]) + hist_dirs = [Path(p) for p in sys.argv[2:] if p and Path(p).is_dir()] + data_latest = load_folder(latest) + hist = [load_folder(p) for p in hist_dirs] + charts_dir = latest / "charts-optimal" + charts(data_latest, hist + [data_latest], charts_dir) + md = write_md(data_latest, hist, "charts-optimal") + md_path = latest / "optimal-config.md" + md_path.write_text(md, encoding="utf-8") + html_path = latest / "optimal-config.html" + pdf_path = latest / "optimal-config.pdf" + render(md_path, html_path, pdf_path) + print(f"wrote {md_path}") + print(f"wrote {html_path}", file=sys.stderr) + print(f"wrote {pdf_path}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create-cluster.sh b/scripts/create-cluster.sh new file mode 100755 index 0000000..bb42239 --- /dev/null +++ b/scripts/create-cluster.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Create three distinct Proxmox LXC guests and start a JetStream cluster on vmbr1. +# Does not touch host loopback NATS (127.0.0.1:4222) or vmbr0. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +. "$ROOT/cluster.env" +# shellcheck disable=SC1091 +. "$ROOT/scripts/lib-ct.sh" +ct_require_proxmox + +mapfile -t rows < <(printf '%s\n' "$NODES" | awk 'NF==3 {print}') +[[ ${#rows[@]} -eq 3 ]] || { echo "need exactly 3 nodes in cluster.env" >&2; exit 1; } + +declare -a VMIDS NAMES IPS +for row in "${rows[@]}"; do + # shellcheck disable=SC2086 + set -- $row + VMIDS+=("$1"); NAMES+=("$2"); IPS+=("$3") +done + +i=0 +for i in 0 1 2; do + ct_ensure "${VMIDS[$i]}" "${NAMES[$i]}" "${IPS[$i]}" + ct_bootstrap_user "${VMIDS[$i]}" +done + +# Install nats-server + conf + systemd on each guest +for i in 0 1 2; do + routes="" + for j in 0 1 2; do + [[ $i -eq $j ]] && continue + routes="${routes} nats-route://${IPS[$j]}:6222"$'\n' + done + tmpconf="$(mktemp)" + NAME="${NAMES[$i]}" IP="${IPS[$i]}" CLUSTER="$CLUSTER_NAME" ROUTES="$routes" \ + python3 - "$ROOT/conf/nats.conf.tmpl" "$tmpconf" <<'PY' +import os, pathlib, sys +t = pathlib.Path(sys.argv[1]).read_text() +out = t.replace("{{NAME}}", os.environ["NAME"]).replace("{{IP}}", os.environ["IP"]).replace("{{CLUSTER}}", os.environ["CLUSTER"]).replace("{{ROUTES}}", os.environ["ROUTES"]) +pathlib.Path(sys.argv[2]).write_text(out) +PY + sudo pct exec "${VMIDS[$i]}" -- bash -c 'cat > /tmp/nats.conf' < "$tmpconf" + sudo pct exec "${VMIDS[$i]}" -- bash -c 'cat > /tmp/nats-server.service' < "$ROOT/systemd/nats-server.service" + rm -f "$tmpconf" + sudo pct exec "${VMIDS[$i]}" -- bash -lc " +set -e +export DEBIAN_FRONTEND=noninteractive +id nats >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin nats +install -d -m 755 -o nats -g nats /var/lib/nats/jetstream /etc/nats +mv /tmp/nats.conf /etc/nats/nats.conf +chown root:root /etc/nats/nats.conf +chmod 644 /etc/nats/nats.conf +if [[ ! -x /usr/local/bin/nats-server ]]; then + curl -fsSL https://github.com/nats-io/nats-server/releases/download/v${NATS_VER}/nats-server-v${NATS_VER}-linux-amd64.tar.gz -o /tmp/nats.tgz + tar -xzf /tmp/nats.tgz -C /tmp + install -m 0755 /tmp/nats-server-v${NATS_VER}-linux-amd64/nats-server /usr/local/bin/nats-server + rm -rf /tmp/nats.tgz /tmp/nats-server-v${NATS_VER}-linux-amd64 +fi +install -m 644 /tmp/nats-server.service /etc/systemd/system/nats-server.service +systemctl daemon-reload +systemctl enable --now nats-server +" + echo "nats-server ${NAMES[$i]} ${IPS[$i]}:4222 cluster ${IPS[$i]}:6222" +done + +echo "cluster client URL: nats://${IPS[0]}:4222,nats://${IPS[1]}:4222,nats://${IPS[2]}:4222" +echo "lab loopback NATS on the host is unchanged (127.0.0.1:4222)" +echo "next: bash $ROOT/scripts/test.sh" diff --git a/scripts/cutover-ns1.sh b/scripts/cutover-ns1.sh new file mode 100755 index 0000000..6fa073c --- /dev/null +++ b/scripts/cutover-ns1.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Point NS1 test modules at the 3-node vmbr1 cluster. Does not change MOCK_VERAE or Zapier. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +. "$ROOT/client.env" +export PATH="/usr/sbin:/usr/bin:/bin:$PATH" +bash "$ROOT/scripts/ensure-streams.sh" +echo "NATS_URL=$NATS_URL" +echo "streams ensured. restart keep + fleet on the host after copying overlay/service JSON." diff --git a/scripts/docs-print.css b/scripts/docs-print.css new file mode 100644 index 0000000..99256e9 --- /dev/null +++ b/scripts/docs-print.css @@ -0,0 +1,122 @@ +/* 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; } +} diff --git a/scripts/ensure-streams.sh b/scripts/ensure-streams.sh new file mode 100755 index 0000000..4b661be --- /dev/null +++ b/scripts/ensure-streams.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Create product JetStream streams with replicas=3 on the Proxmox cluster. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +. "$ROOT/client.env" +export PATH="/usr/sbin:/usr/bin:/bin:/usr/local/bin:$PATH" +VMID="${1:-511}" +sudo pct exec "$VMID" -- bash -lc " +set -e +export DEBIAN_FRONTEND=noninteractive +export PATH=/usr/local/bin:/usr/bin:/bin +export NATS_URL=nats://10.10.10.21:4222 +if [[ ! -x /usr/local/bin/nats ]]; then + apt-get install -y --no-install-recommends unzip >/dev/null + curl -fsSL https://github.com/nats-io/natscli/releases/download/v0.1.6/nats-0.1.6-linux-amd64.zip -o /tmp/natscli.zip + rm -rf /tmp/natscli && mkdir -p /tmp/natscli + unzip -o /tmp/natscli.zip -d /tmp/natscli >/dev/null + BIN=\$(find /tmp/natscli -type f -name nats | head -1) + install -m 0755 \"\$BIN\" /usr/local/bin/nats +fi +add() { + local name=\$1 subj=\$2 + nats stream info \"\$name\" >/dev/null 2>&1 && return 0 + nats stream add \"\$name\" --subjects=\"\$subj\" --replicas=3 --storage=file --retention=limits --discard=old --max-msgs=-1 --max-bytes=-1 --max-age=24h --dupe-window=2m --defaults +} +add ZAPIER_JOBS 'verae.zapier.jobs.watch' +add ZAPIER_EVENTS 'verae.zapier.jobs.events' +add ZAPIER_WEBHOOKS 'verae.zapier.webhooks.deliver' +add ZAPIER_USAGE 'verae.zapier.usage' +add VERAE_ARCHIVE 'verae.archive.>' +nats stream ls +" +echo "streams ready on cluster (replicas=3)" diff --git a/scripts/exhaustive-ns1-study.sh b/scripts/exhaustive-ns1-study.sh new file mode 100755 index 0000000..f369570 --- /dev/null +++ b/scripts/exhaustive-ns1-study.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Exhaustive NS1 ladder on the *current* 8c/16G cluster with JetStream on ZFS. +# Adds r=1 vs r=3, file vs memory, reconnect tax, UDP echo, MQTT gateway probe. +# Does not tmpfs (product streams stay). Must run on NS1. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="/usr/sbin:/usr/bin:/bin:/usr/local/bin:$PATH" +HOST="$(hostname -f 2>/dev/null || hostname)" +case "$HOST" in + NS1.GEORGELAMBERT.ORG|NS1|ns1.georgelambert.org|ns1) ;; + *) echo "refusing: exhaustive-ns1-study.sh must run on NS1, got '$HOST'" >&2; exit 1 ;; +esac + +export EXHAUSTIVE=1 +export JS_EXTRA_MEMORY=1 +export COMPARE_DIR="${COMPARE_DIR:-$ROOT/results/20260912T051237Z}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +export BENCH_OUT="$ROOT/results/$STAMP" +mkdir -p "$BENCH_OUT" + +# MQTT gateway on nats-a only (vmbr1). Restored after. +MQTT_CONF=/etc/nats/nats.conf +enable_mqtt() { + sudo pct exec 511 -- bash -lc ' +set -e +f=/etc/nats/nats.conf +grep -q "^mqtt {" "$f" && exit 0 +cat >> "$f" </dev/null 2>&1 || true' +sudo pct exec 511 -- bash -c 'cat > /tmp/udp-probe.mjs' < "$ROOT/scripts/udp-probe.mjs" +sudo pct exec 510 -- bash -c 'cat > /tmp/nats-lat/udp-probe.mjs' < "$ROOT/scripts/udp-probe.mjs" +sudo pct exec 511 -- bash -lc 'setsid node /tmp/udp-probe.mjs server 9999 >/tmp/udp-echo.log 2>&1 < /dev/null &' +sleep 1 +echo "=== udp-ping-1k-128 ===" | tee "$OUT/udp-ping-1k-128.txt" +sudo pct exec 510 -- bash -lc 'node /tmp/nats-lat/udp-probe.mjs client 10.10.10.21 1000 9999 128' | tee -a "$OUT/udp-ping-1k-128.txt" +sudo pct exec 511 -- bash -lc 'pkill -f "udp-probe.mjs server" || true' + +# MQTT QoS0 +sudo pct exec 510 -- bash -lc ' +set -e +cd /tmp/nats-lat +if [[ ! -d node_modules/mqtt ]]; then npm install --no-audit --no-fund mqtt@10 >/dev/null; fi +' +sudo pct exec 510 -- bash -c 'cat > /tmp/nats-lat/mqtt-probe.mjs' < "$ROOT/scripts/mqtt-probe.mjs" +echo "=== mqtt-qos0-5k-128 ===" | tee "$OUT/mqtt-qos0-5k-128.txt" +sudo pct exec 510 -- bash -lc 'cd /tmp/nats-lat && node mqtt-probe.mjs mqtt://10.10.10.21:1883 5000 128' | tee -a "$OUT/mqtt-qos0-5k-128.txt" || echo '{"error":"mqtt probe failed"}' | tee -a "$OUT/mqtt-qos0-5k-128.txt" + +python3 "$ROOT/scripts/build-optimal-report.py" "$OUT" \ + "$ROOT/results/20260912T045131Z" \ + "$ROOT/results/20260912T051237Z" \ + "$ROOT/results/20260912T053120Z" +echo "exhaustive complete $OUT" diff --git a/scripts/latency.mjs b/scripts/latency.mjs new file mode 100644 index 0000000..d15ae95 --- /dev/null +++ b/scripts/latency.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Pub→sub round trip through the cluster (two connections). + * Usage: NATS_URL=... node latency.mjs [count] [payloadBytes] [publishers] [ping|flood|reconnect] + * ping = sequential publish-wait on persistent sockets (one-message RTT) + * flood = publish the batch then drain (queueing under burst) + * reconnect = connect, one publish, wait, close — measures handshake tax + */ +import { connect, headers } from "nats"; + +const url = process.env.NATS_URL || "nats://10.10.10.21:4222"; +const count = Number(process.argv[2] || 5000); +const size = Number(process.argv[3] || 128); +const pubs = Number(process.argv[4] || 1); +const mode = process.argv[5] || "flood"; +const servers = url.split(",").map((s) => s.trim()); +const subject = `bench.lat.${process.pid}`; +const payload = new Uint8Array(size); + +function pct(sorted, p) { + if (!sorted.length) return 0; + const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[i]; +} + +const samples = []; +if (mode === "reconnect") { + const subNc = await connect({ servers, name: "lat-sub" }); + let resolveOne = null; + const sub = subNc.subscribe(subject, { max: count }); + const consume = (async () => { + for await (const m of sub) { + const sent = Number(m.headers?.get("t") || 0); + samples.push(Number(process.hrtime.bigint() / 1000n) - sent); + resolveOne?.(); + } + })(); + await subNc.flush(); + for (let i = 0; i < count; i++) { + const got = new Promise((r) => { + resolveOne = r; + }); + const pubNc = await connect({ servers, name: `lat-re-${i}` }); + const h = headers(); + h.set("t", String(process.hrtime.bigint() / 1000n)); + pubNc.publish(subject, payload, { headers: h }); + await pubNc.flush(); + await got; + await pubNc.close(); + } + await consume; + await subNc.close(); +} else if (mode === "ping") { + const subNc = await connect({ servers, name: "lat-sub" }); + const pubNc = await connect({ servers, name: "lat-pub" }); + let resolveOne = null; + const sub = subNc.subscribe(subject, { max: count }); + const consume = (async () => { + for await (const m of sub) { + const sent = Number(m.headers?.get("t") || 0); + samples.push(Number(process.hrtime.bigint() / 1000n) - sent); + resolveOne?.(); + } + })(); + await subNc.flush(); + for (let i = 0; i < count; i++) { + const got = new Promise((r) => { + resolveOne = r; + }); + const h = headers(); + h.set("t", String(process.hrtime.bigint() / 1000n)); + pubNc.publish(subject, payload, { headers: h }); + await got; + } + await consume; + await pubNc.close(); + await subNc.close(); +} else { + const subNc = await connect({ servers, name: "lat-sub" }); + const sub = subNc.subscribe(subject, { max: count }); + const done = (async () => { + for await (const m of sub) { + const sent = Number(m.headers?.get("t") || 0); + if (sent) samples.push(Number(process.hrtime.bigint() / 1000n) - sent); + } + })(); + await subNc.flush(); + const per = Math.ceil(count / pubs); + const publishers = []; + for (let p = 0; p < pubs; p++) { + publishers.push( + (async () => { + const nc = await connect({ servers, name: `lat-pub-${p}` }); + const n = p === pubs - 1 ? count - per * (pubs - 1) : per; + for (let i = 0; i < n; i++) { + const h = headers(); + h.set("t", String(process.hrtime.bigint() / 1000n)); + nc.publish(subject, payload, { headers: h }); + } + await nc.flush(); + await nc.close(); + })(), + ); + } + await Promise.all(publishers); + await done; + await subNc.close(); +} + +samples.sort((a, b) => a - b); +const sum = samples.reduce((a, b) => a + b, 0); +const us = (n) => `${(n / 1000).toFixed(3)}ms`; +console.log( + JSON.stringify({ + count: samples.length, + pubs, + size, + mode, + min_us: samples[0], + avg_us: Math.round(sum / samples.length), + p50_us: pct(samples, 50), + p90_us: pct(samples, 90), + p99_us: pct(samples, 99), + max_us: samples[samples.length - 1], + min: us(samples[0]), + avg: us(sum / samples.length), + p50: us(pct(samples, 50)), + p90: us(pct(samples, 90)), + p99: us(pct(samples, 99)), + max: us(samples[samples.length - 1]), + }), +); diff --git a/scripts/lib-ct.sh b/scripts/lib-ct.sh new file mode 100755 index 0000000..1a4ff8e --- /dev/null +++ b/scripts/lib-ct.sh @@ -0,0 +1,62 @@ +# shellcheck shell=bash +# Shared LXC bootstrap for NS1 Proxmox. Does not generate SSH keys if one exists. +export PATH="/usr/sbin:/usr/bin:/bin:$PATH" + +ct_require_proxmox() { + if [[ ! -d /etc/pve/nodes ]]; then + echo "not a Proxmox host" >&2 + return 1 + fi + command -v pct >/dev/null || { echo "pct missing" >&2; return 1; } +} + +ct_ensure() { + local vmid="$1" hostname="$2" ip="$3" + if [[ ! -f "$TEMPLATE" ]]; then + echo "missing template $TEMPLATE" >&2 + return 1 + fi + if ! sudo pct status "$vmid" >/dev/null 2>&1; then + echo "pct create $vmid $hostname $ip/24" + sudo pct create "$vmid" "$TEMPLATE" \ + --hostname "$hostname" \ + --memory "$MEMORY" --cores "$CORES" --swap 256 \ + --net0 "name=eth0,bridge=${BRIDGE},ip=${ip}/24,gw=${GW},type=veth" \ + --rootfs "${STORAGE}:${DISK}" \ + --unprivileged 1 --onboot 1 --nameserver "$DNS" \ + --features nesting=1 \ + --ostype ubuntu + else + echo "CT $vmid already exists" + fi + sudo pct start "$vmid" 2>/dev/null || true + local i + for i in $(seq 1 40); do + sudo pct exec "$vmid" -- true 2>/dev/null && return 0 + sleep 2 + done + echo "CT $vmid did not start" >&2 + return 1 +} + +ct_bootstrap_user() { + local vmid="$1" + local pub="" + [[ -f "$HOME/.ssh/id_ed25519.pub" ]] && pub="$(cat "$HOME/.ssh/id_ed25519.pub")" + [[ -z "$pub" && -f "$HOME/.ssh/authorized_keys" ]] && pub="$(head -1 "$HOME/.ssh/authorized_keys")" + [[ -n "$pub" ]] || { echo "no ssh public key" >&2; return 1; } + sudo pct exec "$vmid" -- bash -lc " +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends openssh-server sudo curl ca-certificates xz-utils tar +id $USER_NAME >/dev/null 2>&1 || useradd -m -s /bin/bash $USER_NAME +echo '$USER_NAME ALL=(ALL) NOPASSWD:ALL' >/etc/sudoers.d/90-$USER_NAME +chmod 440 /etc/sudoers.d/90-$USER_NAME +install -d -m 700 -o $USER_NAME -g $USER_NAME /home/$USER_NAME/.ssh +grep -qxF '$pub' /home/$USER_NAME/.ssh/authorized_keys 2>/dev/null || echo '$pub' >>/home/$USER_NAME/.ssh/authorized_keys +chown $USER_NAME:$USER_NAME /home/$USER_NAME/.ssh/authorized_keys +chmod 600 /home/$USER_NAME/.ssh/authorized_keys +systemctl enable --now ssh +" +} diff --git a/scripts/maximize-ns1-study.sh b/scripts/maximize-ns1-study.sh new file mode 100755 index 0000000..7799a6d --- /dev/null +++ b/scripts/maximize-ns1-study.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Maximize nats LXC resources + RAM-disk JetStream, run the NS1 study, then +# put product streams back on ZFS. Cores/RAM/max_mem stay raised. +# Must run on NS1.GEORGELAMBERT.ORG. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="/usr/sbin:/usr/bin:/bin:/usr/local/bin:$PATH" + +HOST="$(hostname -f 2>/dev/null || hostname)" +case "$HOST" in + NS1.GEORGELAMBERT.ORG|NS1|ns1.georgelambert.org|ns1) ;; + *) + echo "refusing: maximize-ns1-study.sh must run on NS1, got '$HOST'" >&2 + exit 1 + ;; +esac + +CORES="${CORES:-8}" +MEMORY="${MEMORY:-16384}" +TMPFS_SIZE="${TMPFS_SIZE:-8G}" +NATS_VMS=(511 512 513) +ALL_VMS=(510 511 512 513) + +apply_resources() { + local v + for v in "${ALL_VMS[@]}"; do + echo "pct set $v --cores $CORES --memory $MEMORY" + sudo pct set "$v" --cores "$CORES" --memory "$MEMORY" + done +} + +patch_max_mem() { + local v + for v in "${NATS_VMS[@]}"; do + sudo pct exec "$v" -- sed -i -E 's/max_mem:[[:space:]]*[0-9]+[MmGg]/max_mem: 8G/' /etc/nats/nats.conf + sudo pct exec "$v" -- grep -n max_mem /etc/nats/nats.conf + done +} + +mount_tmpfs() { + local v + for v in "${NATS_VMS[@]}"; do + sudo pct exec "$v" -- bash -lc " +set -e +systemctl stop nats-server +mkdir -p /var/lib/nats/jetstream +if ! mountpoint -q /var/lib/nats/jetstream; then + mount -t tmpfs -o size=${TMPFS_SIZE} nats-js /var/lib/nats/jetstream +fi +chown nats:nats /var/lib/nats/jetstream +chmod 755 /var/lib/nats/jetstream +systemctl start nats-server +mount | grep jetstream +" + done +} + +unmount_tmpfs() { + local v + for v in "${NATS_VMS[@]}"; do + sudo pct exec "$v" -- bash -lc ' +set -e +systemctl stop nats-server || true +if mountpoint -q /var/lib/nats/jetstream; then + umount /var/lib/nats/jetstream +fi +mkdir -p /var/lib/nats/jetstream +chown nats:nats /var/lib/nats/jetstream +systemctl start nats-server +' + done +} + +wait_cluster() { + local n=0 + until sudo pct exec 511 -- curl -fsS --max-time 2 http://127.0.0.1:8222/varz >/dev/null 2>&1; do + n=$((n + 1)) + [[ $n -lt 30 ]] || { echo "nats-a varz not up" >&2; return 1; } + sleep 1 + done + sleep 2 +} + +restore_durable() { + echo "restoring ZFS JetStream (product streams)" + unmount_tmpfs + wait_cluster + bash "$ROOT/scripts/ensure-streams.sh" || true +} + +apply_resources +patch_max_mem +mount_tmpfs +wait_cluster +trap restore_durable EXIT + +export JS_EXTRA_MEMORY=1 +export COMPARE_DIR="${COMPARE_DIR:-$ROOT/results/20260912T051237Z}" +# recorded in host-before by appending after dump starts — study script reads pct config live +bash "$ROOT/scripts/study-on-ns1.sh" + +echo "maximize study finished; trap will restore ZFS jetstream" diff --git a/scripts/mqtt-probe.mjs b/scripts/mqtt-probe.mjs new file mode 100755 index 0000000..cdcc63f --- /dev/null +++ b/scripts/mqtt-probe.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** MQTT QoS0 publish rate against nats-server MQTT gateway. */ +import mqtt from "mqtt"; + +const url = process.argv[2] || "mqtt://10.10.10.21:1883"; +const count = Number(process.argv[3] || 5000); +const size = Number(process.argv[4] || 128); +const payload = Buffer.alloc(size, 9); +const topic = `bench/mqtt/${process.pid}`; + +const c = mqtt.connect(url, { reconnectPeriod: 0, connectTimeout: 5000 }); +await new Promise((res, rej) => { + c.on("connect", res); + c.on("error", rej); +}); +const t0 = process.hrtime.bigint(); +for (let i = 0; i < count; i++) { + await new Promise((res, rej) => c.publish(topic, payload, { qos: 0 }, (err) => (err ? rej(err) : res()))); +} +const ns = Number(process.hrtime.bigint() - t0); +c.end(true); +const sec = ns / 1e9; +console.log( + JSON.stringify({ + mode: "mqtt-qos0", + count, + size, + url, + secs: Number(sec.toFixed(3)), + pubs_per_sec: Math.round(count / sec), + mb_per_sec: Number(((count * size) / sec / 1e6).toFixed(2)), + }), +); diff --git a/scripts/ns1-study-methodology.md b/scripts/ns1-study-methodology.md new file mode 100644 index 0000000..2d3423d --- /dev/null +++ b/scripts/ns1-study-methodology.md @@ -0,0 +1,202 @@ +## 4. Study methodology + +### 4.1 Question + +On the NS1 test stand, what message **throughput** and **delay** does the three-node `verae` JetStream cluster deliver at several loads, and which part of the stack is the limiter for product traffic (jobs, events, webhooks, archive)? + +### 4.2 Hypotheses (stated before the run) + +1. **H1 — Core vs JetStream.** Fire-and-forget core NATS is at least an order of magnitude faster than JetStream **file + replicas=3**, because durable publish waits for a majority disk replica. +2. **H2 — JetStream parallelism.** Adding publishers does **not** linearly increase JetStream write rate once the replica log is saturated. +3. **H3 — Quiet delay.** Sequential pub→sub round trip on `vmbr1` is well under 1 ms p99 when the consumer is waiting. +4. **H4 — Burst delay.** If publishers dump a batch before the subscriber drains, observed delay is **queueing time**, roughly linear in backlog, not in cluster hop count. +5. **H5 — Payload.** Moving 128 B → 1 KiB lowers message rate and raises byte rate on core NATS; JetStream in this size band stays replica/fsync bound. + +### 4.3 Independent variables (what we changed) + +| Factor | Levels | +|--------|--------| +| Transport | Core NATS pub/sub vs JetStream file replicas=3 | +| Publisher count | 1, 2, 4, 8 | +| Subscriber count | 0 (JS publish-only), 1, 2, 4, 8 | +| Message count | 1k, 5k, 10k, 20k, 50k, 100k, 200k (by ladder step) | +| Payload | 128 B, 1024 B | +| Delay mode | **ping** (publish, wait, repeat) vs **flood** (publish all, then drain) | + +### 4.4 Dependent variables (what we recorded) + +| Metric | Instrument | Unit | +|--------|------------|------| +| Publish rate | `nats bench` 0.1.6 Pub stats | msgs/s, MB/s | +| Subscribe rate | `nats bench` Sub stats | msgs/s, MB/s | +| Aggregate | `nats bench` NATS Pub/Sub stats | msgs/s (fan-out counts both sides) | +| Publisher spread | nats min/avg/max **msgs/s** | not delay | +| One-way-ish RTT | `latency.mjs` header timestamp | min, avg, p50, p90, p99, max | +| Host load | `/proc/loadavg` before and after | load average | +| Broker counters | `http://127.0.0.1:8222/varz` inside each nats LXC | connections, in/out msgs, cpu, mem | + +**Important:** nats CLI 0.1.6 min/avg/max are **rate spread across publishers**, not microseconds of delay. Delay is only `latency.mjs`. + +### 4.5 Controls and constants + +- Cluster name `verae`, three routes, client `:4222`, cluster `:6222`, monitor loopback `:8222`. +- Client URL always the three-node list on `vmbr1` (never host `127.0.0.1:4222`, never `vmbr0`). +- Bench client is LXC **510**, not a nats-* server. +- JetStream bench stream name `benchstream`, **file** storage, **replicas=3**, deleted between JS loads (`nats stream rm --force`) so names do not collide. +- Product streams were **not** the bench target (no load test on `ZAPIER_*` / `VERAE_ARCHIVE`). +- No TLS, no nkeys, no account isolation (isolation is `vmbr1`). +- Same nats CLI version (0.1.6) and `nats@2` Node client as the first ladder. + +### 4.6 Procedure + +1. Confirm this script is executing on **NS1.GEORGELAMBERT.ORG**. Refuse otherwise. +2. Snapshot host load, memory, LXC configs, and each nats `varz`. +3. From NS1, `pct exec 510` the core ladder (1p1s, 4p4s, 8p8s at 128 B; 4p4s at 1 KiB). +4. Delete `benchstream`; JS ladder (1p, 4p, 4p×1 KiB, 2p2s pull) at replicas=3 file. +5. Copy `latency.mjs` into 510; ping then flood at several batch sizes. +6. Snapshot host/`varz` again. +7. Parse logs on **this host**; draw charts; write HTML and PDF on **this host**. + +No publish, subscribe, chart, or PDF process runs on the operator laptop for this study. + +### 4.7 Instrumentation path + +```text +[NS1 host 70.88.205.138] + study-on-ns1.sh (bash + python3) + | + | sudo pct exec 510 + v +[LXC 510 verae-px-worker 10.10.10.20] + nats bench / node latency.mjs + | + | NATS client protocol to + v +[LXC 511/512/513 10.10.10.21-23 :4222] + nats-server -js cluster routes :6222 +``` + +The hypervisor issues the guest commands. The messages themselves never leave `vmbr1`. + +### 4.8 Threats to validity + +| Threat | Effect on numbers | +|--------|-------------------| +| **One physical host** | Three “replicas” share CPU, memory, and usually the same datastore. This measures process/LXC HA, not disk HA. | +| **Shared load** | NS1 also runs Caddy, Forgejo, keep, fleet, portal, and other CTs. Load average during a run is part of the result, not noise to ignore. | +| **Single bench client** | All publishers live in 510. Per-publisher rate spread is contention in that guest. | +| **Short runs** | Seconds of traffic. No compaction, no multi-hour page-cache eviction, no snapshot during load. | +| **No TLS/nkeys** | Production auth will cost CPU. Do not treat these rates as post-nkeys rates. | +| **Fan-out aggregate** | Core aggregate msgs/s counts pub+sub. Do not compare that column to JetStream unique writes. | +| **Flood ≠ RTT** | Mixing flood averages with ping p99 produces a fake “NATS is slow” story. | +| **Lab only** | Not a Zapier HTTPS bench and not live `api.veraetime.net`. | + +### 4.9 Ethics / safety + +Bench uses throwaway subjects (`bench.core.*`, `bench.js.*`, `bench.lat.*`) and a throwaway stream. It does not purge product streams. Zapier cloud has no NATS socket. + +--- + +## 5. Suggestions for fine-tuning + +These follow from the method and from the first ladder on this stand (JetStream ~16k durable 128 B pubs/s; ping ~0.3 ms; flood hundreds of ms). Apply in order of leverage. Re-run **this NS1 study** after each change so the delta is measured the same way. + +### 5.1 Treat JetStream as the product limiter + +Product jobs/events/webhooks/archive are durable. Tuning core NATS to 2M msgs/s will not move a timestamp Zap. Put effort into **replica write path** and **consumer lag**, not core fan-out. + +### 5.2 Split storage class by stream + +| Stream | Suggested store | Why | +|--------|-----------------|-----| +| `ZAPIER_JOBS` | file, r=3 | Work queue; lose-a-job is bad | +| `ZAPIER_EVENTS` | file r=3, or memory r=3 if events are rebuildable from job status | Hot waiters; measure both | +| `ZAPIER_WEBHOOKS` | file, r=3, workqueue | HTTPS to Zapier is the slow consumer | +| `ZAPIER_USAGE` | file, r=3, limits + max-age | Telemetry | +| `VERAE_ARCHIVE` | file, r=3, on the **best disk** | Puts are larger and must survive | + +Try `ZAPIER_EVENTS` as memory store in a maintenance window and re-run only the JS + ping/flood steps. If ping stays ~0.3 ms and durable events still ack at a higher rate, keep it; if a CT restart drops in-flight waiters, revert. + +### 5.3 Give JetStream real disks + +Today r=3 on three LXC guests on **one Proxmox host** is three files, one failure domain. + +- Bind-mount a distinct SSD/NVMe (or ZFS dataset with its own vdev) into each nats LXC `store_dir`. +- Set `sync: always` only on archive if you need it; default sync is often enough for jobs and is faster. Measure. +- Do not put JetStream `store_dir` on the same busy rootfs as Forgejo/Caddy if we can avoid it. +- When moving to three metal boxes: same configs, private NIC, one disk (or mirror) **per node**. That is the first change that makes r=3 mean “two boxes can die.” + +### 5.4 Isolate the nats CTs from the rest of NS1 + +Host load on this box is often already several. Pin: + +- `nats-a/b/c`: dedicated cores, no steal from keep/fleet Node processes. +- Memory high enough that file-backed streams stay cache-hot for the working set. +- `cpuunits` / cpuset in `pct config` so a Zapier-facing Node GC pause does not stall fsync. + +Re-run this study after pinning; H1/H2 should move more than ping. + +### 5.5 Consumer and mailbox tuning (delay H4) + +Flood delay is backlog / consume_rate. Fine-tune the **waiters**, not the broker RTT. + +- `jobs.events` and `webhooks.deliver`: raise `max_ack_pending` so a slow HTTPS hook does not stall the whole consumer; cap it so a poison message cannot unbounded-buffer RAM. +- Pull consumers: larger batch, shorter `expires`, more pullers horizontally (fleet replica floors) instead of one fat subscriber. +- Middleware should **not** flood-publish then wait; it already does per-job publish. Keep that. The flood test is the outage profile when a consumer is stopped. +- Alert on **consumer lag** (pending + ack pending) from JetStream, not on ping RTT. + +### 5.6 Publisher-side batching in middleware + +A timestamp job is one small JSON. 16k msgs/s is ample. Still: + +- Avoid per-byte publishes; one message per job/event. +- Reuse NATS connections (connection churn showed up as publisher spread in the core 4p/8p runs). +- Idempotent `msg id` / duplicate window sized to Verae retry window, not default-only. + +### 5.7 nats-server knobs worth measuring (A/B with this script) + +| Knob | Why try it | +|------|------------| +| `max_payload` | Keep default unless archive puts grow | +| `write_deadline` | Slow consumer protection for webhooks | +| `max_pending` | Bound memory on a stuck Zapier hook | +| `max_connections` | Fleet workers + keep + middleware | +| JetStream `max_file_store` / `max_memory_store` | Prevent one stream from filling the CT | +| `max_outstanding_catchup` | Replica restart after a nats-c blip | +| GOMAXPROCS = LXC cores | Do not overthread a 2-core CT | + +Change **one** knob, re-run `study-on-ns1.sh`, compare JetStream 1p 128 B and ping p99. + +### 5.8 Network + +- Keep NATS off `vmbr0`. No change. +- When on metal: dedicated NIC or VLAN for cluster `:6222` vs client `:4222` if possible (replication vs client load). +- Check virtio queue counts on the LXC nics if core 1 KiB byte rate plateaus. + +### 5.9 Security cost (when nkeys/mTLS flip) + +`verae-nats-accounts` is still a sketch. Enabling accounts will add CPU on publish. Budget: re-run this exact study **after** creds are in every `NATS_URL`, and accept a drop on both core and JS. Do not flip without that measurement. + +### 5.10 Operational fine-tuning (lag, not peak msgs/s) + +1. Scrape `varz` / `jsz` from the host over `vmbr1` (not public). Monitor loopback `:8222` is invisible to Prometheus on NS1 unless we add a host-side proxy on `10.10.10.21:8222` bound only to `vmbr1`. +2. Keep replica floors for webhook-deliver and job-poller — they are the flood defense. +3. Backup/restore drill of JetStream **during idle**, then a short JS 1p run to see catchup cost. +4. A 15–30 minute soak (not in this ladder) for page cache and compaction; add that as a third study when disks are dedicated. + +### 5.11 What not to tune + +- Do not chase core 8p8s aggregate. It is fan-out on a lab bridge. +- Do not treat flood 400 ms as “cluster RTT.” Fix consumers. +- Do not load-test on `ZAPIER_*` streams. +- Do not bind client NATS to `0.0.0.0` on `vmbr0`. + +### 5.12 Recommended next experiments (same method, one change each) + +1. CPU pin nats-a/b/c → re-run JS 1p + ping. +2. `ZAPIER_EVENTS`-shaped memory stream vs file (throwaway stream, same flags as this JS ladder). +3. Distinct `store_dir` disks per node. +4. nkeys on, same ladder. +5. Three hardware boxes, same `cluster.env` IPs updated. + +Each experiment should produce a new `results//` on NS1 and a new progress-repo report so we can diff H1–H5 instead of arguing from memory. diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 0000000..1306656 --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck disable=SC1091 +. "$ROOT/cluster.env" +export PATH="/usr/sbin:/usr/bin:/bin:$PATH" +printf '%s\n' "$NODES" | awk 'NF==3 {print}' | while read -r vmid name ip; do + st="$(sudo pct status "$vmid" 2>/dev/null || echo missing)" + js="$(sudo pct exec "$vmid" -- curl -fsS --max-time 2 http://127.0.0.1:8222/varz 2>/dev/null || echo '{}')" + echo "$vmid $name $ip $st" + python3 -c " +import json,sys +try: + d=json.loads(sys.argv[1]) +except Exception: + print(' nats down') + raise SystemExit +print(' server_name', d.get('server_name'), 'cluster', (d.get('cluster') or {}).get('name'), 'routes', len((d.get('cluster') or {}).get('urls') or d.get('connect_urls') or [])) +print(' jetstream', bool(d.get('jetstream')), 'port', d.get('port'), 'host', d.get('host')) +" "$js" 2>/dev/null || echo " nats down" +done +echo "host loopback still: $(ss -lnt | grep '127.0.0.1:4222' && echo up || echo down)" diff --git a/scripts/study-on-ns1.sh b/scripts/study-on-ns1.sh new file mode 100755 index 0000000..e4e424f --- /dev/null +++ b/scripts/study-on-ns1.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Full message-speed study. Must run ON NS1.GEORGELAMBERT.ORG (70.88.205.138). +# Orchestration, nats bench (via pct into LXC 510), charts, HTML, and PDF all +# happen on this host. The laptop is not in the measurement path. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export PATH="/usr/sbin:/usr/bin:/bin:/usr/local/bin:$PATH" + +HOST="$(hostname -f 2>/dev/null || hostname)" +case "$HOST" in + NS1.GEORGELAMBERT.ORG|NS1|ns1.georgelambert.org|ns1) ;; + *) + echo "refusing: study-on-ns1.sh must run on NS1.GEORGELAMBERT.ORG (70.88.205.138), got '$HOST'" >&2 + exit 1 + ;; +esac + +# shellcheck disable=SC1091 +. "$ROOT/client.env" +CLIENT_VMID="${CLIENT_VMID:-510}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +OUT="${BENCH_OUT:-$ROOT/results/$STAMP}" +mkdir -p "$OUT" + +dump_env() { + local tag="$1" + local f="$OUT/host-$tag.txt" + { + echo "execution_host=NS1.GEORGELAMBERT.ORG" + echo "execution_ip=70.88.205.138" + echo "hostname=$(hostname)" + echo "utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "whoami=$(whoami)" + echo "pwd=$(pwd)" + echo "uname=$(uname -a)" + echo "nproc=$(nproc)" + echo "loadavg=$(cat /proc/loadavg)" + echo "client_vmid=$CLIENT_VMID" + echo "nats_url=$NATS_URL" + echo "js_extra_memory=${JS_EXTRA_MEMORY:-0}" + echo "compare_dir=${COMPARE_DIR:-}" + echo "--- nats 511 max_mem ---" + sudo pct exec 511 -- grep max_mem /etc/nats/nats.conf || true + echo "--- nats 511 jetstream mount ---" + sudo pct exec 511 -- mount | grep jetstream || echo "jetstream on rootfs" + echo "--- free ---" + free -h + echo "--- pct list ---" + sudo pct list + for v in 510 511 512 513; do + echo "--- pct config $v ---" + sudo pct config "$v" | grep -E '^(hostname|cores|memory|swap|rootfs|mp|net)' || true + done + } >"$f" + python3 - "$OUT" "$tag" <<'PY' +import json, sys, urllib.request +from pathlib import Path +out, tag = Path(sys.argv[1]), sys.argv[2] +nodes = [] +for vmid, name in (("511", "nats-a"), ("512", "nats-b"), ("513", "nats-c")): + raw = "" + try: + import subprocess + raw = subprocess.check_output( + ["sudo", "pct", "exec", vmid, "--", "curl", "-fsS", "--max-time", "3", "http://127.0.0.1:8222/varz"], + text=True, + ) + d = json.loads(raw) + nodes.append({ + "vmid": vmid, + "name": name, + "server_name": d.get("server_name"), + "host": d.get("host"), + "port": d.get("port"), + "connections": d.get("connections"), + "in_msgs": d.get("in_msgs"), + "out_msgs": d.get("out_msgs"), + "in_bytes": d.get("in_bytes"), + "out_bytes": d.get("out_bytes"), + "cpu": d.get("cpu"), + "cores": d.get("cores"), + "mem": d.get("mem"), + "jetstream": bool(d.get("jetstream")), + }) + except Exception as e: + nodes.append({"vmid": vmid, "name": name, "error": str(e)}) +(out / f"varz-{tag}.json").write_text(json.dumps(nodes, indent=2) + "\n", encoding="utf-8") +PY +} + +echo "NS1 study $STAMP out=$OUT" +dump_env before +BENCH_OUT="$OUT" CLIENT_VMID="$CLIENT_VMID" bash "$ROOT/scripts/bench.sh" +dump_env after +python3 "$ROOT/scripts/build-ns1-study-report.py" "$OUT" "${COMPARE_DIR:-}" +echo "NS1 study complete $OUT" +ls -la "$OUT"/nats-cluster-bench-ns1.* "$OUT"/charts 2>/dev/null || ls -la "$OUT" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..feb50b8 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Local syntax check always. Live cluster check when pct is present. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +bash -n "$ROOT/scripts/lib-ct.sh" +bash -n "$ROOT/scripts/create-cluster.sh" +bash -n "$ROOT/scripts/status.sh" +bash -n "$ROOT/scripts/bench.sh" +bash -n "$ROOT/scripts/study-on-ns1.sh" +bash -n "$ROOT/scripts/maximize-ns1-study.sh" +bash -n "$ROOT/scripts/exhaustive-ns1-study.sh" +grep -q 'host: {{IP}}' "$ROOT/conf/nats.conf.tmpl" +grep -qv '0.0.0.0' "$ROOT/conf/nats.conf.tmpl" +if [[ ! -d /etc/pve/nodes ]]; then + echo "OK (syntax; not on Proxmox)" + exit 0 +fi +# shellcheck disable=SC1091 +. "$ROOT/cluster.env" +export PATH="/usr/sbin:/usr/bin:/bin:$PATH" +mapfile -t rows < <(printf '%s\n' "$NODES" | awk 'NF==3 {print}') +ready=0 +for row in "${rows[@]}"; do + # shellcheck disable=SC2086 + set -- $row + vmid=$1 name=$2 ip=$3 + js="$(sudo pct exec "$vmid" -- curl -fsS --max-time 3 http://127.0.0.1:8222/varz 2>/dev/null || true)" + echo "$js" | grep -q '"jetstream"' && ready=$((ready + 1)) || echo "not ready $name" +done +[[ $ready -eq 3 ]] || { echo "cluster not fully up ($ready/3)" >&2; exit 1; } + +# nats CLI on first node +first="$(echo "${rows[0]}" | awk '{print $1}')" +sudo pct exec "$first" -- bash -lc ' +set -e +export DEBIAN_FRONTEND=noninteractive +if [[ ! -x /usr/local/bin/nats ]]; then + apt-get install -y --no-install-recommends unzip >/dev/null + curl -fsSL https://github.com/nats-io/natscli/releases/download/v0.1.6/nats-0.1.6-linux-amd64.zip -o /tmp/natscli.zip + rm -rf /tmp/natscli && mkdir -p /tmp/natscli + unzip -o /tmp/natscli.zip -d /tmp/natscli >/dev/null + BIN=$(find /tmp/natscli /tmp -maxdepth 3 -type f -name nats | head -1) + test -n "$BIN" + install -m 0755 "$BIN" /usr/local/bin/nats +fi +IP=$(hostname -I | awk "{print \$1}") +export NATS_URL=nats://$IP:4222 +export PATH=/usr/local/bin:/usr/bin:/bin +nats stream rm VERAE_PX_TEST --force >/dev/null 2>&1 || true +nats stream add VERAE_PX_TEST --subjects="verae.px.test" --replicas=3 --storage=file --retention=limits --discard=old --max-msgs=-1 --max-bytes=-1 --max-age=1h --dupe-window=2m --defaults +nats pub verae.px.test cluster-ok +nats stream info VERAE_PX_TEST +' +echo "OK live cluster (3/3 + replicas=3 stream)" diff --git a/scripts/udp-probe.mjs b/scripts/udp-probe.mjs new file mode 100755 index 0000000..6760964 --- /dev/null +++ b/scripts/udp-probe.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** UDP echo RTT. server: node udp-probe.mjs server [port] + * client: node udp-probe.mjs client [port] [size] */ +import dgram from "node:dgram"; + +const mode = process.argv[2] || "server"; +const port = Number(process.argv[mode === "server" ? 3 : 5] || 9999); + +if (mode === "server") { + const s = dgram.createSocket("udp4"); + s.on("message", (msg, rinfo) => s.send(msg, rinfo.port, rinfo.address)); + s.bind(port, "0.0.0.0", () => console.log(JSON.stringify({ mode: "udp-server", port }))); +} else { + const host = process.argv[3]; + const count = Number(process.argv[4] || 1000); + const size = Number(process.argv[6] || 128); + const sock = dgram.createSocket("udp4"); + const payload = Buffer.alloc(size, 7); + const samples = []; + let i = 0; + const sendOne = () => { + const t0 = process.hrtime.bigint(); + const once = (msg) => { + sock.off("message", once); + samples.push(Number(process.hrtime.bigint() - t0) / 1000); + i += 1; + if (i >= count) { + samples.sort((a, b) => a - b); + const us = (n) => `${(n / 1000).toFixed(3)}ms`; + const pct = (p) => samples[Math.min(samples.length - 1, Math.floor((p / 100) * samples.length))]; + const sum = samples.reduce((a, b) => a + b, 0); + console.log( + JSON.stringify({ + mode: "udp-ping", + count: samples.length, + size, + host, + min: us(samples[0]), + avg: us(sum / samples.length), + p50: us(pct(50)), + p99: us(pct(99)), + max: us(samples[samples.length - 1]), + p50_us: Math.round(pct(50)), + p99_us: Math.round(pct(99)), + }), + ); + sock.close(); + } else sendOne(); + }; + sock.on("message", once); + sock.send(payload, port, host); + }; + sendOne(); +} diff --git a/systemd/nats-server.service b/systemd/nats-server.service new file mode 100644 index 0000000..86e5076 --- /dev/null +++ b/systemd/nats-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=NATS JetStream (Verae cluster node) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=nats +Group=nats +ExecStart=/usr/local/bin/nats-server -c /etc/nats/nats.conf +LimitNOFILE=65536 +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target