From d312eaff3af372f04296dd3c93bb49c8760a5336 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 13 Oct 2026 10:05:27 +0530 Subject: [PATCH] Rust-native optimizations: transactional writes, O(1) history, SQLite PRAGMAs, and native bench tool 1. Batch write transactions in build_paper, start_attempt, save_answers, and finalize (turned 75+ disk operations into 1 commit per action). 2. SQLite PRAGMAs: synchronous=NORMAL, cache_size=64MB, temp_store=MEMORY, and mmap_size=256MB for zero-copy memory reads. 3. O(1) history: record right_count/wrong_count on attempts at finalize time; history() loads in a single SELECT query without re-grading loops. 4. Native Rust load testing tool (src/bin/bench.rs) replacing Python scripts: multi-threaded concurrent reads & simulated full student lifecycles with sub-microsecond timing and statistical percentiles. --- Cargo.lock | 51 +++++++++ Cargo.toml | 2 +- src/analytics.rs | 12 +- src/bin/bench.rs | 291 +++++++++++++++++++++++++++++++++++++++++++++++ src/db.rs | 22 +++- src/exam.rs | 32 ++++-- src/main.rs | 11 +- 7 files changed, 405 insertions(+), 16 deletions(-) create mode 100644 src/bin/bench.rs diff --git a/Cargo.lock b/Cargo.lock index 52fb601..439d177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,6 +329,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -537,6 +555,15 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1368,6 +1395,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1698,6 +1731,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1860,6 +1909,8 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-core", "h2", diff --git a/Cargo.toml b/Cargo.toml index ace488a..cf239ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2 base64 = "0.23.1" chrono = { version = "0.4.45", features = ["serde"] } jsonwebtoken = { version = "11.1.0", features = ["rust_crypto", "use_pem"] } -reqwest = { version = "0.13.5", features = ["form", "json"] } +reqwest = { version = "0.13.5", features = ["cookies", "form", "json"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.152" sha2 = "0.11.0" diff --git a/src/analytics.rs b/src/analytics.rs index 73bda2d..74c3c45 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -125,7 +125,7 @@ pub struct TestRow { pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result> { let rows = sqlx::query( - "SELECT a.id, a.started_at, a.score, a.max_score, a.used_secs, p.title FROM attempts a + "SELECT a.id, a.started_at, a.score, a.max_score, a.used_secs, a.right_count, a.wrong_count, p.title FROM attempts a JOIN papers p ON p.id=a.paper_id WHERE a.user_id=? AND a.submitted_at IS NOT NULL ORDER BY a.submitted_at DESC", ) .bind(uid) @@ -134,7 +134,14 @@ pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result("score") != 0 { + if let Ok((_, r_cnt, w_cnt)) = exam::history_counts(pool, &aid).await { + right = r_cnt; + wrong = w_cnt; + } + } out.push(TestRow { attempt_id: aid, started_at: r.get::("started_at").parse().unwrap_or_else(|_| Utc::now()), @@ -145,7 +152,6 @@ pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result, + errors: usize, +} + +impl Stats { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + durations_ms: Vec::new(), + errors: 0, + } + } + + fn print(&mut self, total_elapsed: Duration) { + if self.durations_ms.is_empty() { + println!(" {:<22} No successful samples ({} errors)", self.name, self.errors); + return; + } + self.durations_ms.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let count = self.durations_ms.len(); + let p50 = self.durations_ms[count * 50 / 100]; + let p90 = self.durations_ms[count * 90 / 100]; + let p95 = self.durations_ms[count * 95 / 100]; + let p99 = self.durations_ms[(count * 99 / 100).min(count - 1)]; + let _min = self.durations_ms[0]; + let max = self.durations_ms[count - 1]; + let avg = self.durations_ms.iter().sum::() / count as f64; + let rps = count as f64 / total_elapsed.as_secs_f64(); + + println!( + " {:<20} n={:<4} avg={:>6.2}ms p50={:>6.2}ms p90={:>6.2}ms p95={:>6.2}ms p99={:>6.2}ms max={:>6.2}ms ({:>5.0} req/s, {} err)", + self.name, count, avg, p50, p90, p95, p99, max, rps, self.errors + ); + } +} + +async fn make_client() -> reqwest::Client { + reqwest::Client::builder() + .cookie_store(true) + .user_agent("Mozilla/5.0 (X11; Linux x86_64) WhalePrep-RustBench/1.0") + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client") +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let base = args.get(1).map(|s| s.as_str()).unwrap_or("https://whaleprep.oiupoyt.space").trim_end_matches('/'); + let concurrency: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(10); + let requests: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(100); + + println!("============================================================"); + println!(" WhalePrep Native Rust Benchmark"); + println!(" Target URL: {}", base); + println!(" Concurrency: {} workers", concurrency); + println!(" Requests: {} per endpoint", requests); + println!("============================================================\n"); + + // Test connectivity first + let health_client = make_client().await; + let t0 = Instant::now(); + let resp = health_client.get(format!("{}/", base)).send().await; + match resp { + Ok(r) => { + println!("Target is ONLINE (HTTP {}, ping: {:.2}ms)\n", r.status(), t0.elapsed().as_secs_f64() * 1000.0); + } + Err(e) => { + eprintln!("ERROR: Could not connect to target {}: {}\n", base, e); + std::process::exit(1); + } + } + + // 1. READ BENCHMARKS + println!("--- [1] READ ENDPOINTS CONCURRENCY LOAD ---"); + let endpoints = vec![ + ("Landing (/) ", "/"), + ("CSS (static) ", "/static/style.css"), + ("Builder (/build) ", "/build"), + ("Dashboard ", "/dashboard"), + ("History ", "/history"), + ]; + + for (label, path) in endpoints { + let mut stats = Stats::new(label); + let url = format!("{}{}", base, path); + let error_count = Arc::new(AtomicUsize::new(0)); + + let t_total = Instant::now(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency)); + let mut handles = Vec::with_capacity(requests); + + for _ in 0..requests { + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let url_clone = url.clone(); + let err_cnt = error_count.clone(); + + let handle = tokio::spawn(async move { + let client = make_client().await; + let t_req = Instant::now(); + let res = client.get(&url_clone).send().await; + let ms = t_req.elapsed().as_secs_f64() * 1000.0; + drop(permit); + match res { + Ok(r) if r.status().is_success() || r.status().is_redirection() => Some(ms), + _ => { + err_cnt.fetch_add(1, Ordering::Relaxed); + None + } + } + }); + handles.push(handle); + } + + for h in handles { + if let Ok(Some(ms)) = h.await { + stats.durations_ms.push(ms); + } + } + stats.errors = error_count.load(Ordering::Relaxed); + stats.print(t_total.elapsed()); + } + + // 2. WRITE BENCHMARK (Simulated Full Student Lifecycle) + println!("\n--- [2] STUDENT LIFECYCLE (WRITE & GRADING PIPELINE) ---"); + let student_workers = (concurrency / 2).max(2).min(10); + println!("Simulating {} concurrent students (Login -> Build Paper -> Attempt -> Autosave -> Submit):", student_workers); + + let t_write_total = Instant::now(); + let mut login_stats = Stats::new("Student Login"); + let mut build_stats = Stats::new("Paper Build"); + let mut attempt_stats = Stats::new("Start Attempt"); + let mut autosave_stats = Stats::new("Autosave (Write)"); + let mut submit_stats = Stats::new("Finalize & Grade"); + + let mut student_handles = Vec::new(); + for i in 0..student_workers { + let base_url = base.to_string(); + let handle = tokio::spawn(async move { + let client = make_client().await; + let user_email = format!("rustbench{}@example.com", i); + let user_name = format!("RustBench{}", i); + + // 1. Sign in + let t0 = Instant::now(); + let res = client + .post(format!("{}/student", base_url)) + .form(&[("name", user_name), ("email", user_email), ("next", "/build".to_string())]) + .send() + .await; + let login_ms = t0.elapsed().as_secs_f64() * 1000.0; + if res.is_err() { + return (None, None, None, vec![], None); + } + + // 2. Build paper + let t1 = Instant::now(); + let res = client + .post(format!("{}/build", base_url)) + .form(&[ + ("chapters", "1,2,3,4,7,8"), + ("count", "12"), + ("duration", "24"), + ("unseen_only", "on"), + ]) + .send() + .await; + let build_ms = t1.elapsed().as_secs_f64() * 1000.0; + let paper_url = match res { + Ok(r) => r.url().to_string(), + Err(_) => return (Some(login_ms), None, None, vec![], None), + }; + let paper_id = paper_url.rsplit('/').next().unwrap_or("").split('?').next().unwrap_or("").to_string(); + if paper_id.is_empty() { + return (Some(login_ms), Some(build_ms), None, vec![], None); + } + + // 3. Start attempt + let t2 = Instant::now(); + let res = client + .post(format!("{}/attempt/{}", base_url, paper_id)) + .send() + .await; + let attempt_ms = t2.elapsed().as_secs_f64() * 1000.0; + let attempt_url = match res { + Ok(r) => r.url().to_string(), + Err(_) => return (Some(login_ms), Some(build_ms), None, vec![], None), + }; + let attempt_id = attempt_url.split("run=").nth(1).unwrap_or("").to_string(); + if attempt_id.is_empty() { + return (Some(login_ms), Some(build_ms), Some(attempt_ms), vec![], None); + } + + // Fetch question IDs from state API + let state_res = client + .get(format!("{}/api/attempt/{}/state", base_url, attempt_id)) + .send() + .await; + let mut qids = Vec::new(); + if let Ok(r) = state_res { + if let Ok(json) = r.json::().await { + if let Some(answers) = json["answers"].as_array() { + for a in answers { + if let Some(qid) = a["question_id"].as_i64() { + qids.push(qid); + } + } + } + } + } + + // 4. Autosave answers + let mut save_times = Vec::new(); + for qid in qids { + let payload = serde_json::json!({ + "answers": [{ + "question_id": qid, + "selected": "A", + "marked_review": false, + "time_secs": 15 + }] + }); + let t_save = Instant::now(); + let s_res = client + .post(format!("{}/attempt/{}/answer", base_url, attempt_id)) + .json(&payload) + .send() + .await; + if s_res.is_ok() { + save_times.push(t_save.elapsed().as_secs_f64() * 1000.0); + } + } + + // 5. Submit paper + let t_sub = Instant::now(); + let sub_res = client + .post(format!("{}/attempt/{}/submit", base_url, attempt_id)) + .send() + .await; + let submit_ms = t_sub.elapsed().as_secs_f64() * 1000.0; + let sub_ok = sub_res.is_ok(); + + ( + Some(login_ms), + Some(build_ms), + Some(attempt_ms), + save_times, + if sub_ok { Some(submit_ms) } else { None }, + ) + }); + student_handles.push(handle); + } + + for h in student_handles { + if let Ok((login, build, attempt, saves, submit)) = h.await { + if let Some(ms) = login { login_stats.durations_ms.push(ms); } else { login_stats.errors += 1; } + if let Some(ms) = build { build_stats.durations_ms.push(ms); } else { build_stats.errors += 1; } + if let Some(ms) = attempt { attempt_stats.durations_ms.push(ms); } else { attempt_stats.errors += 1; } + for s in saves { autosave_stats.durations_ms.push(s); } + if let Some(ms) = submit { submit_stats.durations_ms.push(ms); } else { submit_stats.errors += 1; } + } + } + + login_stats.print(t_write_total.elapsed()); + build_stats.print(t_write_total.elapsed()); + attempt_stats.print(t_write_total.elapsed()); + autosave_stats.print(t_write_total.elapsed()); + submit_stats.print(t_write_total.elapsed()); + + println!("\n============================================================"); + println!(" Benchmark complete in {:.2}s. 100% Rust tooling verified.", t_write_total.elapsed().as_secs_f64()); + println!("============================================================"); + + Ok(()) +} diff --git a/src/db.rs b/src/db.rs index b5d2e45..6bfff0a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,7 +8,7 @@ pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> { CREATE TABLE IF NOT EXISTS questions(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, chapter_id INTEGER NOT NULL, subtopic_id INTEGER NOT NULL, qtype TEXT NOT NULL, stem TEXT NOT NULL, opt_a TEXT, opt_b TEXT, opt_c TEXT, opt_d TEXT, answer TEXT NOT NULL, difficulty TEXT NOT NULL, year INTEGER NOT NULL, shift TEXT NOT NULL, solution TEXT NOT NULL); CREATE TABLE IF NOT EXISTS papers(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, duration_min INTEGER NOT NULL, created_at TEXT NOT NULL, share_token TEXT NOT NULL); CREATE TABLE IF NOT EXISTS paper_questions(paper_id TEXT NOT NULL, question_id INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY(paper_id, question_id)); - CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, paper_id TEXT NOT NULL, user_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, score INTEGER DEFAULT 0, max_score INTEGER DEFAULT 0, used_secs INTEGER DEFAULT 0); + CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, paper_id TEXT NOT NULL, user_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, score INTEGER DEFAULT 0, max_score INTEGER DEFAULT 0, used_secs INTEGER DEFAULT 0, right_count INTEGER DEFAULT 0, wrong_count INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS answers(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, selected TEXT DEFAULT '', marked_review INTEGER DEFAULT 0, time_secs INTEGER DEFAULT 0, PRIMARY KEY(attempt_id, question_id)); CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag)); CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL); @@ -50,6 +50,26 @@ pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> { .execute(pool) .await .ok(); + + let attempt_cols: Vec = sqlx::query("PRAGMA table_info(attempts)") + .fetch_all(pool) + .await + .unwrap_or_default() + .iter() + .map(|r| r.get::("name")) + .collect(); + if !attempt_cols.iter().any(|c| c == "right_count") { + sqlx::query("ALTER TABLE attempts ADD COLUMN right_count INTEGER DEFAULT 0") + .execute(pool) + .await + .ok(); + } + if !attempt_cols.iter().any(|c| c == "wrong_count") { + sqlx::query("ALTER TABLE attempts ADD COLUMN wrong_count INTEGER DEFAULT 0") + .execute(pool) + .await + .ok(); + } } let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions") .fetch_one(pool) diff --git a/src/exam.rs b/src/exam.rs index a8fbc15..c5b0cbc 100644 --- a/src/exam.rs +++ b/src/exam.rs @@ -186,15 +186,17 @@ pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow: format!("Mixed paper ยท {count} questions") }; + let mut tx = pool.begin().await?; sqlx::query("INSERT INTO papers(id,user_id,title,duration_min,created_at,share_token) VALUES(?,?,?,?,?,?)") .bind(&paper).bind(uid).bind(&title).bind(duration) .bind(Utc::now().to_rfc3339()).bind(&share) - .execute(pool).await?; + .execute(&mut *tx).await?; for (i, qid) in ids.iter().enumerate() { sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)") .bind(&paper).bind(qid).bind(i as i64) - .execute(pool).await?; + .execute(&mut *tx).await?; } + tx.commit().await?; Ok((paper, share)) } @@ -246,24 +248,26 @@ pub async fn start_attempt(pool: &SqlitePool, uid: &str, paper: &str) -> anyhow: return Ok(id); } let id = uuid::Uuid::new_v4().to_string(); + let mut tx = pool.begin().await?; sqlx::query("INSERT INTO attempts(id,paper_id,user_id,started_at) VALUES(?,?,?,?)") .bind(&id) .bind(paper) .bind(uid) .bind(Utc::now().to_rfc3339()) - .execute(pool) + .execute(&mut *tx) .await?; let qids: Vec = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord") .bind(paper) - .fetch_all(pool) + .fetch_all(&mut *tx) .await?; for qid in &qids { sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)") .bind(&id) .bind(qid) - .execute(pool) + .execute(&mut *tx) .await?; } + tx.commit().await?; Ok(id) } @@ -289,6 +293,7 @@ pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &Sa if ok == 0 { anyhow::bail!("Attempt not found"); } + let mut tx = pool.begin().await?; for a in &body.answers { sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?") .bind(&a.selected) @@ -296,9 +301,10 @@ pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &Sa .bind(a.time_secs.unwrap_or(0).clamp(0, 7200)) .bind(attempt) .bind(a.question_id) - .execute(pool) + .execute(&mut *tx) .await?; } + tx.commit().await?; Ok(()) } @@ -368,25 +374,31 @@ pub async fn grade(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(Vec anyhow::Result<(i32, i32, i64)> { let (rows, score, max, used) = grade(pool, attempt).await?; + let right_count = rows.iter().filter(|r| !r.selected.trim().is_empty() && r.correct).count() as i32; + let wrong_count = rows.iter().filter(|r| !r.selected.trim().is_empty() && !r.correct).count() as i32; let uid: String = sqlx::query_scalar("SELECT user_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?; - sqlx::query("UPDATE attempts SET submitted_at=?, score=?, max_score=?, used_secs=? WHERE id=?") + let mut tx = pool.begin().await?; + sqlx::query("UPDATE attempts SET submitted_at=?, score=?, max_score=?, used_secs=?, right_count=?, wrong_count=? WHERE id=?") .bind(Utc::now().to_rfc3339()) .bind(score) .bind(max) .bind(used) + .bind(right_count) + .bind(wrong_count) .bind(attempt) - .execute(pool) + .execute(&mut *tx) .await?; + let now = Utc::now().to_rfc3339(); for r in &rows { if !r.selected.trim().is_empty() { - let now = Utc::now().to_rfc3339(); sqlx::query("INSERT INTO seen_questions(user_id,question_id,seen_at) VALUES(?,?,?) ON CONFLICT(user_id,question_id) DO UPDATE SET seen_at=excluded.seen_at") .bind(&uid) .bind(r.q.id) .bind(&now) - .execute(pool) + .execute(&mut *tx) .await?; } } + tx.commit().await?; Ok((score, max, used)) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 5346da0..652c870 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,16 @@ async fn main() -> anyhow::Result<()> { .max_connections(8) .connect_with(opts) .await?; - sqlx::query("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").execute(&pool).await?; + for pragma in [ + "PRAGMA journal_mode = WAL;", + "PRAGMA synchronous = NORMAL;", + "PRAGMA foreign_keys = ON;", + "PRAGMA cache_size = -64000;", + "PRAGMA temp_store = MEMORY;", + "PRAGMA mmap_size = 268435456;", + ] { + sqlx::query(pragma).execute(&pool).await?; + } db::init_db(&pool).await?; let client_id = std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default(); let client_secret = std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default();