whaleprep/scripts/bench_scale.py
dev c6269ad3dd Honour RUST_LOG, add load and scale benchmarks
The subscriber was pinned to DEBUG, so RUST_LOG=warn was ignored and sqlx
logged every statement to stderr in production. Now an EnvFilter defaults to
info with sqlx at warn; RUST_LOG=debug restores the query log.

Three benchmarks, so the hosting numbers come from measurement rather than
guesswork: scripts/bench.py for latency and throughput, bench_write.py for the
autosave and submit path under concurrent students, and bench_scale.py which
builds a database at the real bank's size (10,905 questions, 2,000 students,
12,000 papers, 900k answers) to report disk cost and query timings.
2026-10-11 21:08:35 +05:30

164 lines
7.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""Estimates disk and index cost at the real bank's scale.
Builds a throwaway database holding 10,905 questions plus simulated student
activity, then reports the size on disk and the query timings that decide
whether SQLite is still the right store.
scripts/bench_scale.py [db-path]
"""
import os
import random
import sqlite3
import sys
import time
DB = sys.argv[1] if len(sys.argv) > 1 else "/tmp/scale.db"
QUESTIONS = 10905
SUBTOPICS = 349
STUDENTS = 2000
PAPERS_PER_STUDENT = 6
QUESTIONS_PER_PAPER = 75
for suffix in ("", "-wal", "-shm"):
try:
os.remove(DB + suffix)
except FileNotFoundError:
pass
con = sqlite3.connect(DB)
con.executescript(
"""
PRAGMA journal_mode=WAL;
CREATE TABLE users(id TEXT PRIMARY KEY, name TEXT, email TEXT UNIQUE, created_at TEXT);
CREATE TABLE chapters(id INTEGER PRIMARY KEY, subject TEXT, name TEXT);
CREATE TABLE subtopics(id INTEGER PRIMARY KEY, chapter_id INTEGER, name TEXT);
CREATE TABLE questions(id INTEGER PRIMARY KEY, subject TEXT, chapter_id INTEGER,
subtopic_id INTEGER, qtype TEXT, stem TEXT, opt_a TEXT, opt_b TEXT, opt_c TEXT,
opt_d TEXT, answer TEXT, difficulty TEXT, year INTEGER, shift TEXT, solution TEXT);
CREATE TABLE papers(id TEXT PRIMARY KEY, user_id TEXT, title TEXT,
duration_min INTEGER, created_at TEXT, share_token TEXT);
CREATE TABLE paper_questions(paper_id TEXT, question_id INTEGER, ord INTEGER,
PRIMARY KEY(paper_id, question_id));
CREATE TABLE attempts(id TEXT PRIMARY KEY, paper_id TEXT, user_id TEXT,
started_at TEXT, submitted_at TEXT, score INTEGER, max_score INTEGER, used_secs INTEGER);
CREATE TABLE answers(attempt_id TEXT, question_id INTEGER, selected TEXT,
marked_review INTEGER, time_secs INTEGER, PRIMARY KEY(attempt_id, question_id));
CREATE TABLE sessions(token TEXT PRIMARY KEY, user_id TEXT, created_at TEXT);
CREATE TABLE seen_questions(user_id TEXT, question_id INTEGER,
PRIMARY KEY(user_id, question_id));
CREATE TABLE mistake_tags(attempt_id TEXT, question_id INTEGER, tag TEXT,
PRIMARY KEY(attempt_id, question_id, tag));
"""
)
rng = random.Random(7)
stem = ("A particle of mass 2 kg moves along the x axis under a restoring force "
"F = -kx. If k = 200 N/m the time period of small oscillations is:")
opt = "A 0.1 s" * 4
t0 = time.perf_counter()
con.executemany("INSERT INTO chapters VALUES(?,?,?)",
[(i, ["Physics", "Chemistry", "Maths"][i % 3], f"Chapter {i}") for i in range(1, 28)])
con.executemany("INSERT INTO subtopics VALUES(?,?,?)",
[(i, i % 27 + 1, f"Subtopic {i}") for i in range(1, SUBTOPICS + 1)])
con.executemany(
"INSERT INTO questions VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[(i, ["Physics", "Chemistry", "Maths"][i % 3], i % 27 + 1, i % SUBTOPICS + 1,
"MCQ" if i % 4 else "NUM", stem, opt, opt, opt, opt, "ABCD"[i % 4],
["Easy", "Medium", "Hard"][i % 3], 2020 + i % 7, f"S{i % 4 + 1}", stem)
for i in range(1, QUESTIONS + 1)])
con.commit()
print(f"seeded {QUESTIONS:,} questions in {time.perf_counter()-t0:.1f}s")
# Indexes the app would need for subtopic filtering and per-user lookups.
t0 = time.perf_counter()
con.executescript(
"""
CREATE INDEX q_chapter ON questions(chapter_id);
CREATE INDEX q_subtopic ON questions(subtopic_id);
CREATE INDEX pq_question ON paper_questions(question_id);
CREATE INDEX a_paper ON attempts(paper_id);
CREATE INDEX a_user ON attempts(user_id, submitted_at);
CREATE INDEX ans_question ON answers(question_id);
CREATE INDEX seen_q ON seen_questions(question_id);
"""
)
con.commit()
print(f"indexes built in {time.perf_counter()-t0:.1f}s")
t0 = time.perf_counter()
papers = STUDENTS * PAPERS_PER_STUDENT
for s in range(STUDENTS):
uid = f"user-{s}"
con.execute("INSERT INTO users VALUES(?,?,?,?)", (uid, f"Student {s}", f"s{s}@x.com", "2026-01-01"))
for _ in range(PAPERS_PER_STUDENT):
pid, aid = f"paper-{s}-{_}", f"att-{s}-{_}"
con.execute("INSERT INTO papers VALUES(?,?,?,?,?,?)",
(pid, uid, "Paper", 180, "2026-01-01", f"tok{pid}"))
picks = rng.sample(range(1, QUESTIONS + 1), QUESTIONS_PER_PAPER)
con.executemany("INSERT INTO paper_questions VALUES(?,?,?)",
[(pid, q, n) for n, q in enumerate(picks)])
con.execute("INSERT INTO attempts VALUES(?,?,?,?,?,?,?,?)",
(aid, pid, uid, "2026-01-01", "2026-01-01", 180, 300, 5400))
con.executemany("INSERT INTO answers VALUES(?,?,?,?,?)",
[(aid, q, "ABCD"[q % 4], q % 5 == 0, 40 + q % 60) for q in picks])
# A question already answered on an earlier paper is not "new" for this student.
con.executemany("INSERT OR IGNORE INTO seen_questions VALUES(?,?)",
[(uid, q) for q in picks])
con.commit()
elapsed = time.perf_counter() - t0
print(f"seeded {STUDENTS:,} students / {papers:,} papers in {elapsed:.1f}s")
con.execute("VACUUM")
con.commit()
con.close()
def mb(path):
total = 0
for suffix in ("", "-wal", "-shm"):
try:
total += os.path.getsize(path + suffix)
except FileNotFoundError:
pass
return total / 1024 / 1024
db_mb = mb(DB)
print(f"\n=== {DB} ===")
print(f" questions {QUESTIONS:>9,}")
print(f" students {STUDENTS:>9,}")
print(f" papers {papers:>9,}")
print(f" answer rows {papers * QUESTIONS_PER_PAPER:>9,}")
print(f" on disk {db_mb:>9.1f} MB")
print(f" per paper {db_mb * 1024 / papers:.1f} KB")
print(f" per student {db_mb * 1024 / STUDENTS:.1f} KB")
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
print("\nquery timings at this size")
queries = {
"paper builder (subtopic filter)":
"SELECT q.id FROM questions q WHERE q.subtopic_id IN (?, ?) AND q.chapter_id IN (?, ?) "
"AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?) LIMIT 75",
"seen-question exclusion (450 rows)":
"SELECT q.id FROM questions q WHERE q.id NOT IN "
"(SELECT question_id FROM seen_questions WHERE user_id=?)",
"dashboard topic rollup":
"SELECT c.name, s.name, COUNT(*) FROM answers a JOIN attempts at ON at.id=a.attempt_id "
"JOIN questions q ON q.id=a.question_id JOIN chapters c ON c.id=q.chapter_id "
"JOIN subtopics s ON s.id=q.subtopic_id WHERE at.user_id=? GROUP BY c.name, s.name",
"history list":
"SELECT a.id, a.score FROM attempts a WHERE a.user_id=? ORDER BY a.started_at DESC",
"mistake-tag rollup":
"SELECT tag, COUNT(*) FROM mistake_tags WHERE attempt_id IN "
"(SELECT id FROM attempts WHERE user_id=?) GROUP BY tag",
}
uid = "user-3"
topics = ",".join(str(i) for i in range(1, 31))
chapters = ",".join(str(i) for i in range(1, 10))
for label, q in queries.items():
# Bind exactly what each statement declares, in order.
n_list = q.count("?") - 1
params = [int(i) for i in topics.split(",")][:n_list] + [uid]
t0 = time.perf_counter()
rows = con.execute(q, tuple(params)).fetchall()
print(f" {label:<34} {(time.perf_counter()-t0)*1000:7.1f} ms ({len(rows)} rows)")