Skip to main content

2026-10-12, 612ec15: first run, 1 and 3 nodes

The first full run of the load harness, full profile, on the native backbone. The application code is main at 92e21a1; 612ec15 only adds the harness. This is the pre-scale baseline that #34 asks to compare against after #35. The Kafka column comes with #66.

It ran on a developer machine: Windows 11 with Docker Desktop (WSL 2), 12 vCPUs and 31 GiB visible to Docker. Every node, Postgres, k6 and the mock provider shared these, with no per-node limits. In the 3-node topology the three nodes also share one host and one Postgres (3 × 20 pooled connections). That topology therefore measures clustering overhead and failover, not horizontal scale. These numbers are not comparable with the nightly CI runs.

Findings​

  • No acknowledged message was lost in any scenario on either topology. This held for 1 000 sockets under load, reconnect storms of all 1 000 sockets, graceful restarts of every node, and a SIGKILL. It also covers every webhook delivery (8 634 and 6 789 burst deliveries, 23 820 and 27 382 fan-out deliveries).
  • REST → WebSocket p95 misses the 250 ms target: 433 ms on 1 node at 94 msgs/s with 1 000 sockets. The REST POST itself takes as long (432 ms p95), so the write path is the bottleneck, not the WebSocket fan-out.
  • The DB pool saturates under bursts. At 500 inbound webhooks/s, one node with POOL_SIZE=20 accepted about 96/s. The rest got HTTP 500: Ecto dropped the queued connection checkouts after about 100 ms. Converger should answer 503 or 429 with Retry-After and flip readiness instead, which is the subject of #35. WebSocket handshakes also read the database: in an earlier attempt at 500 connects/s, only 2 579 of 10 000 sockets got in (the harness now connects at 100/s and clients back off).
  • Outbound delivery throughput is low: about 85 deliveries/s with a mock provider that answers in 20–30 ms and 20 delivery workers, where about 700/s would be expected. After the burst, the p50 delivery took 42 s.
  • Idle sockets cost about 130–150 KiB each (RSS growth for 10 000 sockets: 1.9 GB on one node). That is more than the "tens of KiB" in the capacity guidance.
  • Nodes come back ready 20–30 s after a restart or kill (42 s at most on 3 nodes). During a 3-node rolling restart, the slowest client needed 51 s to reconnect: its backoff kept landing on nodes that were restarting or busy.

Results​

Measured commit 612ec15a4a6d, 2026-10-12T00:46:37Z, profile full. Generated by load/run.sh (see Benchmarks).

ScenarioMetric1 node, native3 nodes, native
e2eREST→WS p50 (ms)80502
e2eREST→WS p95 (ms)4331149
e2eREST→WS p99 (ms)5482053
e2eREST POST p95 (ms)431.61147.7
e2esustained msgs/s (acked)93.791.3
e2eWS frames/s delivered937.2913.2
e2eWS connections10001000
webhook_burstinbound accepted/s95.975.4
webhook_burstinbound POST p95 (ms)3213.25098.9
webhook_burstinbound→provider p50 (ms)4245428474
webhook_burstinbound→provider p95 (ms)5631056937
webhook_burstinbound→provider p99 (ms)8923661747
webhook_burstdeliveries/s84.587.8
webhook_burstqueue drained after burst (s)54.331.6
idleidle sockets connected1000010000
idleconnect p95 (ms)265293
idlememory per idle socket (KiB)149127.5
reconnect_stormreconnect p95 (ms)18346883
reconnect_stormreconnect max (ms)19987408
reconnect_stormREST→WS p95 during storms (ms)10851783
rolling_restartnode back to ready, max (s)26.542
rolling_restartclient reconnect max (ms)1721650853
node_killkilled node back to ready (s)20.328.5
node_killclient reconnect max (ms)1471715843
e2elost acked messages00
webhook_burstlost acked messages00
reconnect_stormlost acked messages00
rolling_restartlost acked messages00
node_killlost acked messages00

Resources​

Highest values sampled while the scenario ran (CPU and RSS about every 2.5 s, the rest every second).

TopologyScenarioNodeCPU avg %CPU max %RSS max (MiB)DB pool wait max (ms)Oban queue depth max
1 node, nativee2eapp251.3435.26783.9112
1 node, nativee2edb139.4270.3199.4––
1 node, nativewebhook_burstapp263.5430.7500.4476.310235
1 node, nativewebhook_burstdb208422.8234.7––
1 node, nativeidleapp67.4282.71883.10.50
1 node, nativeidledb13.995.8236.4––
1 node, nativereconnect_stormapp322.7562.61559.6266222
1 node, nativereconnect_stormdb152266.1250.4––
1 node, nativerolling_restartapp279.1497.81540.178.8231
1 node, nativerolling_restartdb147.4250.5274.3––
1 node, nativenode_killapp211.7437.71305.6373.4198
1 node, nativenode_killdb103.6249.3257.9––
3 nodes, nativee2eapp119186.4397.5271.5131
3 nodes, nativee2eapp2116.2177.7434.4271.5131
3 nodes, nativee2eapp3119.9181.4483.7271.5131
3 nodes, nativee2edb139.6227.9427.7––
3 nodes, nativewebhook_burstapp114.5202.5374.91671.17938
3 nodes, nativewebhook_burstapp2125.1293.4467.51671.17938
3 nodes, nativewebhook_burstapp3132280.8385.61671.17938
3 nodes, nativewebhook_burstdb279.3574.3482––
3 nodes, nativeidleapp3292.7820.261.80
3 nodes, nativeidleapp23282.8763.561.80
3 nodes, nativeidleapp333.786.5814.761.80
3 nodes, nativeidledb22130.3488.4––
3 nodes, nativereconnect_stormapp103.1196.8599.3578.5282
3 nodes, nativereconnect_stormapp2101207.8622.1578.5282
3 nodes, nativereconnect_stormapp3112.7221.6751.5578.5282
3 nodes, nativereconnect_stormdb118.1208.4500.9––
3 nodes, nativerolling_restartapp120.8450.7640.91130.31124
3 nodes, nativerolling_restartapp2140.5516.27901130.31124
3 nodes, nativerolling_restartapp3126.3501.6747.11130.31124
3 nodes, nativerolling_restartdb162.7621.8509.1––
3 nodes, nativenode_killapp79.6191.9767.3500.8528
3 nodes, nativenode_killapp2145.6281.7792.5500.8528
3 nodes, nativenode_killapp3144.3438.5691.1500.8528
3 nodes, nativenode_killdb121.3322.8505.8––

Setup​

  • 1 node, native: 12 vCPU and 30.8 GiB visible to Docker (MINGW64_NT-10.0-26200, local, kernel 6.6.87.2-microsoft-standard-WSL2, Docker 29.2.1); per-node limits CPU unlimited, memory unlimited; Postgres 17.10, 79.4 MiB after the run; pool size 20; mock provider latency 20+10 ms, error rate 0.
    • e2e: conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=60
    • webhook_burst: rate=500, duration_s=30, participants=200, targets=3
    • idle: sockets=10000, sockets_per_vu=200, connect_rate=100, ramp_s=100, hold_s=60
    • reconnect_storm: mode=storm, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=60, storm_at_s=15, storm_every_s=15
    • rolling_restart: mode=restart, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=70, storm_at_s=null, storm_every_s=null
    • node_kill: mode=restart, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=70, storm_at_s=null, storm_every_s=null
  • 3 nodes, native: 12 vCPU and 30.8 GiB visible to Docker (MINGW64_NT-10.0-26200, local, kernel 6.6.87.2-microsoft-standard-WSL2, Docker 29.2.1); per-node limits CPU unlimited, memory unlimited; Postgres 17.10, 83.6 MiB after the run; pool size 20; mock provider latency 20+10 ms, error rate 0.
    • e2e: conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=60
    • webhook_burst: rate=500, duration_s=30, participants=200, targets=3
    • idle: sockets=10000, sockets_per_vu=200, connect_rate=100, ramp_s=100, hold_s=60
    • reconnect_storm: mode=storm, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=60, storm_at_s=15, storm_every_s=15
    • rolling_restart: mode=restart, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=170, storm_at_s=null, storm_every_s=null
    • node_kill: mode=restart, conversations=100, subscribers_per_conversation=10, sockets=1000, rate_per_conversation=1, duration_s=70, storm_at_s=null, storm_every_s=null

Checks​

  • ✅ 1 node, native: e2e: k6 ran and correctness thresholds passed (k6 exit 99)
  • ⚠️ 1 node, native: e2e: performance targets (missed: e2e_latency_ms: p(95)<250)
  • ✅ 1 node, native: e2e: messages sent and received live (5623 acked, 56230 latency samples)
  • ✅ 1 node, native: webhook_burst: k6 ran and correctness thresholds passed (k6 exit 99)
  • ⚠️ 1 node, native: webhook_burst: performance targets (missed: inbound_rejected: count<1, dropped_iterations: count<1, http_req_duration{name:POST /inbound}: p(95)<500)
  • ✅ 1 node, native: webhook_burst: every accepted message stored and delivered to every target (accepted 2878, stored 2878, delivered 8634/8634)
  • ✅ 1 node, native: idle: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 1 node, native: reconnect_storm: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 1 node, native: reconnect_storm: messages sent and received live (5380 acked, 52711 latency samples)
  • ✅ 1 node, native: rolling_restart: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 1 node, native: rolling_restart: messages sent and received live (5987 acked, 55852 latency samples)
  • ✅ 1 node, native: node_kill: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 1 node, native: node_kill: messages sent and received live (5331 acked, 52016 latency samples)
  • ✅ 1 node, native: e2e: zero lost acked messages (lost 0, never acked 0)
  • ✅ 1 node, native: reconnect_storm: zero lost acked messages (lost 0, never acked 0)
  • ✅ 1 node, native: rolling_restart: zero lost acked messages (lost 0, never acked 0)
  • ✅ 1 node, native: node_kill: zero lost acked messages (lost 0, never acked 0)
  • ✅ 1 node, native: fan-out channel: every activity delivered to the outbound webhook (23820/23820 delivered, 0 jobs still open)
  • ✅ 3 nodes, native: e2e: k6 ran and correctness thresholds passed (k6 exit 99)
  • ⚠️ 3 nodes, native: e2e: performance targets (missed: http_req_duration{name:POST /activities}: p(95)<1000, e2e_latency_ms: p(95)<250)
  • ✅ 3 nodes, native: e2e: messages sent and received live (5479 acked, 54790 latency samples)
  • ✅ 3 nodes, native: webhook_burst: k6 ran and correctness thresholds passed (k6 exit 99)
  • ⚠️ 3 nodes, native: webhook_burst: performance targets (missed: inbound_rejected: count<1, dropped_iterations: count<1, http_req_duration{name:POST /inbound}: p(95)<500)
  • ✅ 3 nodes, native: webhook_burst: every accepted message stored and delivered to every target (accepted 2263, stored 2263, delivered 6789/6789)
  • ✅ 3 nodes, native: idle: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 3 nodes, native: reconnect_storm: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 3 nodes, native: reconnect_storm: messages sent and received live (5002 acked, 46969 latency samples)
  • ✅ 3 nodes, native: rolling_restart: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 3 nodes, native: rolling_restart: messages sent and received live (10292 acked, 94478 latency samples)
  • ✅ 3 nodes, native: node_kill: k6 ran and correctness thresholds passed (k6 exit 0)
  • ✅ 3 nodes, native: node_kill: messages sent and received live (5109 acked, 50328 latency samples)
  • ✅ 3 nodes, native: e2e: zero lost acked messages (lost 0, never acked 0)
  • ✅ 3 nodes, native: reconnect_storm: zero lost acked messages (lost 0, never acked 0)
  • ✅ 3 nodes, native: rolling_restart: zero lost acked messages (lost 0, never acked 0)
  • ✅ 3 nodes, native: node_kill: zero lost acked messages (lost 0, never acked 0)
  • ✅ 3 nodes, native: fan-out channel: every activity delivered to the outbound webhook (27382/27382 delivered, 0 jobs still open)