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.
This commit is contained in:
dev 2026-10-13 10:05:27 +05:30
parent 8a89f6045b
commit d312eaff3a
7 changed files with 405 additions and 16 deletions

51
Cargo.lock generated
View file

@ -329,6 +329,24 @@ dependencies = [
"version_check", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -537,6 +555,15 @@ dependencies = [
"syn 3.0.7", "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]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@ -1368,6 +1395,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@ -1698,6 +1731,22 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.12" version = "0.11.12"
@ -1860,6 +1909,8 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
"cookie",
"cookie_store",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"h2", "h2",

View file

@ -9,7 +9,7 @@ axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2
base64 = "0.23.1" base64 = "0.23.1"
chrono = { version = "0.4.45", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
jsonwebtoken = { version = "11.1.0", features = ["rust_crypto", "use_pem"] } 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 = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.152" serde_json = "1.0.152"
sha2 = "0.11.0" sha2 = "0.11.0"

View file

@ -125,7 +125,7 @@ pub struct TestRow {
pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow>> { pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow>> {
let rows = sqlx::query( 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", 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) .bind(uid)
@ -134,7 +134,14 @@ pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow
let mut out = Vec::new(); let mut out = Vec::new();
for r in &rows { for r in &rows {
let aid: String = r.get("id"); let aid: String = r.get("id");
let (g, right, wrong) = exam::history_counts(pool, &aid).await?; let mut right: i32 = r.try_get("right_count").unwrap_or(0);
let mut wrong: i32 = r.try_get("wrong_count").unwrap_or(0);
if right == 0 && wrong == 0 && r.get::<i32, _>("score") != 0 {
if let Ok((_, r_cnt, w_cnt)) = exam::history_counts(pool, &aid).await {
right = r_cnt;
wrong = w_cnt;
}
}
out.push(TestRow { out.push(TestRow {
attempt_id: aid, attempt_id: aid,
started_at: r.get::<String, _>("started_at").parse().unwrap_or_else(|_| Utc::now()), started_at: r.get::<String, _>("started_at").parse().unwrap_or_else(|_| Utc::now()),
@ -145,7 +152,6 @@ pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow
right, right,
wrong, wrong,
}); });
let _ = g;
} }
Ok(out) Ok(out)
} }

291
src/bin/bench.rs Normal file
View file

@ -0,0 +1,291 @@
// Native Rust benchmarking and load testing tool for WhalePrep.
// 100% Rust, zero Python dependency.
//
// Usage:
// cargo run --release --bin bench -- [BASE_URL] [CONCURRENCY] [REQUESTS]
// Example:
// cargo run --release --bin bench -- https://whaleprep.oiupoyt.space 10 100
// cargo run --release --bin bench -- http://127.0.0.1:8080 20 200
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct Stats {
name: String,
durations_ms: Vec<f64>,
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::<f64>() / 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<dyn std::error::Error>> {
let args: Vec<String> = 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::<serde_json::Value>().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(())
}

View file

@ -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 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 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 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 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 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); 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) .execute(pool)
.await .await
.ok(); .ok();
let attempt_cols: Vec<String> = sqlx::query("PRAGMA table_info(attempts)")
.fetch_all(pool)
.await
.unwrap_or_default()
.iter()
.map(|r| r.get::<String, _>("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") let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions")
.fetch_one(pool) .fetch_one(pool)

View file

@ -186,15 +186,17 @@ pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow:
format!("Mixed paper · {count} questions") 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(?,?,?,?,?,?)") 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(&paper).bind(uid).bind(&title).bind(duration)
.bind(Utc::now().to_rfc3339()).bind(&share) .bind(Utc::now().to_rfc3339()).bind(&share)
.execute(pool).await?; .execute(&mut *tx).await?;
for (i, qid) in ids.iter().enumerate() { for (i, qid) in ids.iter().enumerate() {
sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)") sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)")
.bind(&paper).bind(qid).bind(i as i64) .bind(&paper).bind(qid).bind(i as i64)
.execute(pool).await?; .execute(&mut *tx).await?;
} }
tx.commit().await?;
Ok((paper, share)) Ok((paper, share))
} }
@ -246,24 +248,26 @@ pub async fn start_attempt(pool: &SqlitePool, uid: &str, paper: &str) -> anyhow:
return Ok(id); return Ok(id);
} }
let id = uuid::Uuid::new_v4().to_string(); 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(?,?,?,?)") sqlx::query("INSERT INTO attempts(id,paper_id,user_id,started_at) VALUES(?,?,?,?)")
.bind(&id) .bind(&id)
.bind(paper) .bind(paper)
.bind(uid) .bind(uid)
.bind(Utc::now().to_rfc3339()) .bind(Utc::now().to_rfc3339())
.execute(pool) .execute(&mut *tx)
.await?; .await?;
let qids: Vec<i64> = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord") let qids: Vec<i64> = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord")
.bind(paper) .bind(paper)
.fetch_all(pool) .fetch_all(&mut *tx)
.await?; .await?;
for qid in &qids { for qid in &qids {
sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)") sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)")
.bind(&id) .bind(&id)
.bind(qid) .bind(qid)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
} }
tx.commit().await?;
Ok(id) Ok(id)
} }
@ -289,6 +293,7 @@ pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &Sa
if ok == 0 { if ok == 0 {
anyhow::bail!("Attempt not found"); anyhow::bail!("Attempt not found");
} }
let mut tx = pool.begin().await?;
for a in &body.answers { for a in &body.answers {
sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?") sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?")
.bind(&a.selected) .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(a.time_secs.unwrap_or(0).clamp(0, 7200))
.bind(attempt) .bind(attempt)
.bind(a.question_id) .bind(a.question_id)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
} }
tx.commit().await?;
Ok(()) Ok(())
} }
@ -368,25 +374,31 @@ pub async fn grade(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(Vec<Grad
pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, i32, i64)> { pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, i32, i64)> {
let (rows, score, max, used) = grade(pool, attempt).await?; 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?; 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(Utc::now().to_rfc3339())
.bind(score) .bind(score)
.bind(max) .bind(max)
.bind(used) .bind(used)
.bind(right_count)
.bind(wrong_count)
.bind(attempt) .bind(attempt)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
let now = Utc::now().to_rfc3339();
for r in &rows { for r in &rows {
if !r.selected.trim().is_empty() { 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") 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(&uid)
.bind(r.q.id) .bind(r.q.id)
.bind(&now) .bind(&now)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
} }
} }
tx.commit().await?;
Ok((score, max, used)) Ok((score, max, used))
} }

View file

@ -45,7 +45,16 @@ async fn main() -> anyhow::Result<()> {
.max_connections(8) .max_connections(8)
.connect_with(opts) .connect_with(opts)
.await?; .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?; db::init_db(&pool).await?;
let client_id = std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default(); let client_id = std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default();
let client_secret = std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default(); let client_secret = std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default();