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
@@ -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 };
|
||||
Reference in new issue
Block a user