Seed z repozitare, zatezove testy, worker bez spanku, dialogy bez rozmazani
Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu, akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje. Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety a zatezovy skript (npm run load) proti bezici instanci vcetne davky udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop posluchacu streamu zvednut na 2 000. Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s. Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c25e826766
commit
ea9387bea9
@@ -0,0 +1,741 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Zatezovy test portalu pres HTTP.
|
||||
*
|
||||
* Virtualni uzivatele se prihlasi a v smycce delaji to, co dela prohlizec
|
||||
* po otevreni prehledu: prava, seznam ticketu, detail, souhrn, data widgetu
|
||||
* a lide. Kazdy desaty uzivatel drzi otevreny zivy stream (SSE). Na konci
|
||||
* se vypise tabulka s p50/p95/p99 za endpoint, propustnost a chybovost
|
||||
* a totez se zapise do `load-report.json` v pracovnim adresari (je v .gitignore).
|
||||
*
|
||||
* Obycejny Node bez zavislosti (`fetch`, `perf_hooks`), aby sel spustit
|
||||
* i bez `npm ci`. Cisla a jak je cist jsou v documentation/19-kapacita-200-firem.md.
|
||||
*
|
||||
* Promenne prostredi:
|
||||
* LOAD_URL adresa serveru, vychozi http://127.0.0.1:3000
|
||||
* LOAD_EMAIL, LOAD_PASSWORD ucet, vychozi demo spravce platformy
|
||||
* LOAD_USERS pocet virtualnich uzivatelu, vychozi 100
|
||||
* LOAD_DURATION_SEC delka behu, vychozi 30
|
||||
* LOAD_WEBHOOK_TOKEN kdyz je nastaven, posle se navic davka udalosti na
|
||||
* /webhook/<token> a ceka se, az fronta dobehne
|
||||
* LOAD_WEBHOOK_EVENTS velikost davky, vychozi 2000
|
||||
* LOAD_START_LOCAL=1 spusti `node dist/index.js` na volnem portu s docasnym
|
||||
* DATA_DIR (rezim souboru) a na konci ho ukonci
|
||||
*
|
||||
* Prihlaseni je jedno pro vsechny uzivatele: server pousti z jedne adresy
|
||||
* 20 prihlaseni za ctvrt hodiny a sto virtualnich uzivatelu by narazilo.
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// ------------------------------------------------------------------ nastaveni
|
||||
|
||||
const DEFAULT_URL = 'http://127.0.0.1:3000';
|
||||
const DEFAULT_EMAIL = 'admin@automia.cz';
|
||||
const DEFAULT_PASSWORD = 'demo1234';
|
||||
const DEFAULT_USERS = 100;
|
||||
const DEFAULT_DURATION_SEC = 30;
|
||||
const DEFAULT_WEBHOOK_EVENTS = 2_000;
|
||||
|
||||
/** Jeden zivy stream na deset uzivatelu, jako kdyz ma kazdy desaty otevreny prehled. */
|
||||
const USERS_PER_STREAM = 10;
|
||||
/** Stranka seznamu ticketu jako v portalu. */
|
||||
const TICKET_PAGE_LIMIT = 50;
|
||||
/**
|
||||
* Dlazdice, ktere si vychozi rozlozeni pocita na serveru. Ostatni dlazdice
|
||||
* vychoziho rozlozeni berou data ze souhrnu a seznamu, viz
|
||||
* src/data/dashboardLayouts.ts a COMPUTED_BUILTINS v web/src/pages/dashboard/Overview.tsx.
|
||||
*/
|
||||
const DEFAULT_LAYOUT_WIDGET_IDS = ['list.myTickets', 'list.unassigned'];
|
||||
/** Kolik udalosti webhooku leti naraz. Odesilatel (e-shop) taky neceka na odpoved. */
|
||||
const WEBHOOK_CONCURRENCY = 20;
|
||||
/** Jak casto se pta na stav fronty a jak dlouho se na vyprazdneni ceka nejvys. */
|
||||
const QUEUE_POLL_MS = 500;
|
||||
const QUEUE_DRAIN_TIMEOUT_MS = 180_000;
|
||||
/** Cekani na start lokalniho serveru. */
|
||||
const HEALTH_WAIT_MS = 30_000;
|
||||
const HEALTH_POLL_MS = 250;
|
||||
/** Jak casto se odecita pamet lokalniho serveru. */
|
||||
const MEMORY_SAMPLE_MS = 2_000;
|
||||
/** Kolik se ceka na ukonceni lokalniho serveru, nez se zabije natvrdo. */
|
||||
const STOP_TIMEOUT_MS = 10_000;
|
||||
const REPORT_FILE = 'load-report.json';
|
||||
/** System hlasi pamet v kB, tabulka ji ukazuje v MB. */
|
||||
const KB_PER_MB = 1024;
|
||||
const MS_PER_SECOND = 1_000;
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} fallback
|
||||
* @returns {number}
|
||||
*/
|
||||
function envNumber(name, fallback) {
|
||||
const parsed = Number(process.env[name]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
url: (process.env.LOAD_URL || DEFAULT_URL).replace(/\/+$/, ''),
|
||||
email: process.env.LOAD_EMAIL || DEFAULT_EMAIL,
|
||||
password: process.env.LOAD_PASSWORD || DEFAULT_PASSWORD,
|
||||
users: envNumber('LOAD_USERS', DEFAULT_USERS),
|
||||
durationSec: envNumber('LOAD_DURATION_SEC', DEFAULT_DURATION_SEC),
|
||||
webhookToken: (process.env.LOAD_WEBHOOK_TOKEN ?? '').trim(),
|
||||
webhookEvents: envNumber('LOAD_WEBHOOK_EVENTS', DEFAULT_WEBHOOK_EVENTS),
|
||||
startLocal: process.env.LOAD_START_LOCAL === '1',
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------- mereni
|
||||
|
||||
/** @typedef {{ count: number, errors: number, latencies: number[] }} EndpointStats */
|
||||
|
||||
/** @type {Map<string, EndpointStats>} */
|
||||
const endpoints = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} ms
|
||||
* @param {boolean} ok
|
||||
*/
|
||||
function record(name, ms, ok) {
|
||||
let stats = endpoints.get(name);
|
||||
if (!stats) {
|
||||
stats = { count: 0, errors: 0, latencies: [] };
|
||||
endpoints.set(name, stats);
|
||||
}
|
||||
stats.count += 1;
|
||||
if (ok) stats.latencies.push(ms);
|
||||
else stats.errors += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} sorted
|
||||
* @param {number} share 0 az 1
|
||||
* @returns {number}
|
||||
*/
|
||||
function percentile(sorted, share) {
|
||||
if (sorted.length === 0) return 0;
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * share) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EndpointStats} stats
|
||||
*/
|
||||
function summarise(stats) {
|
||||
const sorted = [...stats.latencies].sort((a, b) => a - b);
|
||||
return {
|
||||
count: stats.count,
|
||||
errors: stats.errors,
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
p99: percentile(sorted, 0.99),
|
||||
max: sorted[sorted.length - 1] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedno volani s merenim. Chyba site i odpoved mimo 2xx se pocita jako chyba
|
||||
* a nevyhazuje se - jeden spadly request nesmi zastavit virtualniho uzivatele.
|
||||
* @param {string} name popisek endpointu v tabulce
|
||||
* @param {string} url
|
||||
* @param {RequestInit} init
|
||||
* @returns {Promise<unknown>} telo jako JSON, nebo null pri chybe
|
||||
*/
|
||||
async function call(name, url, init) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
const body = await response.json().catch(() => null);
|
||||
record(name, performance.now() - start, response.ok);
|
||||
if (!response.ok) return null;
|
||||
return body;
|
||||
} catch (err) {
|
||||
record(name, performance.now() - start, false);
|
||||
noteError(name, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Map<string, number>} */
|
||||
const errorKinds = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {unknown} err
|
||||
*/
|
||||
function noteError(name, err) {
|
||||
const key = `${name}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
errorKinds.set(key, (errorKinds.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ virtualni uzivatel
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function authHeaders(token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} base
|
||||
* @param {string} token
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function login(base, token) {
|
||||
if (token) return token;
|
||||
const response = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: settings.email, password: settings.password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`prihlaseni selhalo: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
const body = /** @type {{ token: string }} */ (await response.json());
|
||||
return body.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smycka jednoho uzivatele: stejna posloupnost, jakou dela prehled v prohlizeci.
|
||||
* @param {string} base
|
||||
* @param {string} token
|
||||
* @param {number} deadline performance.now(), kdy skoncit
|
||||
*/
|
||||
async function virtualUser(base, token, deadline) {
|
||||
const headers = authHeaders(token);
|
||||
const jsonHeaders = { ...headers, 'Content-Type': 'application/json' };
|
||||
/** @type {string[]} */
|
||||
let ticketIds = [];
|
||||
|
||||
while (performance.now() < deadline) {
|
||||
await call('GET /api/dashboard/access', `${base}/api/dashboard/access`, { headers });
|
||||
|
||||
const list = /** @type {{ items?: Array<{ id: string }> } | null} */ (
|
||||
await call(
|
||||
`GET /api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`,
|
||||
`${base}/api/dashboard/tickets?limit=${TICKET_PAGE_LIMIT}`,
|
||||
{ headers },
|
||||
)
|
||||
);
|
||||
if (list?.items) ticketIds = list.items.map((item) => item.id);
|
||||
|
||||
const ticketId = ticketIds[Math.floor(Math.random() * ticketIds.length)];
|
||||
if (ticketId) {
|
||||
await call('GET /api/dashboard/tickets/:id', `${base}/api/dashboard/tickets/${ticketId}`, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
await call('GET /api/dashboard/summary', `${base}/api/dashboard/summary`, { headers });
|
||||
await call('POST /api/dashboard/widget-data', `${base}/api/dashboard/widget-data`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ widgetIds: DEFAULT_LAYOUT_WIDGET_IDS }),
|
||||
});
|
||||
await call('GET /api/dashboard/people', `${base}/api/dashboard/people`, { headers });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ zivy stream
|
||||
|
||||
/** @typedef {{ opened: number, events: number, errors: number }} StreamStats */
|
||||
|
||||
/**
|
||||
* Drzi jedno spojeni SSE az do zruseni a pocita prijate udalosti.
|
||||
* @param {string} base
|
||||
* @param {string} token
|
||||
* @param {AbortSignal} signal
|
||||
* @param {StreamStats} stats
|
||||
*/
|
||||
async function holdStream(base, token, signal, stats) {
|
||||
try {
|
||||
const response = await fetch(`${base}/api/dashboard/stream`, {
|
||||
headers: authHeaders(token),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
stats.errors += 1;
|
||||
return;
|
||||
}
|
||||
stats.opened += 1;
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
stats.events += text.split('\n').filter((line) => line.startsWith('event:')).length;
|
||||
}
|
||||
} catch (err) {
|
||||
// Zruseni na konci behu je ocekavane, cokoliv jineho je chyba spojeni.
|
||||
if (!signal.aborted) {
|
||||
stats.errors += 1;
|
||||
noteError('SSE /api/dashboard/stream', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- davka webhooku
|
||||
|
||||
/**
|
||||
* Stav fronty za vsechny firmy (ucet je spravce platformy, jinak jen za jeho firmy).
|
||||
* @param {string} base
|
||||
* @param {string} token
|
||||
* @returns {Promise<{ pending: number, running: number, failed: number, done: number }>}
|
||||
*/
|
||||
async function queueStats(base, token) {
|
||||
const response = await fetch(`${base}/api/dashboard/runs?scope=all&limit=1`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!response.ok) throw new Error(`stav fronty: ${response.status}`);
|
||||
const body =
|
||||
/** @type {{ stats: { pending: number, running: number, failed: number, done: number } }} */ (
|
||||
await response.json()
|
||||
);
|
||||
return body.stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telo udalosti podle kontraktu webhooku (`GET /webhook/<token>` vraci ukazku).
|
||||
* Textova pole, ktera vypadaji jako identifikator, dostanou unikatni hodnotu,
|
||||
* aby kazda udalost byla nova a nesla na jeden ticket.
|
||||
* @param {unknown} example
|
||||
* @param {Array<{ name: string, path: string, type: string }>} expects
|
||||
* @param {number} index
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
function eventBody(example, expects, index) {
|
||||
const body = /** @type {Record<string, unknown>} */ (
|
||||
JSON.parse(JSON.stringify(example && typeof example === 'object' ? example : {}))
|
||||
);
|
||||
for (const field of expects) {
|
||||
if (field.type !== 'string' || !/id$/i.test(field.name)) continue;
|
||||
const segments = field.path.split('.');
|
||||
const last = segments.pop();
|
||||
if (last === undefined) continue;
|
||||
let target = body;
|
||||
for (const key of segments) {
|
||||
const next = target[key];
|
||||
if (typeof next !== 'object' || next === null) {
|
||||
target[key] = {};
|
||||
}
|
||||
target = /** @type {Record<string, unknown>} */ (target[key]);
|
||||
}
|
||||
target[last] = `load-${index}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} base
|
||||
* @param {string} token webhooku
|
||||
* @param {string} auth token prihlaseni pro cteni fronty
|
||||
* @param {number} count
|
||||
*/
|
||||
async function webhookBurst(base, token, auth, count) {
|
||||
const contractResponse = await fetch(`${base}/webhook/${token}`);
|
||||
if (!contractResponse.ok) {
|
||||
throw new Error(`webhook ${token.slice(0, 6)}...: ${contractResponse.status}`);
|
||||
}
|
||||
const contract =
|
||||
/** @type {{ example: unknown, expects: Array<{ name: string, path: string, type: string }> }} */ (
|
||||
await contractResponse.json()
|
||||
);
|
||||
|
||||
const before = await queueStats(base, auth);
|
||||
const start = performance.now();
|
||||
let accepted = 0;
|
||||
let rejected = 0;
|
||||
let next = 0;
|
||||
|
||||
/** Jeden "odesilatel": posila dalsi a dalsi, dokud je co. */
|
||||
async function sender() {
|
||||
while (next < count) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const body = await call('POST /webhook/:token', `${base}/webhook/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(eventBody(contract.example, contract.expects, index)),
|
||||
});
|
||||
if (body) accepted += 1;
|
||||
else rejected += 1;
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: WEBHOOK_CONCURRENCY }, sender));
|
||||
const sentAt = performance.now();
|
||||
|
||||
// Cekani, az worker frontu vyprazdni.
|
||||
let stats = await queueStats(base, auth);
|
||||
while (stats.pending + stats.running > 0) {
|
||||
if (performance.now() - start > QUEUE_DRAIN_TIMEOUT_MS) {
|
||||
console.warn(`[load] fronta se nevyprazdnila do ${QUEUE_DRAIN_TIMEOUT_MS / MS_PER_SECOND} s`);
|
||||
break;
|
||||
}
|
||||
await sleep(QUEUE_POLL_MS);
|
||||
stats = await queueStats(base, auth);
|
||||
}
|
||||
const drainedAt = performance.now();
|
||||
|
||||
return {
|
||||
events: count,
|
||||
accepted,
|
||||
rejected,
|
||||
sendMs: Math.round(sentAt - start),
|
||||
drainMs: Math.round(drainedAt - start),
|
||||
runsPerSec: Math.round(accepted / ((drainedAt - start) / MS_PER_SECOND)),
|
||||
done: stats.done - before.done,
|
||||
failed: stats.failed - before.failed,
|
||||
pendingAtEnd: stats.pending + stats.running,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- lokalni server
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* true = na portu nikdo neposloucha.
|
||||
* @param {number} port
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function portFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ port, host: '127.0.0.1' });
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once('error', () => resolve(true));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} base
|
||||
*/
|
||||
async function waitForHealth(base) {
|
||||
const deadline = performance.now() + HEALTH_WAIT_MS;
|
||||
while (performance.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${base}/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Server jeste neposloucha, zkusi se znovu.
|
||||
}
|
||||
await sleep(HEALTH_POLL_MS);
|
||||
}
|
||||
throw new Error(`server na ${base} nenabehl do ${HEALTH_WAIT_MS / MS_PER_SECOND} s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pamet procesu v MB (RSS). Server ji sam nevystavuje, tak se cte ze systemu.
|
||||
* @param {number} pid
|
||||
* @returns {Promise<number | null>}
|
||||
*/
|
||||
async function residentMb(pid) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execFileAsync('tasklist', [
|
||||
'/FI',
|
||||
`PID eq ${pid}`,
|
||||
'/FO',
|
||||
'CSV',
|
||||
'/NH',
|
||||
]);
|
||||
const cells = stdout.trim().split(',');
|
||||
const last = cells[cells.length - 1] ?? '';
|
||||
const kb = Number(last.replace(/[^0-9]/g, ''));
|
||||
return Number.isFinite(kb) && kb > 0 ? Math.round(kb / KB_PER_MB) : null;
|
||||
}
|
||||
const status = await fs.readFile(`/proc/${pid}/status`, 'utf8');
|
||||
const match = /VmRSS:\s+(\d+)\s+kB/.exec(status);
|
||||
return match ? Math.round(Number(match[1]) / KB_PER_MB) : null;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[load] pamet serveru se nepodarila precist:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spusti sestaveny server na volnem portu s docasnym DATA_DIR (rezim souboru).
|
||||
* @param {string} webhookToken
|
||||
*/
|
||||
async function startLocal(webhookToken) {
|
||||
const port = await freePort();
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'csbot-load-'));
|
||||
const entry = path.join(root, 'dist', 'index.js');
|
||||
await fs.access(entry).catch(() => {
|
||||
throw new Error(`chybi ${entry}, spustte npm run build`);
|
||||
});
|
||||
|
||||
/** @type {string[]} */
|
||||
const stderr = [];
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATA_DIR: dataDir,
|
||||
DATABASE_URL: '',
|
||||
SEED_DEMO: '1',
|
||||
JWT_SECRET: 'load-test-jwt-secret',
|
||||
SECRETS_KEY: 'load-test-secrets-key',
|
||||
...(webhookToken ? { WEBHOOK_TOKEN_TEST: webhookToken } : {}),
|
||||
},
|
||||
// Log serveru se zahazuje: kazdy request pise radek a stovky radku za
|
||||
// sekundu by v terminalu prekryly vysledek. Chyby se drzi pro hlaseni.
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr.push(String(chunk));
|
||||
if (stderr.length > 50) stderr.shift();
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code !== null && code !== 0) {
|
||||
console.error(`[load] server skoncil s kodem ${code}\n${stderr.join('')}`);
|
||||
}
|
||||
});
|
||||
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await waitForHealth(base);
|
||||
} catch (err) {
|
||||
child.kill();
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
console.log(`[load] lokalni server bezi na ${base}, data v ${dataDir}`);
|
||||
|
||||
return {
|
||||
base,
|
||||
port,
|
||||
pid: child.pid ?? 0,
|
||||
async stop() {
|
||||
if (child.exitCode === null) {
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
const timer = sleep(STOP_TIMEOUT_MS).then(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
await Promise.race([exited, timer]);
|
||||
}
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
const free = await portFree(port);
|
||||
console.log(`[load] server ukoncen, port ${port} ${free ? 'je volny' : 'JE STALE OBSAZENY'}`);
|
||||
return free;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- vystup
|
||||
|
||||
/**
|
||||
* @param {Array<Array<string>>} rows
|
||||
*/
|
||||
function printTable(rows) {
|
||||
const widths = rows.reduce(
|
||||
(acc, row) => row.map((cell, index) => Math.max(acc[index] ?? 0, cell.length)),
|
||||
/** @type {number[]} */ ([]),
|
||||
);
|
||||
for (const row of rows) {
|
||||
console.log(row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
*/
|
||||
function ms(value) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ beh
|
||||
|
||||
async function main() {
|
||||
/** @type {Awaited<ReturnType<typeof startLocal>> | null} */
|
||||
let local = null;
|
||||
let portFreed = true;
|
||||
|
||||
try {
|
||||
if (settings.startLocal) local = await startLocal(settings.webhookToken);
|
||||
const base = local ? local.base : settings.url;
|
||||
|
||||
const token = await login(base, '');
|
||||
console.log(
|
||||
`[load] ${settings.users} uzivatelu, ${settings.durationSec} s, ${Math.ceil(settings.users / USERS_PER_STREAM)} streamu, cil ${base}`,
|
||||
);
|
||||
|
||||
/** @type {number[]} */
|
||||
const memorySamples = [];
|
||||
const memoryTimer = local
|
||||
? setInterval(() => {
|
||||
void residentMb(local.pid).then((value) => {
|
||||
if (value !== null) memorySamples.push(value);
|
||||
});
|
||||
}, MEMORY_SAMPLE_MS)
|
||||
: null;
|
||||
|
||||
const streamAbort = new AbortController();
|
||||
/** @type {StreamStats} */
|
||||
const streams = { opened: 0, events: 0, errors: 0 };
|
||||
const streamCount = Math.ceil(settings.users / USERS_PER_STREAM);
|
||||
const streamTasks = Array.from({ length: streamCount }, () =>
|
||||
holdStream(base, token, streamAbort.signal, streams),
|
||||
);
|
||||
|
||||
const startedAt = performance.now();
|
||||
const deadline = startedAt + settings.durationSec * MS_PER_SECOND;
|
||||
await Promise.all(
|
||||
Array.from({ length: settings.users }, () => virtualUser(base, token, deadline)),
|
||||
);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
let webhook = null;
|
||||
if (settings.webhookToken) {
|
||||
console.log(`[load] davka ${settings.webhookEvents} udalosti na webhook`);
|
||||
webhook = await webhookBurst(base, settings.webhookToken, token, settings.webhookEvents);
|
||||
}
|
||||
|
||||
streamAbort.abort();
|
||||
await Promise.all(streamTasks);
|
||||
if (memoryTimer) clearInterval(memoryTimer);
|
||||
if (local) memorySamples.push((await residentMb(local.pid)) ?? 0);
|
||||
|
||||
// ---------------------------------------------------------------- souhrn
|
||||
/** @type {Record<string, ReturnType<typeof summarise>>} */
|
||||
const perEndpoint = {};
|
||||
let requests = 0;
|
||||
let errors = 0;
|
||||
for (const [name, stats] of endpoints) {
|
||||
perEndpoint[name] = summarise(stats);
|
||||
// Davka webhooku se do propustnosti smycky uzivatelu nepocita, bezela az po ni.
|
||||
if (name.startsWith('POST /webhook')) continue;
|
||||
requests += stats.count;
|
||||
errors += stats.errors;
|
||||
}
|
||||
const seconds = elapsedMs / MS_PER_SECOND;
|
||||
const total = {
|
||||
requests,
|
||||
errors,
|
||||
errorRate: requests === 0 ? 0 : Math.round((errors / requests) * 10_000) / 100,
|
||||
seconds: Math.round(seconds * 10) / 10,
|
||||
requestsPerSec: Math.round(requests / seconds),
|
||||
};
|
||||
const memory =
|
||||
memorySamples.length > 0
|
||||
? {
|
||||
rssMaxMb: Math.max(...memorySamples),
|
||||
rssLastMb: memorySamples[memorySamples.length - 1] ?? 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
console.log('');
|
||||
printTable([
|
||||
['Endpoint', 'pozadavku', 'chyb', 'p50 ms', 'p95 ms', 'p99 ms', 'max ms'],
|
||||
...Object.entries(perEndpoint).map(([name, row]) => [
|
||||
name,
|
||||
String(row.count),
|
||||
String(row.errors),
|
||||
ms(row.p50),
|
||||
ms(row.p95),
|
||||
ms(row.p99),
|
||||
ms(row.max),
|
||||
]),
|
||||
]);
|
||||
console.log('');
|
||||
console.log(
|
||||
`Celkem ${total.requests} pozadavku za ${total.seconds} s = ${total.requestsPerSec} req/s, ` +
|
||||
`chyb ${total.errors} (${total.errorRate} %)`,
|
||||
);
|
||||
console.log(
|
||||
`SSE: ${streams.opened} spojeni z ${streamCount}, prijato ${streams.events} udalosti, chyb ${streams.errors}`,
|
||||
);
|
||||
if (memory)
|
||||
console.log(
|
||||
`Pamet serveru (RSS): max ${memory.rssMaxMb} MB, na konci ${memory.rssLastMb} MB`,
|
||||
);
|
||||
else console.log('Pamet serveru: /health ji nevystavuje, jen cisla z klienta');
|
||||
if (webhook) {
|
||||
console.log(
|
||||
`Webhook: ${webhook.accepted} prijato, ${webhook.rejected} odmitnuto, odeslano za ${webhook.sendMs} ms, ` +
|
||||
`fronta prazdna za ${webhook.drainMs} ms = ${webhook.runsPerSec} behu/s ` +
|
||||
`(hotovo ${webhook.done}, selhalo ${webhook.failed}, zbyva ${webhook.pendingAtEnd})`,
|
||||
);
|
||||
}
|
||||
if (errorKinds.size > 0) {
|
||||
console.log('Chyby:');
|
||||
for (const [kind, count] of errorKinds) console.log(` ${count}x ${kind}`);
|
||||
}
|
||||
|
||||
const reportPath = path.join(process.cwd(), REPORT_FILE);
|
||||
await fs.writeFile(
|
||||
reportPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
url: base,
|
||||
local: local !== null,
|
||||
users: settings.users,
|
||||
durationSec: settings.durationSec,
|
||||
endpoints: perEndpoint,
|
||||
total,
|
||||
sse: { ...streams, wanted: streamCount },
|
||||
server: { memory: memory ?? null, health: 'bez pameti, jen uptime' },
|
||||
webhook,
|
||||
errors: Object.fromEntries(errorKinds),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
console.log(`[load] zprava zapsana do ${reportPath}`);
|
||||
} finally {
|
||||
if (local) portFreed = await local.stop();
|
||||
}
|
||||
|
||||
if (!portFreed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[load] selhalo:', err instanceof Error ? (err.stack ?? err.message) : err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user