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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
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 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)
|
||||||
|
|
|
||||||
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")
|
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))
|
||||||
}
|
}
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue