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