Files
thptqg/docs/system-architecture.md
2026-10-03 11:59:22 +07:00

260 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# System Architecture
Static site, no backend. The browser downloads the whole SQLite file once and
queries it in memory with `sql.js` (SQLite compiled to WebAssembly). 2016 costs
about 31 MB to fetch and 142 MB of memory to hold; 2017, 36 MB and 119 MB. Every
query after that is local — an exam-number lookup is immediate, and a name
search scans all 877,460 rows in a few hundred milliseconds.
The download is kept on the device, so the transfer is paid once per device
rather than once per visit.
That is a reversal of the previous design, which read the file where it lay
over HTTP range requests. See [Considered and not taken](#considered-and-not-taken).
One frontend, one parser, one schema, two datasets.
## Data flow
Each stage is a directory; `data/` and `_site/` are the stores they hand work
through. `assembler/` sequences everything from the parser onwards.
```
▲ crawler/ (Go — manual refresh only, never part of the build)
data/<id>/*.xls(x)
│
▼ parser/ (Go, one binary, one config per dataset)
.build/public/db/<id>.sqlite3
│
▼ assembler/ — row count and size must match datasets.json
.build/public/db/<id>.sqlite3 (uncompressed: the host gzips it on the
│ wire, so a .gz would decompress twice)
▼ assembler/ → npm run build (SvelteKit static, assets = .build/public)
web/dist/
│
▼ assembler/ — one index.html per dataset; every database must be present
_site/ → GitHub Pages
│
▼ browser
download gate → whole file → sql.js in memory → queries never leave the tab
↕
Cache Storage, keyed by ETag — a second visit skips the gate
```
## The download, and keeping it
`web/src/lib/database.svelte.js` owns the transfer; `web/src/lib/db-cache.js`
owns what survives it. Opening a dataset page:
1. A conditional request asks the server for the database's current ETag.
2. If a copy of exactly that version is in Cache Storage, it opens without
asking — consent was given once and reuse costs no network.
3. Otherwise the gate states the transfer and the memory cost, and downloads
only when the visitor accepts. The response is stored under
`<url>?v=<etag>`, and every other version of that database is dropped, so a
dataset never occupies the disk twice.
4. If the server cannot be reached at all, any stored version is used. Slightly
old frozen exam results beat an error page, and this is what lets the site
answer offline.
Cache Storage rather than IndexedDB: the thing being stored is an HTTP
response, the browser can stream it to disk instead of holding it as one
buffer, and eviction under storage pressure is the browser's business.
Progress is measured against `dbSizeMb` from the registry, not
`Content-Length`. The server sends the file gzipped, so `Content-Length` counts
compressed bytes while the stream yields decompressed ones; the compressed
figure is reported separately as the transfer size.
## The dataset id
One identifier ties the whole pipeline together:
```
data/2017/ → parser/configs/2017.yml → db/2017.sqlite3 → /thptqg/2017/
```
`datasets.json` at the repository root declares the ids once, with the row count
and artifact size the assembler enforces. It is JSON rather than a module
because the assembler is a Go program and the web app is not, and JSON is the
only format both parse without a dependency.
Presentation — titles, labels, search examples, SQL presets — stays in
`web/src/lib/datasets.js`, keyed by id. That file cross-checks the two: a registry
entry with no content, or content for a dataset that was never built, throws at
module load rather than rendering a page with no title or a link to a database
that does not exist.
| id | Exam | Rows | Source |
| --- | --- | --- | --- |
| `2016` | 2016 | 877,460 | `dtnt.bacninh.edu.vn` |
| `2017` | 2017 | 861,068 | `baotintuc.vn` |
Full source URLs are in [data-pipeline](./data-pipeline.md#sources); the web
footer links to them per dataset.
## Canonical schema
Defined once in `parser/internal/schema/schema.go` — DDL, INSERT, column order and the 16
subject regexes. The two YAML configs carry no SQL at all, only per-dataset
parse rules. Config parsing sets `KnownFields(true)`, so a leftover `schema:`
block fails loudly instead of looking effective while `schema.go` drives the
build.
```sql
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY, -- 2017: 8 digits; 2016: 9 digits or a
-- 2-4 letter cluster code then digits
ho_ten TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL, -- NFD-stripped lowercase, for accent-insensitive search
ngay_sinh TEXT, -- dd/mm/yyyy
ten_cum_thi TEXT, -- 2016 only
gioi_tinh TEXT, -- 2016 only
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
lich_su, dia_ly, gdcd, khxh,
tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung REAL
);
```
One table and no secondary indexes. The primary key is the only one, and it
comes free with the table. Everything else is a scan, which in memory costs a
few hundred milliseconds — an index would buy that back at the price of
megabytes on every visitor's network. `TestDDLIsFrozen` in `parser` holds an
independent copy of this DDL, so changing the shape has to be deliberate.
Every dataset gets all 22 columns; ones it has no data for are NULL, costing
about a byte per row. `khtn`, `khxh` and `gdcd` are empty on 2016;
`ten_cum_thi` and `gioi_tinh` are empty on 2017.
## Routing
URLs are flat, one segment per dataset, and the segment is the id:
```
/thptqg/ hub
/thptqg/2016/
/thptqg/2017/
```
The route is `web/src/routes/[dataset]/`, and its entry generator reads the same
`datasets.json` the assembler does, so the set of pages and the set of databases
cannot drift apart. Unknown paths fall through to the hub.
SvelteKit prerenders one HTML file per route, each with its own `<title>`.
Asset URLs stay absolute (`paths.relative: false`), so the copy of the hub that
serves as `404.html` resolves its assets from any depth. **No SPA 404-fallback
redirect is used** — the usual hack rewrites URLs and would interfere with the
deep links.
## Serving both exam years without branching
No component contains a per-dataset conditional. Two mechanisms do the work:
- **All-NULL columns are hidden.** `score-table.svelte` drops any column where
every row in the result set is NULL, so 2016 rows surface Cụm thi / GT / Đức /
Nhật and 2017 rows surface KHTN / KHXH / GDCD / Nga.
- **Incomplete admission blocks are skipped.** `computeBlocks()` only returns a
block when the student has all three subjects, so one block list covers both
years: GDCD blocks self-exclude on 2016, German and Japanese blocks
self-exclude wherever those languages were not sat.
Anything genuinely per-dataset — title, source, database size, search examples,
SQL presets — lives in `web/src/lib/datasets.js`.
## Exam ID formats
`web/src/lib/query-mode.js` decides whether a query is an exam ID or a name, and
is shared by the dataset page and `search-form.svelte` (they previously held
separate copies and had drifted apart on exactly this rule). `query-mode.test.js`
covers every form in the table below.
| Form | Example | Where |
| --- | --- | --- |
| 8 digits | `49008235` | 2017 — first two digits are the province |
| 9 digits with leading zero | `017006021` | 2016 |
| 2-4 letters then digits | `BAL000001` | 2016 — exam cluster code |
The letter-prefixed form is the majority case for 2016: 624,424 of 877,460
candidates (71.2%); the remaining 253,036 are all 9-digit. Letter prefixes are
upper-cased before lookup, so `bal000001` resolves.
## Score tiers
Six-level ladder in `scoreTier()` (`web/src/lib/admission-blocks.js`), paired with a
symbol so meaning is never colour-only.
| Tier | Range | Vietnamese |
| --- | --- | --- |
| common | ≤ 1 | Điểm liệt |
| uncommon | < 5 | Chưa đạt |
| rare | 5–6.5 | Trung bình |
| epic | 6.5–8 | Khá |
| legendary | 8–9 | Giỏi |
| prismatic | 9–10 | Xuất sắc |
## Admission blocks
Vietnamese universities admit on three-subject combinations (khối thi).
`web/src/lib/admission-blocks.js` lists the blocks computable from this schema
(A00–A11, B00–B08, C00–C20, D01–D15, plus D05/D06 for German and Japanese).
`computeBlocks(student)` returns those where all three scores exist, sorted by
total descending.
## Design decisions
| Concern | Choice | Rationale |
| --- | --- | --- |
| Storage | Static SQLite file, downloaded whole | No backend; the datasets are frozen, and one large transfer is something browsers and CDNs are both good at |
| Download gate | Blocking, no dismiss | The page has no answers before the file arrives, and 31 MB of someone's mobile data should be asked for rather than spent silently |
| Keeping the download | Cache Storage, versioned by ETag | The transfer is paid once per device instead of once per visit; the ETag is what stops a redeploy being answered with last week's data |
| Searching | On submit, never while typing | A name search scans every row, so a keystroke-per-search would run hundreds of full scans to answer one question |
| Compression | None published | The host gzips on the wire, so a `.gz` artifact would only be decompressed twice |
| WASM hosting | Bundled with the app | `sql.js` ships its own build; one less third-party runtime dependency |
| Diacritics search | Pre-computed `ho_ten_ascii` | `LOWER(REPLACE(...))` at query time is far slower over 877,460 rows than a column computed once at build |
| Secondary indexes | None | In memory a full scan costs a few hundred milliseconds; an index costs every visitor megabytes of download. The name index alone was 146 MB |
| Row count in the footer | Read from `datasets.json` | Costs nothing and is the same number the assembler enforces |
| Page size | 4 KiB | SQLite's default, and nothing on the client depends on it any more |
| SQL safety | Leading-keyword allowlist | The copy is the visitor's own, so this guards their session against a typo rather than protecting data |
| 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 |
| Styling | Tailwind, with tier colours as CSS variables | Tier classes are chosen at runtime, which no utility generator can see |
### Considered and not taken
- **Reading the file over HTTP range requests** (`sql.js-httpvfs`), which this
site did until it was measured. Two costs killed it. Reads are serial —
the worker uses synchronous XHR — so a name search that touched 390 pages
waited 17 seconds to move 608 KB, and roughly one request per result row is a
floor no page size removes. And the first visitor after each deploy waited
~26 s for the CDN to fill its cache with a 288 MB object. The download pays
once, up front, visibly.
- **`sqlite-wasm-http`.** Built on the official SQLite WASM and maintained,
but it sizes the file from a HEAD request's `Content-Length` and exposes no
option to override it, so on a host that gzips it would silently use the
compressed size. Same class of problem, less recourse.
- **DuckDB-WASM over Parquet.** Genuinely maintained, async, parallel range
requests. Its binaries are 32–37 MB before the Parquet extension, which is
more than the entire database download for a phone looking up one score.
- **Static pre-generated shards**, one file per exam-number bucket. The most
robust option and the fastest single lookup, but it cannot answer arbitrary
SQL, and a static file cannot stop early the way `LIMIT` does — a common
Vietnamese surname would mean fetching a very large posting list.
## Risks and limitations
- **Memory is the binding constraint.** The database lives in the tab's
WebAssembly memory for as long as the page is open: 142 MB for 2016, 119 MB
for 2017. A low-memory phone may have the tab killed, which is why the gate
states the figure before the download starts.
- **A deploy costs returning visitors the transfer again.** Every rebuild lays
SQLite pages out differently, so the file — and its ETag — changes even when
the data does not. The stored copy is then a stale version and is replaced.
- **The stored copy can be evicted.** Cache Storage is subject to the browser's
own storage pressure, so a device short on disk falls back to downloading.
- **A visitor who will not download cannot use the site.** That is the
deliberate shape of the gate, and it makes the first impression a 31 MB ask.
- **Hosted size.** 526 MB for both datasets against the 1 GB GitHub Pages
limit; a third dataset of this size would not fit.
- **Excel format drift.** A new source file with an unseen header layout needs a
new branch in `parser/internal/ingest/detect2016.go` or a new config.