Files
tiennm99 4cb0a4f340 feat: download the database instead of reading it over HTTP
Reading the file where it lay never worked well enough. Two costs were
structural rather than bugs: reads are serial, because the worker uses
synchronous XHR, so a name search touching 390 pages waited 17 seconds
to move 608 KB — roughly one request per result row, which no page size
removes — and the first visitor after each deploy waited ~26 seconds for
the CDN to fill its cache with a 288 MB object.

The browser now downloads the whole database once and queries it in
memory with sql.js. A dataset page is gated behind that: the gate states
what it will cost, in transfer and in memory, and offers only the
download, because there is nothing to show without it.

Dropping the structures that existed to make range-request queries
index-driven halved the file. name_word carried one row per word of
every name, about 3.5 million of them, and with the partial score
indexes it was more than half of what every visitor would now download.
Measured on rebuilt databases: 2016 went 288.6 -> 142.5 MB (31 MB
gzipped on the wire), 2017 237.7 -> 119.3 MB, both with row counts and
audits unchanged. Queries on the result: an exam number is immediate, a
name scans all 877,460 rows in about 240 ms.

Alternatives were measured before choosing this. sqlite-wasm-http sizes
files from a HEAD Content-Length with no override, so on a host that
gzips it silently uses the compressed size. DuckDB-WASM ships 32-37 MB
of WebAssembly before its Parquet extension, more than this whole
download. Static pre-generated shards are the most robust option but
cannot answer arbitrary SQL, and cannot stop early the way LIMIT does.

The published name loses its chunk index, the byte budgets and the SQL
consent modal go with the range reads that made them necessary, and the
docs no longer describe a design the site does not use.
2026-08-14 17:43:10 +07:00

162 lines
4.8 KiB
Go

// Package writer handles SQLite output: DDL setup, INSERT OR REPLACE, VACUUM
// and the stats block.
//
// Every dataset writes the same canonical table (internal/schema), so there is
// exactly one insert path. Columns a dataset carries no data for bind NULL.
//
// The stats lines are the operator-facing output of the build, and
// docs/deployment-guide.md points at the per-file row counts for
// troubleshooting — do not reword them casually.
package writer
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/tiennm99/thptqg/parser/internal/schema"
"github.com/tiennm99/thptqg/parser/internal/sqlitedb"
"github.com/tiennm99/thptqg/parser/internal/transform"
)
// OpenDB deletes any existing database at dbPath, recreates it, and executes the
// canonical DDL.
//
// Deleting the file rather than issuing DROP TABLE has a consequence worth
// knowing: a concurrent reader sees the file vanish mid-rebuild rather than a
// transactional swap.
func OpenDB(dbPath string) (*sql.DB, error) {
if _, err := os.Stat(dbPath); err == nil {
if err := os.Remove(dbPath); err != nil {
return nil, fmt.Errorf("remove existing db %s: %w", dbPath, err)
}
}
if parent := filepath.Dir(dbPath); parent != "" && parent != "." {
if err := os.MkdirAll(parent, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", parent, err)
}
}
db, err := sql.Open(sqlitedb.DriverName, dbPath)
if err != nil {
return nil, fmt.Errorf("open db %s: %w", dbPath, err)
}
// 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.
//
// SQLite's own default, set explicitly so the published file does not
// change shape if that default ever does. The browser downloads this file
// whole and queries it in memory, so the page size no longer has to match
// anything on the client.
if _, err := db.Exec("PRAGMA page_size = 4096"); err != nil {
db.Close()
return nil, fmt.Errorf("set page size: %w", err)
}
if _, err := db.Exec(schema.DDL); err != nil {
db.Close()
return nil, fmt.Errorf("execute DDL: %w", err)
}
return db, nil
}
// Inserter wraps the canonical INSERT, prepared once per transaction rather
// than per row.
type Inserter struct{ stmt *sql.Stmt }
// Prepare compiles the canonical INSERT against tx.
func Prepare(tx *sql.Tx) (*Inserter, error) {
stmt, err := tx.Prepare(schema.InsertSQL)
if err != nil {
return nil, fmt.Errorf("prepare insert: %w", err)
}
return &Inserter{stmt: stmt}, nil
}
// Close releases the prepared statement.
func (i *Inserter) Close() error { return i.stmt.Close() }
// Insert binds one parsed row and executes the INSERT.
//
// Parameter order is IdentityFields then ScoreFields. Subjects absent from
// row.Scores — and the two identity columns only the 2016 layouts populate —
// bind NULL.
func (i *Inserter) Insert(row *transform.ParsedRow) error {
args := make([]any, 0, schema.ParamCount)
args = append(args,
row.SoBaoDanh,
row.HoTen,
row.HoTenAscii,
nullableString(row.NgaySinh),
nullableString(row.TenCumThi),
nullableString(row.GioiTinh),
)
for _, field := range schema.ScoreFields {
if v, ok := row.Scores[field]; ok {
args = append(args, v)
} else {
args = append(args, nil)
}
}
if len(args) != schema.ParamCount {
return fmt.Errorf("built %d params, want %d", len(args), schema.ParamCount)
}
if _, err := i.stmt.Exec(args...); err != nil {
return err
}
return nil
}
func nullableString(s *string) any {
if s == nil {
return nil
}
return *s
}
// Stats carries the counters the build loop accumulates.
type Stats struct {
SourceRows uint64
Skipped uint64
Errors uint64
}
// Finish compacts the database and prints the stats block.
//
// VACUUM must run AFTER the transaction commits — SQLite refuses it inside one.
func Finish(db *sql.DB, dbPath string, st Stats) error {
if _, err := db.Exec("VACUUM"); err != nil {
return fmt.Errorf("vacuum: %w", err)
}
var dbCount int64
if err := db.QueryRow("SELECT COUNT(*) FROM student").Scan(&dbCount); err != nil {
return fmt.Errorf("count rows: %w", err)
}
insertable := st.SourceRows - st.Skipped
fmt.Println()
fmt.Printf("Source data rows (post-header): %d\n", st.SourceRows)
fmt.Printf(" skipped (empty/invalid): %d\n", st.Skipped)
fmt.Printf(" insertable: %d\n", insertable)
fmt.Printf(" insert errors: %d\n", st.Errors)
fmt.Printf("DB rows (distinct SBD): %d\n", dbCount)
if st.Errors == 0 {
gap := int64(insertable) - dbCount
if gap == 0 {
fmt.Println("Audit: OK — every source row made it in.")
} else {
fmt.Printf("Audit: %d row(s) collapsed (duplicate SBDs overwriting).\n", gap)
}
}
var size int64
if fi, err := os.Stat(dbPath); err == nil {
size = fi.Size()
}
fmt.Printf("Size: %.1f MB\n", float64(size)/1024.0/1024.0)
return nil
}