A switchable trace (DATA/perf/trace.on or PERF_TRACE=1) records every request's phases (stat, open, first read, disk vs network wait), browser resource timings, page-turn waits and flow stalls, a cold storage probe, event-loop lag, scans and scraper calls as JSONL. tools/perf-report.js says which phase slow loads spend their time in; tools/perf-bench.js simulates readers. READ_AHEAD (default on) reads an opened chapter's pages, then the next chapter's, into the OS cache so a NAS's cold reads stop reaching the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
6.5 KiB
JavaScript
111 lines
6.5 KiB
JavaScript
#!/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); });
|