Performance trace, load test and chapter read-ahead
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>
This commit is contained in:
1 parent
ea8a5b40e7
commit
896726a51b
16 files changed
+893
-8
No files matched your search
@@ -12,6 +12,8 @@
|
||||
* SCRAPER_URL=http://127.0.0.1:8091 scraper daemon API
|
||||
* SCRAPER_TOKEN=... shared daemon token (optional on loopback)
|
||||
* BACKUP=/backup folder for built-in DATA backups (unset = backups off)
|
||||
* READ_AHEAD=1 read an opened chapter's pages (then the next chapter's) into the OS cache, 0 = off
|
||||
* PERF_TRACE=1 performance trace to DATA/perf/ (or touch DATA/perf/trace.on at runtime; docs/performance.md)
|
||||
*/
|
||||
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library');
|
||||
@@ -36,6 +38,8 @@ const { chapterDates } = require('./chapter-dates');
|
||||
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
|
||||
const { BackupService, BackupError } = require('./backup');
|
||||
const moments = require('./moments');
|
||||
const { Tracer, requestTrace } = require('./perf/trace');
|
||||
const { ReadAhead } = require('./perf/read-ahead');
|
||||
|
||||
const PORT = +process.env.PORT || 8080;
|
||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||
@@ -64,6 +68,9 @@ const MIME = {
|
||||
|
||||
// ---------- storage: one JSON file, written atomically ----------
|
||||
fs.mkdirSync(DATA, { recursive: true });
|
||||
const tracer = new Tracer({ dir: path.join(DATA, 'perf') }).start();
|
||||
const readAhead = process.env.READ_AHEAD === '0' ? null
|
||||
: new ReadAhead({ onBatch: batch => tracer.log({ k: 'ahead', ...batch }) });
|
||||
const catalog = new Catalog(DATA);
|
||||
const greatestCatalog = loadGreatestCatalog();
|
||||
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
|
||||
@@ -175,8 +182,10 @@ async function performScan(mode) {
|
||||
scanState = { at: Date.now(), ms: Date.now() - t0, mode, changedMangas: changed.length,
|
||||
mangas: lib.mangas.length, chapters, pages, stale: false, error: null };
|
||||
console.log(`[scan] ${mode}: ${changed.length} changed manga, ${lib.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`);
|
||||
tracer.log({ k: 'scan', mode, ms: scanState.ms, changed: changed.length });
|
||||
return scanState;
|
||||
} catch (e) {
|
||||
tracer.log({ k: 'scan', mode, ms: Date.now() - t0, error: e.message });
|
||||
scanState = { ...scanState, at: Date.now(), error: e.message };
|
||||
console.error('[scan] failed:', e.message);
|
||||
return scanState;
|
||||
@@ -253,9 +262,14 @@ async function scraperRequest(endpoint, body) {
|
||||
options.method = 'POST'; headers['content-type'] = 'application/json'; options.body = JSON.stringify(body);
|
||||
}
|
||||
let response;
|
||||
const t0 = performance.now();
|
||||
try { response = await fetch(SCRAPER_URL + endpoint, options); }
|
||||
catch (error) { throw new HttpError(503, `Scraper service unavailable: ${error.message}`); }
|
||||
catch (error) {
|
||||
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), error: error.message });
|
||||
throw new HttpError(503, `Scraper service unavailable: ${error.message}`);
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), status: response.status });
|
||||
if (!response.ok) throw new HttpError(response.status >= 500 ? 502 : response.status, data.error || `Scraper HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
@@ -433,6 +447,17 @@ function continueOf(m, prog, s) {
|
||||
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
|
||||
return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label };
|
||||
}
|
||||
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
|
||||
function warmChapter(m, ch, v, progress, s) {
|
||||
const from = Math.min(Math.max(0, +(progress && progress.v === v.id && progress.p) || 0), v.pages.length);
|
||||
const pages = v.pages.slice(from).concat(v.pages.slice(0, from)).map(p => path.join(LIB, v.rel, p.f));
|
||||
readAhead.warm(pages, `${m.id}/${ch.id}`);
|
||||
const next = neighbour(m, ch, 1, s), nextCh = next && m.chapterById.get(next.id);
|
||||
if (nextCh) {
|
||||
const nv = ranked(m, nextCh)[0].v;
|
||||
readAhead.then(nv.pages.map(p => path.join(LIB, nv.rel, p.f)), `${m.id}/${nextCh.id}`);
|
||||
}
|
||||
}
|
||||
function neighbour(m, ch, step, s) {
|
||||
for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) {
|
||||
const c = m.chapters[i];
|
||||
@@ -743,7 +768,9 @@ const routes = {
|
||||
let seen = false;
|
||||
for (const n of db.notes[u.name] || []) if (!n.read && n.m === m.id && n.c === ch.id) seen = n.read = true;
|
||||
if (seen) save();
|
||||
if (readAhead) warmChapter(m, ch, v, prog[ch.id], s);
|
||||
return {
|
||||
perf: tracer.on,
|
||||
manga: { id: m.id, title: m.title },
|
||||
chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra },
|
||||
version: v.id, versions: versionsOf(m, ch, u), direction: v.direction,
|
||||
@@ -875,6 +902,21 @@ const routes = {
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// Timings the reader page measured (resource timing, decode, page-turn waits) while the trace is on.
|
||||
'POST /api/perf': ({ u, b }) => {
|
||||
if (!tracer.on) return { on: false };
|
||||
const events = Array.isArray(b.events) ? b.events.slice(0, 300) : [];
|
||||
for (const e of events) if (e && typeof e === 'object' && !Array.isArray(e)) {
|
||||
const clean = {};
|
||||
for (const [key, value] of Object.entries(e).slice(0, 30)) {
|
||||
if (key === 't' || key === 'k') continue; // the server's clock and kind
|
||||
if (typeof value === 'number' && Number.isFinite(value)) clean[key] = Math.round(value * 10) / 10;
|
||||
else if (typeof value === 'string' || typeof value === 'boolean') clean[key] = typeof value === 'string' ? value.slice(0, 300) : value;
|
||||
}
|
||||
tracer.log({ ...clean, k: 'client', user: u.name, page: b.page ? String(b.page).slice(0, 40) : undefined });
|
||||
}
|
||||
return { on: true };
|
||||
},
|
||||
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
|
||||
'POST /api/notifications/read': ({ u, b }) => {
|
||||
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
|
||||
@@ -1062,21 +1104,28 @@ async function handleApi(req, res, u, route, q) {
|
||||
}
|
||||
sendJson(res, 200, await route({ req, res, u, b, q }));
|
||||
}
|
||||
function serveFile(req, res, root, rel, cacheControl, only) {
|
||||
function serveFile(req, res, root, rel, cacheControl, only, trace) {
|
||||
let file;
|
||||
try { file = path.resolve(root, decodeURIComponent(rel)); } catch { return sendJson(res, 400, { error: 'Bad path' }); }
|
||||
if (!file.startsWith(root + path.sep) || (only && !only.test(file))) return sendJson(res, 404, { error: 'Not found' });
|
||||
fs.stat(file, (err, st) => {
|
||||
if (trace) trace.mark('stat');
|
||||
if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' });
|
||||
const etag = `"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`;
|
||||
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl }); return res.end(); }
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl });
|
||||
const headers = { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl };
|
||||
// The browser's resource timing shows this, so a slow page can be matched to the server's share.
|
||||
if (trace && trace.on) headers['Server-Timing'] = `stat;dur=${trace.elapsed()}`;
|
||||
res.writeHead(200, headers);
|
||||
if (req.method === 'HEAD') return res.end();
|
||||
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
|
||||
const stream = fs.createReadStream(file).on('error', () => res.destroy());
|
||||
if (trace) trace.stream(stream);
|
||||
stream.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const trace = requestTrace(tracer, req, res);
|
||||
try {
|
||||
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
|
||||
const u = userOf(req);
|
||||
@@ -1124,9 +1173,10 @@ http.createServer(async (req, res) => {
|
||||
const parts = relative.split(path.sep);
|
||||
if (parts.length >= 4 && !readingAccess.status(u.name).allowed)
|
||||
bad(423, 'Reading is outside the deployment schedule.');
|
||||
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG);
|
||||
trace.mark('access');
|
||||
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG, trace);
|
||||
}
|
||||
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache');
|
||||
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache', null, trace);
|
||||
} catch (e) {
|
||||
const expected = e instanceof HttpError || e instanceof CatalogError || e instanceof moments.MomentError;
|
||||
if (!expected) console.error(e);
|
||||
@@ -1134,6 +1184,13 @@ http.createServer(async (req, res) => {
|
||||
}
|
||||
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
|
||||
|
||||
// The trace's storage probe reads a random page of the library (rarely in the OS cache).
|
||||
tracer.setProbeSource(() => {
|
||||
const m = lib.mangas[Math.floor(Math.random() * lib.mangas.length)];
|
||||
const ch = m && m.chapters[Math.floor(Math.random() * m.chapters.length)], v = ch && ch.versions[0];
|
||||
const p = v && v.pages[Math.floor(Math.random() * v.pages.length)];
|
||||
return p ? path.join(LIB, v.rel, p.f) : null;
|
||||
});
|
||||
if (lib.mangas.length) refreshChanged();
|
||||
else rescan();
|
||||
if (SCAN_MINUTES > 0) setInterval(() => refreshChanged(), SCAN_MINUTES * 60000).unref();
|
||||
|
||||
Reference in new issue
Block a user