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>
76 lines
2.9 KiB
JavaScript
76 lines
2.9 KiB
JavaScript
'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 };
|