diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..442282d --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,48 @@ +# loadtest/ + +Harnesses for validating the telnet-pool fix (PERFORMANCE-FINDINGS #1). + +## bench-pool.js — standalone model (no stack) + +Pure-Node simulation comparing a single serialized connection vs a pool of N. +No Prosody, no Postgres, no deps. Confirms the theoretical concurrency gain. + +```sh +node loadtest/bench-pool.js [totalCommands] [cmdLatencyMs] [poolSize] +# defaults: 200 commands, 50 ms latency, pool size 4 +``` + +Expected: pool of 4 ≈ 4x, pool of 8 ≈ 8x throughput vs the single connection. + +## run.js — autocannon against a live instance + +HTTP load test of the read path (`GET /api/groupchats`, `GET /api/healthcheck`). +Reports req/s and p50/p90/p99 latency. Mutating endpoints are excluded. + +```sh +yarn add --dev autocannon # one time +node loadtest/run.js [url] [durationSec] [connections] +# defaults: http://127.0.0.1:9511, 10 s, 50 connections +``` + +## docker-compose.loadtest.yml — local read-path stack + +Postgres (seeded via `init.sql`) + the app image. No Prosody, so only read +endpoints are meaningful; the telnet pool fails to connect on startup (logged, +non-fatal) and read paths don't use it. + +```sh +docker compose -f loadtest/docker-compose.loadtest.yml up --build +# in another terminal: +node loadtest/run.js http://127.0.0.1:9511 10 50 +# tear down: +docker compose -f loadtest/docker-compose.loadtest.yml down -v +``` + +## What to verify on staging (no local Prosody/Postgres) + +- Concurrent `POST /affiliations/:target` requests do not cross telnet + responses (the bug fixed by the pool). +- `POST /creategroup` and `PUT /groupchats/:target` rename flows. +- `GET /healthcheck` and `GET /health`. +- Tune `config.telnet.poolSize` (env `telnetPoolSize`) against real Prosody load. diff --git a/loadtest/bench-pool.js b/loadtest/bench-pool.js new file mode 100644 index 0000000..11ff022 --- /dev/null +++ b/loadtest/bench-pool.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/* + * bench-pool.js — standalone throughput model for the telnet pool. + * + * No external dependencies, no Prosody/Postgres. Simulates a Prosody console + * command that takes `cmdLatency` ms and compares: + * - a single shared connection (serialized process-wide) [pre-fix model] + * - a pool of N connections with a wait-queue [post-fix model] + * + * Usage: + * node loadtest/bench-pool.js [totalCommands] [cmdLatencyMs] [poolSize] + * + * Defaults: 200 commands, 50 ms latency, pool size 4. + */ +'use strict'; + +var totalCommands = parseInt(process.argv[2], 10) || 200; +var cmdLatency = parseInt(process.argv[3], 10) || 50; +var poolSize = parseInt(process.argv[4], 10) || 4; + +function sleep(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms); }); +} + +// Single shared connection: every command serializes on one socket. +function runSingle(commands, latency) { + var start = Date.now(); + var i = 0; + function next() { + if (i >= commands) return Promise.resolve(); + i++; + return sleep(latency).then(next); + } + return next().then(function () { return Date.now() - start; }); +} + +// Pool of `size` connections with a simple wait-queue (mirrors app/telnet-pool.js). +function runPool(commands, latency, size) { + var start = Date.now(); + var queue = []; + var inflight = 0; + var done = 0; + + function dispatch() { + while (inflight < size && (done + inflight) < commands) { + inflight++; + sleep(latency).then(function () { + inflight--; + done++; + if (done >= commands) { + return; + } + dispatch(); + while (inflight < size && queue.length > 0) { + var w = queue.shift(); + inflight++; + w(); + } + }); + } + } + + return new Promise(function (resolve) { + dispatch(); + var timer = setInterval(function () { + if (done >= commands) { + clearInterval(timer); + resolve(Date.now() - start); + } + }, 5); + }); +} + +function fmt(ms, commands) { + var sec = ms / 1000; + var rps = commands / sec; + return ms + ' ms (' + rps.toFixed(1) + ' req/s)'; +} + +(async function main() { + console.log('bench-pool: ' + totalCommands + ' commands, ' + cmdLatency + ' ms latency each, pool size ' + poolSize); + console.log('-----------------------------------------------------------'); + + var singleMs = await runSingle(totalCommands, cmdLatency); + console.log('single connection (serialized): ' + fmt(singleMs, totalCommands)); + + var poolMs = await runPool(totalCommands, cmdLatency, poolSize); + console.log('pool of ' + poolSize + ' (concurrent): ' + fmt(poolMs, totalCommands)); + + var speedup = singleMs / poolMs; + console.log('-----------------------------------------------------------'); + console.log('speedup: ' + speedup.toFixed(2) + 'x (theoretical max ' + poolSize + 'x)'); +})().catch(function (e) { + console.error(e); + process.exit(1); +}); diff --git a/loadtest/docker-compose.loadtest.yml b/loadtest/docker-compose.loadtest.yml new file mode 100644 index 0000000..d6e8542 --- /dev/null +++ b/loadtest/docker-compose.loadtest.yml @@ -0,0 +1,55 @@ +# docker-compose.loadtest.yml — local read-path stack for prosody-muc-rest. +# +# Brings up Postgres (seeded) + the app built from ./Dockerfile. No Prosody, +# so only the read endpoints (GET /groupchats, GET /groupchats/:target, +# GET /health, GET /occupants/:target) are meaningful. The telnet pool will +# fail to connect on startup (logged, non-fatal) — read paths don't use it. +# +# Usage: +# docker compose -f loadtest/docker-compose.loadtest.yml up --build +# # in another terminal: +# node loadtest/run.js http://127.0.0.1:9511 10 50 +# docker compose -f loadtest/docker-compose.loadtest.yml down -v + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: prosody + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + volumes: + - ./loadtest/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d prosody"] + interval: 3s + timeout: 3s + retries: 20 + + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: development + servicePort: "9511" + dbHost: postgres + dbPort: "5432" + dbName: prosody + dbUser: postgres + dbPass: postgres + disableDBtls: "true" + mucDomain: conference.demo.vnc.de + telnetHost: "127.0.0.1" + telneetPort: "5582" + telnetTimeout: "5000" + telnetPoolSize: "4" + prosodyRESTUrl: "http://localhost/rest" + prosodyRESTsecret: dummy + ports: + - "9511:9511" diff --git a/loadtest/init.sql b/loadtest/init.sql new file mode 100644 index 0000000..63f24be --- /dev/null +++ b/loadtest/init.sql @@ -0,0 +1,26 @@ +-- Minimal seed for the read-path load test: creates the `prosody` and +-- `group_owners` tables with the columns read by GET /groupchats and +-- GET /groupchats/:target. No Prosody required. + +CREATE TABLE IF NOT EXISTS prosody ( + host text NOT NULL, + user text NOT NULL, + store text NOT NULL, + key text NOT NULL, + value text NOT NULL, + PRIMARY KEY (host, "user", store, key) +); + +CREATE TABLE IF NOT EXISTS group_owners ( + room text NOT NULL, + owner text, + created bigint NOT NULL DEFAULT (extract(epoch from now()) * 1000)::bigint, + updated bigint NOT NULL DEFAULT (extract(epoch from now()) * 1000)::bigint +); + +-- A few sample rows so the read path returns data. +INSERT INTO group_owners (room, owner) VALUES + ('bench-room-1@conference.demo.vnc.de', 'owner1@demo.vnc.de'), + ('bench-room-2@conference.demo.vnc.de', 'owner2@demo.vnc.de'), + ('bench-room-3@conference.demo.vnc.de', 'owner3@demo.vnc.de') +ON CONFLICT DO NOTHING; diff --git a/loadtest/run.js b/loadtest/run.js new file mode 100644 index 0000000..6c6cd6d --- /dev/null +++ b/loadtest/run.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* + * run.js — autocannon harness against a live prosody-muc-rest instance. + * + * Reports req/s + p50/p90/p99 latency for the read path (GET /groupchats and + * GET /healthcheck). Mutating endpoints are intentionally excluded to avoid + * hammering Prosody's telnet console under load. + * + * Requires autocannon (not a runtime dependency of the app): + * yarn add --dev autocannon + * # or: npx autocannon + * + * Usage: + * node loadtest/run.js [url] [durationSec] [connections] + * + * Defaults: http://127.0.0.1:9511, 10 s, 50 connections. + * Targets the /api/ prefix mounted by app/app.js. + */ +'use strict'; + +var url = process.argv[2] || 'http://127.0.0.1:9511'; +var duration = parseInt(process.argv[3], 10) || 10; +var connections = parseInt(process.argv[4], 10) || 50; + +var autocannon; +try { + autocannon = require('autocannon'); +} catch (e) { + console.error('autocannon is not installed. Install it with:'); + console.error(' yarn add --dev autocannon (or: npx autocannon)'); + process.exit(1); +} + +var base = url.replace(/\/+$/, ''); +var routes = ['/api/groupchats', '/api/healthcheck']; + +var instance = autocannon({ + url: base + routes[0], + connections: connections, + duration: duration, + headers: { 'accept': 'application/json' }, + requests: [ + { method: 'GET', path: routes[0] }, + { method: 'GET', path: routes[1] } + ] +}, function (err, result) { + if (err) { + console.error('autocannon error:', err); + process.exit(1); + } + console.log('\n=== ' + base + ' (conn=' + connections + ', dur=' + duration + 's) ==='); + console.log('requests: ' + result.requests.total + ' (' + result.requests.sent + ' sent)'); + console.log('req/s: ' + (result.requests.average).toFixed(1)); + console.log('latency p50: ' + result.latency.p50 + ' ms'); + console.log('latency p90: ' + result.latency.p90 + ' ms'); + console.log('latency p99: ' + result.latency.p99 + ' ms'); + console.log('errors: ' + result.errors); + console.log('timeouts: ' + result.timeouts); + if (result.non2xx > 0) { + console.log('non-2xx: ' + result.non2xx); + } +}); + +autocannon.track(instance, { renderProgressBar: true });