Read-ahead per reader; compose passes READ_AHEAD and PERF_TRACE; report fixes

Each user gets an own read-ahead queue, served in turn, so two people reading
at once no longer cancel each other's chapter. The report rounds phase times
and measures the quiet time before a slow storage probe correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-29 22:51:19 +01:00
1 parent 896726a51b
commit d7d3bf5b2b
6 files changed
+57 -34

No files matched your search

+4
View File
@@ -20,6 +20,10 @@ READING_TIMEZONE=UTC
READING_QUIZ_UNLOCK_MINUTES=60 READING_QUIZ_UNLOCK_MINUTES=60
# Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json. # Optional deployment-managed JSON file under DATA_PATH, e.g. /data/reading-quiz.json.
# READING_QUIZ_CATALOG_PATH=/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 PROXY_NETWORK=mangacircus-edge
# Extra Compose files, ':'-separated (docs/deployment.md), e.g. HTTPS with the bundled Caddy: # Extra Compose files, ':'-separated (docs/deployment.md), e.g. HTTPS with the bundled Caddy:
# COMPOSE_FILE=docker-compose.yml:deploy/compose.https.yml # COMPOSE_FILE=docker-compose.yml:deploy/compose.https.yml
+2
View File
@@ -61,6 +61,8 @@ services:
DISCOVERY_HARVEST_AT: ${DISCOVERY_HARVEST_AT:-03:30} DISCOVERY_HARVEST_AT: ${DISCOVERY_HARVEST_AT:-03:30}
DISCOVERY_ANILIST_URL: ${DISCOVERY_ANILIST_URL:-} DISCOVERY_ANILIST_URL: ${DISCOVERY_ANILIST_URL:-}
GREATEST_COVER_WARM: ${GREATEST_COVER_WARM:-1} GREATEST_COVER_WARM: ${GREATEST_COVER_WARM:-1}
READ_AHEAD: ${READ_AHEAD:-1}
PERF_TRACE: ${PERF_TRACE:-0}
networks: [backend, egress] networks: [backend, egress]
healthcheck: healthcheck:
test: [CMD, node, tools/healthcheck.js, reader] test: [CMD, node, tools/healthcheck.js, reader]
+28 -25
View File
@@ -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 * 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. * 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'); const fs = require('fs');
class ReadAhead { class ReadAhead {
constructor({ concurrency = 2, remember = 4000, onBatch = () => {} } = {}) { constructor({ concurrency = 2, remember = 4000, onBatch = () => {} } = {}) {
this.concurrency = concurrency; this.remember = remember; this.onBatch = 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. // `files` in reading order; `tag` names the batch in the trace.
warm(files, tag) { warm(files, tag, key = '') {
const fresh = files.filter(f => !this.recent.has(f)); const old = this.batches.get(key);
if (this.batch) this.finishBatch(true); if (old) this.finish(key, old, true);
this.queue = fresh; const queue = files.filter(f => !this.recent.has(f));
this.batch = { tag, files: fresh.length, skipped: files.length - fresh.length, bytes: 0, t0: performance.now(), done: 0 }; const batch = { tag, queue, files: queue.length, skipped: files.length - queue.length, bytes: 0, done: 0, active: 0, t0: performance.now() };
if (!fresh.length) this.finishBatch(false); 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(); this.pump();
} }
// Runs after the current batch (e.g. the next chapter) unless a newer warm() replaces it first. // Runs after the reader's current batch (e.g. the next chapter) unless a newer warm() replaces it first.
then(files, tag) { then(files, tag, key = '') {
const after = () => this.warm(files, tag); const batch = this.batches.get(key);
if (!this.batch) after(); else this.batch.next = after; if (!batch) this.warm(files, tag, key); else batch.next = () => this.warm(files, tag, key);
} }
pump() { pump() {
while (this.active < this.concurrency && this.queue.length) { while (this.active < this.concurrency) {
const file = this.queue.shift(), batch = this.batch; const key = this.turn.find(k => this.batches.get(k)?.queue.length);
this.active++; if (key === undefined) return;
this.readFile(file).then(bytes => { this.turn.push(...this.turn.splice(this.turn.indexOf(key), 1)); // round robin between readers
this.note(file); const batch = this.batches.get(key), file = batch.queue.shift();
if (batch === this.batch) { batch.bytes += bytes; batch.done++; } this.active++; batch.active++;
}, () => {}).finally(() => { this.readFile(file).then(bytes => { this.note(file); batch.bytes += bytes; batch.done++; }, () => {}).finally(() => {
this.active--; this.active--; batch.active--;
// A replaced batch's reads may still be running; the current batch ends when all reads have. if (this.batches.get(key) === batch && !batch.queue.length && !batch.active) this.finish(key, batch, false);
if (this.batch && !this.queue.length && !this.active) this.finishBatch(false);
this.pump(); this.pump();
}); });
} }
} }
finishBatch(replaced) { finish(key, b, replaced) {
const b = this.batch; this.batch = null; if (this.batches.get(key) === b) this.batches.delete(key);
if (!b) return; 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, 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 }); ms: Math.round(performance.now() - b.t0), replaced });
if (!replaced && b.next) b.next(); if (!replaced && b.next) b.next();
+4 -4
View File
@@ -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 }; 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. // 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 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)); 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); const next = neighbour(m, ch, 1, s), nextCh = next && m.chapterById.get(next.id);
if (nextCh) { if (nextCh) {
const nv = ranked(m, nextCh)[0].v; 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) { function neighbour(m, ch, step, s) {
@@ -768,7 +768,7 @@ const routes = {
let seen = false; 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; 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 (seen) save();
if (readAhead) warmChapter(m, ch, v, prog[ch.id], s); if (readAhead) warmChapter(u, m, ch, v, prog[ch.id], s);
return { return {
perf: tracer.on, perf: tracer.on,
manga: { id: m.id, title: m.title }, manga: { id: m.id, title: m.title },
+13
View File
@@ -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]]); 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 () => { 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'); const dir = tmp(), a = path.join(dir, 'a.jpg'), b = path.join(dir, 'b.jpg');
fs.writeFileSync(a, 'a'); fs.writeFileSync(b, 'bb'); fs.writeFileSync(a, 'a'); fs.writeFileSync(b, 'bb');
+6 -5
View File
@@ -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 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 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; 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]; 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. // 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); const nas = events.filter(e => e.k === 'nas' && !e.error);
let lastIo = null; // Library reads that finished before a probe started (a probe's own read does not count).
const ioTimes = events.filter(e => isPageImage(e) || e.k === 'nas' || e.k === 'ahead').map(e => e.t); 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 = { 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 => { 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; 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: lastIo ? round((e.t - e.ms - lastIo) / 60000) : null }; return { at: new Date(e.t).toISOString(), firstMs: e.firstMs, quietMinBefore: before ? round((start - before) / 60000) : null };
}).slice(0, 30); }).slice(0, 30);
const loops = events.filter(e => e.k === 'loop'); const loops = events.filter(e => e.k === 'loop');