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:
parent
8a89f6045b
commit
d312eaff3a
7 changed files with 405 additions and 16 deletions
51
Cargo.lock
generated
51
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ pub struct TestRow {
|
|||
|
||||
pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow>> {
|
||||
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<Vec<TestRow
|
|||
let mut out = Vec::new();
|
||||
for r in &rows {
|
||||
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 {
|
||||
attempt_id: aid,
|
||||
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,
|
||||
wrong,
|
||||
});
|
||||
let _ = g;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
291
src/bin/bench.rs
Normal file
291
src/bin/bench.rs
Normal 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(())
|
||||
}
|
||||
22
src/db.rs
22
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<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")
|
||||
.fetch_one(pool)
|
||||
|
|
|
|||
32
src/exam.rs
32
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<i64> = 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<Grad
|
|||
|
||||
pub async fn finalize(pool: &SqlitePool, attempt: &str) -> 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))
|
||||
}
|
||||
11
src/main.rs
11
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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue