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:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user