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.
83 lines
No EOL
3.2 KiB
Python
Executable file
83 lines
No EOL
3.2 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Write-path check: many concurrent students autosaving and submitting at once.
|
|
|
|
SQLite serialises writers, so this is where the ceiling actually lives.
|
|
scripts/bench_write.py <base-url> <students> <questions>
|
|
"""
|
|
import http.cookiejar
|
|
import sqlite3
|
|
import statistics
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8171"
|
|
STUDENTS = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
|
QUESTIONS = int(sys.argv[3]) if len(sys.argv) > 3 else 12
|
|
DB = sys.argv[4] if len(sys.argv) > 4 else "/tmp/bench.db"
|
|
|
|
|
|
def student_session(i):
|
|
jar = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
body = urllib.parse.urlencode(
|
|
{"name": f"Load{i}", "email": f"load{i}@example.com", "next": "/build"}
|
|
).encode()
|
|
opener.open(urllib.request.Request(f"{BASE}/student", data=body, method="POST")).read()
|
|
return opener
|
|
|
|
|
|
def run(i):
|
|
opener = student_session(i)
|
|
form = urllib.parse.urlencode(
|
|
{"chapters": "1,2,3,4,5,6,7,8,9", "count": QUESTIONS, "duration": 24, "unseen_only": "on"}
|
|
).encode()
|
|
r = opener.open(urllib.request.Request(f"{BASE}/build", data=form, method="POST"))
|
|
paper = r.geturl().rsplit("/", 1)[-1].split("?")[0]
|
|
|
|
r = opener.open(urllib.request.Request(f"{BASE}/attempt/{paper}", data=b"", method="POST"))
|
|
attempt = r.geturl().split("run=")[-1]
|
|
|
|
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
|
|
ids = [x[0] for x in con.execute(
|
|
"SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord", (paper,)
|
|
)]
|
|
con.close()
|
|
|
|
# Autosave each answer, which is what the exam screen does while a student works.
|
|
saves = []
|
|
for q in ids:
|
|
payload = b'{"answers":[{"question_id":%d,"selected":"A","marked_review":false,"time_secs":30}]}' % q
|
|
t0 = time.perf_counter()
|
|
opener.open(
|
|
urllib.request.Request(
|
|
f"{BASE}/attempt/{attempt}/answer", data=payload,
|
|
headers={"content-type": "application/json"}, method="POST",
|
|
)
|
|
).read()
|
|
saves.append((time.perf_counter() - t0) * 1000)
|
|
|
|
t0 = time.perf_counter()
|
|
opener.open(urllib.request.Request(f"{BASE}/attempt/{attempt}/submit", data=b"", method="POST")).read()
|
|
submit_ms = (time.perf_counter() - t0) * 1000
|
|
return saves, submit_ms
|
|
|
|
|
|
t0 = time.perf_counter()
|
|
with ThreadPoolExecutor(max_workers=STUDENTS) as pool:
|
|
results = list(pool.map(run, range(STUDENTS)))
|
|
elapsed = time.perf_counter() - t0
|
|
|
|
saves = [ms for r, _ in results for ms in r]
|
|
submits = [ms for _, ms in results]
|
|
saves.sort()
|
|
total_saves = len(saves)
|
|
|
|
print(f"=== {STUDENTS} concurrent students x {QUESTIONS} questions ===")
|
|
print(f" wall clock {elapsed:.2f}s")
|
|
print(f" autosaves {total_saves} in {elapsed:.2f}s = {total_saves/elapsed:,.0f} writes/s")
|
|
print(f" autosave latency p50={statistics.median(saves):.1f}ms "
|
|
f"p95={saves[int(len(saves)*.95)]:.1f}ms max={saves[-1]:.1f}ms")
|
|
print(f" submit latency p50={statistics.median(submits):.1f}ms max={max(submits):.1f}ms") |