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:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-29 22:28:50 +01:00
1 parent ea8a5b40e7
commit 896726a51b
16 files changed
+893 -8

No files matched your search

+2
View File
@@ -210,6 +210,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `POST /api/moments` `{m, c, v, pages, layout, region, cat, note}` | user | Save a moment: `pages` are 1–4 distinct indexes in displayed order, `layout` `row` (default) or `column`, `cat` one of `twist hot awesome cute funny feels` (required), `region` fractions of the pages put together, or null |
| `POST /api/moments/update` `{id, cat?, region?, note?}` · `POST /api/moments/delete` `{id}` | user | Edit or delete one of the caller's own moments (another user's id is `404`) |
| `GET /api/notifications` · `POST /api/notifications/read` `{id \| all}` | user | Notification list and read state |
| `POST /api/perf` `{page, events: […]}` | user | Browser timings for the performance trace; ignored with `{on: false}` while the trace is off. `/api/chapter` returns `perf: true` while it is on ([performance.md](performance.md)) |
| `GET /api/admin` | admin | Users, last scan result, library path |
| `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result |
| `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password |
@@ -400,6 +401,7 @@ docker compose up -d --build
| User data | `db.json` is rewritten as a whole, at most about twice per second while people read. Comfortable up to a few MB (a household or small group, thousands of chapters read) |
| Concurrency | One process, one writer. No clustering or horizontal scaling by design |
| Images | Served as stored. No resizing or thumbnails, so large covers make the library page heavy |
| Slow storage | Opening a chapter reads its pages (then the next chapter's) into the OS cache, two at a time (`READ_AHEAD`). Where the time goes: [performance.md](performance.md) |
## 10. Design decisions
+78
View File
@@ -0,0 +1,78 @@
# Performance trace, load test and read-ahead
When pages load slowly, the trace shows where the time goes: the storage (a NAS, a disk waking up), the
reader process (CPU, event loop), library scans running at the same time, or the network to the browser.
## Switching the trace on and off
No restart is needed. The reader checks for a flag file every 5 seconds:
```bash
touch "$DATA/perf/trace.on" # on (Docker: the data folder mounted at /data)
rm "$DATA/perf/trace.on" # off
```
`PERF_TRACE=1` in the environment keeps it on from the start. While it is off, the reader does no extra work.
The trace goes to `DATA/perf/trace-YYYY-MM-DD.jsonl` (UTC days), one JSON object per line. Files older than
14 days are deleted; one day stops growing at 256 MB. The file contains request paths (manga and chapter
names) and user names, so treat it like the rest of `DATA`.
## What is recorded
Every line has `t` (server time, ms since the epoch) and `k` (kind):
| `k` | Fields | Meaning |
|---|---|---|
| `req` | `m`, `path`, `status`, `ms`, `bytes`, `conn`, `aborted`; for files `stat`, `open`, `first`, `readEnd`, `disk`, `netWait`; for pages also `access` | One HTTP request. Phase fields are ms since the request arrived: `stat` file lookup done, `open` file opened, `first` first chunk read, `readEnd` last chunk read. `disk` = time spent waiting for the storage, `netWait` = time the file stream was paused because the client did not take data fast enough. `conn` > 1 means a reused keep-alive connection |
| `client` | `e`: `hello`, `res`, `view`, `stall` | Reported by the reader page while the trace is on (`public/perf.js`). `res`: the browser's resource timing of a page image (`queue`, `dns`, `connect`, `ttfb`, `download`, `total`, `size`, `server` = the reader's `stat` time, `cached`). `view`: a page turn in page/book mode, `wait` until the new page was decoded and painted, `preloaded`. `stall`: flow mode, a page on screen before its image, `wait` until it loaded. `hello`: browser, viewport, connection type |
| `nas` | `statMs`, `firstMs`, `ms`, `bytes` or `error` | Every `PERF_NAS_PROBE_SECONDS` (60) one random library page is read. Out of a large library it is almost never cached, so this is the storage's cold latency over time; seconds after a quiet period mean disks were in standby |
| `loop` | `lagP50`, `lagP99`, `lagMax`, `cpu`, `rss`, `heap` | Every 5 s: event-loop delay (ms), CPU % of one core, memory (MB) |
| `scan` | `mode`, `ms`, `changed` | Library scans (change checks every `SCAN_INTERVAL`, full reconciliations) |
| `ahead` | `tag`, `files`, `done`, `skipped`, `bytes`, `ms`, `replaced` | A read-ahead batch (below) |
| `scraper` | `endpoint`, `ms`, `status`/`error` | Calls from the reader to the scraper daemon |
| `trace` | `on` | The trace was switched on or off |
## Reading the result
```bash
node tools/perf-report.js "$DATA/perf" # all days
node tools/perf-report.js "$DATA/perf" --since 2026-10-29T18:00Z --slow 300
docker compose exec reader node tools/perf-report.js /data/perf # inside the container
```
The report starts with a verdict (which phase dominates the slow loads), then percentiles for the server's
phases, the browser's timings, page turns, flow stalls, the storage probe, the event loop, scans, read-ahead
and the API routes. `--json` prints the same numbers for scripts and agents.
How to read it:
- **Slow `firstRead` / `readRest`, slow `nas` probes**: the storage is slow. Probes of several seconds after
minutes without reads point at disk standby (on a Synology: *Control Panel → Hardware & Power → HDD
Hibernation*).
- **Slow `stat`**: metadata lookups on the storage are slow (often during a scan, see `scan`).
- **High `netWait`, or browser `download` much longer than the server's `ms`**: the network between server
and browser (Wi-Fi, VPN, a proxy).
- **Browser `queue`**: the browser waited for a free connection (too many parallel requests).
- **High `lagMax`**: the reader process was busy and could not answer.
## Load test
`tools/perf-bench.js` simulates readers paging through random chapters with a think time and a small
look-ahead, and reports how long a reader looked at an empty page (`wait`), time to first byte and total
per page. It only reads (no progress is written). Use a separate account:
```bash
BENCH_PASSWORD=… node tools/perf-bench.js --url http://server:8080 --user bench --password-env BENCH_PASSWORD \
--readers 4 --chapters 3 --pages 40 --think 400 --ahead 2
```
Run it with the trace on to see the server's side of the same requests.
## Read-ahead
`READ_AHEAD=1` (default): when a chapter is opened, the reader reads its pages once in page order, starting
at the saved page, two at a time, and then the next chapter's. The data is thrown away; the OS page cache
keeps it, so the browser's requests are answered from memory instead of the storage. Opening another
chapter replaces whatever is still queued. `READ_AHEAD=0` turns it off (useful on a local SSD, or to compare
with the trace).