'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. * * One queue per reader (`key`, the user): opening another chapter replaces that reader's queue only, and * the queues take turns so two people reading at once both get their pages ahead. */ const fs = require('fs'); class ReadAhead { constructor({ concurrency = 2, remember = 4000, onBatch = () => {} } = {}) { this.concurrency = concurrency; this.remember = remember; this.onBatch = onBatch; this.batches = new Map(); this.turn = []; this.active = 0; this.recent = new Map(); this.buffers = []; } // `files` in reading order; `tag` names the batch in the trace. warm(files, tag, key = '') { const old = this.batches.get(key); if (old) this.finish(key, old, true); const queue = files.filter(f => !this.recent.has(f)); const batch = { tag, queue, files: queue.length, skipped: files.length - queue.length, bytes: 0, done: 0, active: 0, t0: performance.now() }; this.batches.set(key, batch); if (!this.turn.includes(key)) this.turn.unshift(key); // a fresh request goes next if (!queue.length) this.finish(key, batch, false); this.pump(); } // Runs after the reader's current batch (e.g. the next chapter) unless a newer warm() replaces it first. then(files, tag, key = '') { const batch = this.batches.get(key); if (!batch) this.warm(files, tag, key); else batch.next = () => this.warm(files, tag, key); } pump() { while (this.active < this.concurrency) { const key = this.turn.find(k => this.batches.get(k)?.queue.length); if (key === undefined) return; this.turn.push(...this.turn.splice(this.turn.indexOf(key), 1)); // round robin between readers const batch = this.batches.get(key), file = batch.queue.shift(); this.active++; batch.active++; this.readFile(file).then(bytes => { this.note(file); batch.bytes += bytes; batch.done++; }, () => {}).finally(() => { this.active--; batch.active--; if (this.batches.get(key) === batch && !batch.queue.length && !batch.active) this.finish(key, batch, false); this.pump(); }); } } finish(key, b, replaced) { if (this.batches.get(key) === b) this.batches.delete(key); if (!this.batches.has(key)) this.turn = this.turn.filter(k => k !== key); b.queue = []; 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 };