perf(db): write 4 KiB pages, because requests cost more than bytes

The 1 KiB page size optimised for the fewest bytes per seeked row. The
first real measurement says that is the wrong quantity: a name search
made 390 requests for 608 KB and took 16.9 seconds. The worker reads
with synchronous XHR (lazyFile.ts opens every GET with async=false), so
requests are strictly serial at roughly 40 ms each, and 390 x 40 ms is
the whole of that time. The bytes were never the problem.

Four times the page size means a quarter of the pages for the same
scan, and a shallower b-tree for each of the 100 result rows the search
seeks individually. It also costs about 5% less file.
This commit is contained in:
tiennm99 committed 2026-08-14 16:52:33 +07:00
1 parent 70374f1274
commit c407130162
4 files changed
+22 -12

No files matched your search

+3 -2
View File
@@ -181,9 +181,10 @@ silently drops 13,720 students** (Hanoi +7,275, HCM +6,445). That is what
## Expected row counts
The databases are written with 1 KiB pages (`PRAGMA page_size` in
The databases are written with 4 KiB pages (`PRAGMA page_size` in
`parser/internal/writer/writer.go`) because the browser fetches them a page per
HTTP request. `CHUNK_BYTES` in `web/src/lib/sqlite.svelte.js` must match.
HTTP request, and those requests are serial. `CHUNK_BYTES` in
`web/src/lib/sqlite.svelte.js` must match.
| id | Source rows | Skipped | DB rows |
| --- | --- | --- | --- |
+1 -1
View File
@@ -176,7 +176,7 @@ total descending.
| WASM hosting | Bundled with the app | `sql.js-httpvfs` ships its own build; one less third-party runtime dependency |
| Diacritics search | Pre-computed `ho_ten_ascii`, indexed word by word | `LOWER(REPLACE(...))` at query time defeats the index, and `LIKE '%x%'` reads the whole table |
| Row count in the footer | Read from `datasets.json` | `COUNT(*)` scans an index — 20 MB over range requests |
| Page size | 1 KiB, matched by `requestChunkSize` | One HTTP request is one page; a row fetched by seek costs 1 KB rather than 4 KB, for about 5% more file |
| Page size | 4 KiB, matched by `requestChunkSize` | One HTTP request is one page, and the worker issues them serially over synchronous XHR at ~40 ms each. Request count, not size, is what a search waits on: 390 requests moved 608 KB in 17 s. Four times the page is a quarter of the pages per scan. The 1 KiB both httpvfs libraries suggest optimises bytes per seek instead |
| SQL safety | Leading-keyword allowlist | `sql.js` is in-memory so writes cannot persist; the allowlist prevents confusion |
| Row caps | 100 (lookup), 1000 (SQL) | Keeps DOM render sizes reasonable |
| Routing | SvelteKit file routes, prerendered | Each dataset gets a real HTML file with its own title |
+14 -8
View File
@@ -46,14 +46,20 @@ func OpenDB(dbPath string) (*sql.DB, error) {
// Before the DDL, because a page size cannot change once a table exists —
// only the VACUUM in Finish could rewrite it, and only to this same value.
//
// 1 KiB rather than SQLite's 4 KiB default because the browser reads this
// file a page at a time over HTTP: a row fetched by index seek costs one
// page, so a search that returns 100 scattered rows transfers 100 KB
// instead of 400 KB. It costs about 5% file size, and both sql.js-httpvfs
// and sqlite-wasm-http recommend it. web/src/lib/sqlite.svelte.js must
// request the same size, and web/src/lib/db-probe.js warns when a published
// database disagrees with it.
if _, err := db.Exec("PRAGMA page_size = 1024"); err != nil {
// SQLite's default, kept because the browser reads this file a page at a
// time over HTTP and what costs time is the number of requests, not their
// size. The library reads with synchronous XHR, so requests are strictly
// serial at about 40 ms each; a name search that made 390 of them took 17
// seconds to move 608 KB. Four times the page size is a quarter of the
// pages for the same scan, and a shallower b-tree for each seek.
//
// The 1 KiB both sql.js-httpvfs and sqlite-wasm-http suggest optimises the
// other way — fewest bytes per seeked row — which is the wrong trade when
// one request costs more than the kilobyte it carries.
//
// web/src/lib/sqlite.svelte.js must request the same size, and
// web/src/lib/db-probe.js warns when a published database disagrees.
if _, err := db.Exec("PRAGMA page_size = 4096"); err != nil {
db.Close()
return nil, fmt.Errorf("set page size: %w", err)
}
+4 -1
View File
@@ -18,7 +18,10 @@ import { probeDatabase } from "./db-probe.js";
// Must equal the page size the parser writes (PRAGMA page_size in
// parser/internal/writer/writer.go), so one request is exactly one page. A
// mismatch makes every logical page read span two requests.
const CHUNK_BYTES = 1024;
//
// Reads are serial — the worker uses synchronous XHR — so the count of
// requests, not their size, is what a search waits on.
const CHUNK_BYTES = 4096;
/** Generous for indexed work: a name search costs well under 1 MB. */
export const SEARCH_BUDGET_BYTES = 25 * 1024 * 1024;