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:
1 parent
896726a51b
commit
d7d3bf5b2b
6 files changed
+57
-34
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user