#!/usr/bin/env node 'use strict'; /* * Load test for the reader: N simulated readers each open random chapters and fetch the pages one after * another with a think time, the way a person paging through a chapter does, plus a small look-ahead. * Measures time to first byte and total per page from this machine. Read-only apart from the login; it * never writes progress. Run it with the server's trace on to see the server's share (docs/performance.md). * * node tools/perf-bench.js --url http://host:8080 --user bench --password-env BENCH_PASSWORD \ * [--readers 4] [--chapters 3] [--pages 40] [--think 400] [--ahead 2] [--manga Berserk] [--json] * --cookie 'sid=…' instead of user/password. --chapter-only skips the page fetches (API timings only). */ function parseArgs(argv) { const o = { url: 'http://localhost:8080', readers: 4, chapters: 3, pages: 40, think: 400, ahead: 2, manga: '', json: false }; for (let i = 0; i < argv.length; i++) { const key = argv[i].replace(/^--/, ''), next = () => argv[++i]; if (['readers', 'chapters', 'pages', 'think', 'ahead'].includes(key)) o[key] = +next(); else if (key === 'password-env') o.password = process.env[next()] || ''; else if (['url', 'user', 'cookie', 'manga'].includes(key)) o[key] = next(); else if (key === 'json' || key === 'chapter-only') o[key.replace('-o', 'O')] = true; else if (key === 'h' || key === 'help') o.help = true; } o.url = o.url.replace(/\/$/, ''); return o; } async function login(o) { if (o.cookie) return o.cookie; const r = await fetch(o.url + '/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: o.user, password: o.password }) }); if (!r.ok) throw new Error(`login failed: HTTP ${r.status} ${await r.text()}`); const cookie = (r.headers.get('set-cookie') || '').split(';')[0]; if (!cookie) throw new Error('login returned no session cookie'); return cookie; } async function timed(url, cookie) { const t0 = performance.now(); const r = await fetch(url, { headers: { cookie } }); const ttfb = performance.now() - t0; let bytes = 0; for await (const chunk of r.body) bytes += chunk.length; return { status: r.status, ttfb, ms: performance.now() - t0, bytes, server: r.headers.get('server-timing') || '' }; } const json = async (o, cookie, path) => { const t0 = performance.now(), r = await fetch(o.url + path, { headers: { cookie } }); if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`); return { data: await r.json(), ms: performance.now() - t0 }; }; const pick = list => list[Math.floor(Math.random() * list.length)]; const sleep = ms => new Promise(r => setTimeout(r, ms)); async function reader(o, cookie, mangas, out, id) { for (let c = 0; c < o.chapters; c++) { const m = pick(mangas); const manga = await json(o, cookie, '/api/manga?m=' + encodeURIComponent(m.id)); out.api.push({ path: '/api/manga', ms: manga.ms }); const ch = pick(manga.data.chapters); const chapter = await json(o, cookie, `/api/chapter?m=${encodeURIComponent(m.id)}&c=${encodeURIComponent(ch.id)}`); out.api.push({ path: '/api/chapter', ms: chapter.ms }); if (o.chapterOnly) continue; const pages = chapter.data.pages.slice(0, o.pages); // The browser keeps `ahead` pages in flight while the reader looks at the current one. const inflight = new Map(); const start = k => { if (k < pages.length && !inflight.has(k)) inflight.set(k, timed(o.url + '/' + pages[k].u, cookie)); }; for (let k = 0; k < pages.length; k++) { const waitFrom = performance.now(); start(k); for (let a = 1; a <= o.ahead; a++) start(k + a); const r = await inflight.get(k); out.pages.push({ reader: id, manga: m.id, chapter: ch.id, page: k, wait: performance.now() - waitFrom, ...r }); await sleep(o.think * (0.5 + Math.random())); } await Promise.all(inflight.values()); } } const pct = (v, p) => { const s = v.slice().sort((a, b) => a - b); return s.length ? Math.round(s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))]) : null; }; const sum = v => ({ n: v.length, p50: pct(v, 50), p90: pct(v, 90), p99: pct(v, 99), max: v.length ? Math.round(Math.max(...v)) : null }); async function main() { const o = parseArgs(process.argv.slice(2)); if (o.help || (!o.cookie && !o.user)) { console.log('usage: node tools/perf-bench.js --url URL (--user NAME --password-env VAR | --cookie sid=…) [--readers N] [--chapters N] [--pages N] [--think ms] [--ahead N] [--manga text] [--chapter-only] [--json]'); process.exit(o.help ? 0 : 2); } const cookie = await login(o); let mangas = (await json(o, cookie, '/api/library')).data.mangas; if (o.manga) mangas = mangas.filter(m => m.title.toLowerCase().includes(o.manga.toLowerCase())); if (!mangas.length) throw new Error('no manga to read'); const out = { pages: [], api: [] }, t0 = Date.now(); await Promise.all(Array.from({ length: o.readers }, (_, i) => reader(o, cookie, mangas, out, i))); const ok = out.pages.filter(p => p.status === 200), mb = ok.reduce((a, p) => a + p.bytes, 0) / 1048576; const result = { readers: o.readers, seconds: Math.round((Date.now() - t0) / 1000), pages: out.pages.length, errors: out.pages.length - ok.length, MB: Math.round(mb), wait: sum(ok.map(p => p.wait)), ttfb: sum(ok.map(p => p.ttfb)), total: sum(ok.map(p => p.ms)), waitedOver300: ok.filter(p => p.wait >= 300).length, waitedOver1s: ok.filter(p => p.wait >= 1000).length, api: Object.fromEntries([...new Set(out.api.map(a => a.path))].map(p => [p, sum(out.api.filter(a => a.path === p).map(a => a.ms))])), worst: ok.sort((a, b) => b.wait - a.wait).slice(0, 8).map(p => ({ wait: Math.round(p.wait), ttfb: Math.round(p.ttfb), ms: Math.round(p.ms), kb: Math.round(p.bytes / 1024), at: `${p.manga} ${p.chapter} p${p.page}` })), }; if (o.json) return console.log(JSON.stringify(result, null, 2)); console.log(`${result.readers} readers, ${result.pages} pages (${result.MB} MB) in ${result.seconds} s, ${result.errors} errors`); for (const k of ['wait', 'ttfb', 'total']) console.log(` ${k.padEnd(6)} ms ${JSON.stringify(result[k])}`); console.log(` page waits >= 300 ms: ${result.waitedOver300}, >= 1 s: ${result.waitedOver1s} (wait = time the reader looked at an empty page)`); for (const [p, s] of Object.entries(result.api)) console.log(` ${p} ${JSON.stringify(s)}`); for (const w of result.worst) console.log(` wait ${w.wait} ms (ttfb ${w.ttfb}, total ${w.ms}, ${w.kb} KB) ${w.at}`); } main().catch(e => { console.error(e.message); process.exit(1); });