diff --git a/.env.example b/.env.example index 3d8943d..98a9346 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ READING_TIMEZONE=UTC READING_QUIZ_UNLOCK_MINUTES=60 # Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json. # READING_QUIZ_CATALOG_PATH=/data/reading-quiz.json +# Read an opened chapter into the OS cache ahead of the browser (1 = on, the default); see docs/performance.md. +# READ_AHEAD=1 +# Performance trace from the start (also switchable at runtime: touch DATA_PATH/perf/trace.on). +# PERF_TRACE=0 PROXY_NETWORK=mangacircus-edge # Extra Compose files, ':'-separated (docs/deployment.md), e.g. HTTPS with the bundled Caddy: # COMPOSE_FILE=docker-compose.yml:deploy/compose.https.yml diff --git a/docker-compose.yml b/docker-compose.yml index ffe7d02..d0afe1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,8 @@ services: DISCOVERY_HARVEST_AT: ${DISCOVERY_HARVEST_AT:-03:30} DISCOVERY_ANILIST_URL: ${DISCOVERY_ANILIST_URL:-} GREATEST_COVER_WARM: ${GREATEST_COVER_WARM:-1} + READ_AHEAD: ${READ_AHEAD:-1} + PERF_TRACE: ${PERF_TRACE:-0} networks: [backend, egress] healthcheck: test: [CMD, node, tools/healthcheck.js, reader] diff --git a/perf/read-ahead.js b/perf/read-ahead.js index 6caa2e7..4833cf9 100644 --- a/perf/read-ahead.js +++ b/perf/read-ahead.js @@ -5,49 +5,52 @@ * 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. + * 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.queue = []; this.active = 0; this.recent = new Map(); this.batch = null; this.buffers = []; + 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) { - 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); + 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 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; + // 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 && 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); + 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(); }); } } - finishBatch(replaced) { - const b = this.batch; this.batch = null; - if (!b) return; + 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(); diff --git a/server.js b/server.js index bce53ee..55ad355 100644 --- a/server.js +++ b/server.js @@ -448,14 +448,14 @@ function continueOf(m, prog, s) { 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) { +function warmChapter(u, 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}`); + readAhead.warm(pages, `${m.id}/${ch.id}`, u.name); 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}`); + readAhead.then(nv.pages.map(p => path.join(LIB, nv.rel, p.f)), `${m.id}/${nextCh.id}`, u.name); } } function neighbour(m, ch, step, s) { @@ -768,7 +768,7 @@ 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); + if (readAhead) warmChapter(u, m, ch, v, prog[ch.id], s); return { perf: tracer.on, manga: { id: m.id, title: m.title }, diff --git a/test/perf.test.js b/test/perf.test.js index 0dd46ef..c777738 100644 --- a/test/perf.test.js +++ b/test/perf.test.js @@ -76,6 +76,19 @@ test('read-ahead reads files in order and a newer chapter replaces the queued on assert.deepEqual(batches.map(x => [x.tag, x.files, x.skipped]), [['C', 0, 2]]); }); +test('read-ahead keeps one queue per reader and serves them in turn', async () => { + const dir = tmp(), mk = n => { const f = path.join(dir, n); fs.writeFileSync(f, n); return f; }; + const order = [], batches = []; + const ahead = new ReadAhead({ concurrency: 1, onBatch: b => batches.push(b) }); + const read = ahead.readFile.bind(ahead); + ahead.readFile = f => { order.push(path.basename(f)); return read(f); }; + ahead.warm([mk('a1'), mk('a2')], 'A', 'ann'); + ahead.warm([mk('b1'), mk('b2')], 'B', 'bob'); + await new Promise(r => { const check = () => (batches.length === 2 ? r() : setTimeout(check, 5)); check(); }); + assert.deepEqual(order, ['a1', 'b1', 'a2', 'b2']); + assert.ok(batches.every(b => !b.replaced && b.done === 2)); +}); + test('read-ahead runs the follow-up batch after the current one', async () => { const dir = tmp(), a = path.join(dir, 'a.jpg'), b = path.join(dir, 'b.jpg'); fs.writeFileSync(a, 'a'); fs.writeFileSync(b, 'bb'); diff --git a/tools/perf-report.js b/tools/perf-report.js index 809e6a4..32a118a 100644 --- a/tools/perf-report.js +++ b/tools/perf-report.js @@ -53,7 +53,8 @@ function serverPhases(e) { const stat = e.stat ?? 0, open = e.open != null ? e.open - stat : 0, firstRead = e.first != null && e.open != null ? e.first - e.open : 0; const rest = Math.max(0, (e.disk ?? 0) - firstRead), net = e.netWait ?? 0; const tail = e.readEnd != null ? Math.max(0, e.ms - e.readEnd) : 0; - return { stat, open, firstRead, readRest: rest, netWait: net, tail }; + const r = v => Math.round(v * 10) / 10; + return { stat: r(stat), open: r(open), firstRead: r(firstRead), readRest: r(rest), netWait: r(net), tail: r(tail) }; } const dominant = phases => Object.entries(phases).sort((a, b) => b[1] - a[1])[0][0]; @@ -106,12 +107,12 @@ function analyse(events, { slow }) { // Storage probe: cold reads of random pages; a long read after quiet time points at disks waking up. const nas = events.filter(e => e.k === 'nas' && !e.error); - let lastIo = null; - const ioTimes = events.filter(e => isPageImage(e) || e.k === 'nas' || e.k === 'ahead').map(e => e.t); + // Library reads that finished before a probe started (a probe's own read does not count). + const ioEnds = events.filter(e => isPageImage(e) || e.k === 'nas' || e.k === 'ahead').map(e => e.t); out.nas = { probes: nas.length, errors: events.filter(e => e.k === 'nas' && e.error).length, first: stats(nas.map(e => e.firstMs)), total: stats(nas.map(e => e.ms)) }; out.nas.slow = nas.filter(e => e.firstMs >= 1000).map(e => { - let i = ioTimes.findIndex(t => t >= e.t); lastIo = i > 0 ? ioTimes[i - 1] : null; - return { at: new Date(e.t).toISOString(), firstMs: e.firstMs, quietMinBefore: lastIo ? round((e.t - e.ms - lastIo) / 60000) : null }; + const start = e.t - e.ms, before = ioEnds.filter(t => t < start).at(-1); + return { at: new Date(e.t).toISOString(), firstMs: e.firstMs, quietMinBefore: before ? round((start - before) / 60000) : null }; }).slice(0, 30); const loops = events.filter(e => e.k === 'loop');