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:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-29 22:28:50 +01:00
1 parent ea8a5b40e7
commit 896726a51b
16 files changed
+893 -8

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
'use strict';
/*
* Read-ahead for network libraries: when a chapter is opened, its pages (then the next chapter's) are read
* once in page order, a few at a time, so the OS page cache holds them before the browser asks. On a NAS
* with spinning disks a cold page costs 10-100 ms (seconds while a disk wakes up); a cached one well under
* 5 ms. The data is discarded; only the cache effect matters.
*
* Only the newest chapter request counts: opening another chapter replaces whatever is still queued.
*/
const fs = require('fs');
class ReadAhead {
constructor({ concurrency = 2, remember = 4000, onBatch = () => {} } = {}) {
this.concurrency = concurrency; this.remember = remember; this.onBatch = onBatch;
this.queue = []; this.active = 0; this.recent = new Map(); this.batch = null; this.buffers = [];
}
// `files` in reading order; `tag` names the batch in the trace.
warm(files, tag) {
const fresh = files.filter(f => !this.recent.has(f));
if (this.batch) this.finishBatch(true);
this.queue = fresh;
this.batch = { tag, files: fresh.length, skipped: files.length - fresh.length, bytes: 0, t0: performance.now(), done: 0 };
if (!fresh.length) this.finishBatch(false);
this.pump();
}
// Runs after the current batch (e.g. the next chapter) unless a newer warm() replaces it first.
then(files, tag) {
const after = () => this.warm(files, tag);
if (!this.batch) after(); else this.batch.next = after;
}
pump() {
while (this.active < this.concurrency && this.queue.length) {
const file = this.queue.shift(), batch = this.batch;
this.active++;
this.readFile(file).then(bytes => {
this.note(file);
if (batch === this.batch) { batch.bytes += bytes; batch.done++; }
}, () => {}).finally(() => {
this.active--;
// A replaced batch's reads may still be running; the current batch ends when all reads have.
if (this.batch && !this.queue.length && !this.active) this.finishBatch(false);
this.pump();
});
}
}
finishBatch(replaced) {
const b = this.batch; this.batch = null;
if (!b) return;
this.onBatch({ tag: b.tag, files: b.files, done: b.done, skipped: b.skipped, bytes: b.bytes,
ms: Math.round(performance.now() - b.t0), replaced });
if (!replaced && b.next) b.next();
}
note(file) {
this.recent.delete(file); this.recent.set(file, 1);
while (this.recent.size > this.remember) this.recent.delete(this.recent.keys().next().value);
}
async readFile(file) {
const buf = this.buffers.pop() || Buffer.allocUnsafe(256 * 1024);
const fh = await fs.promises.open(file, 'r');
let total = 0;
try {
for (;;) {
const { bytesRead } = await fh.read(buf, 0, buf.length, total);
if (!bytesRead) break;
total += bytesRead;
}
} finally { await fh.close(); this.buffers.push(buf); }
return total;
}
}
module.exports = { ReadAhead };