test: add loadtest harness for telnet pool verification

Phase 4 verification harness in loadtest/:
- bench-pool.js: standalone dep-free model showing pool of 4 = 4x and
  pool of 8 = 8x throughput vs a single serialized connection.
- run.js: autocannon harness for the read path (req/s + p50/p90/p99).
- docker-compose.loadtest.yml + init.sql: seeded Postgres + app read-path
  stack (no Prosody) for local load testing.
- README.md: usage and staging validation checklist.

Part-of: <http://gitlab.vnc.biz/uxf/prosody-muc-rest/-/merge_requests/3>
This commit is contained in:
2026-07-10 14:54:25 +02:00
parent 4adb497a64
commit 683e224d67
5 changed files with 289 additions and 0 deletions
+48
View File
@@ -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.
+96
View File
@@ -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);
});
+55
View File
@@ -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"
+26
View File
@@ -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;
+64
View File
@@ -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 });