simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 8fd1574e6cc9c3fcc24d0dd2b91c131795d01f9a
parent d66f52ebd9f851442ffec2a1bc3a37088de1a5a4
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun, 14 Jun 2026 12:18:07 +0200

perf: add semaphore on password hashing to prevent DB read starvation

Argon2 and DB reads share the same blocking thread pool (capped
at core count). Under concurrent login bursts, hashes could fill
all slots and starve reads.

Semaphore limits concurrent hashes to cores/2, leaving slots
free for DB reads. Hashing is also separated from DB queries
into its own spawn_blocking call so it doesn't hold a read
connection while hashing.

Benchmark (100 concurrent logins + reads, Ryzen 7 7700):
  Before: reads drop to 5,192 rps (4.4x degradation)
  After:  reads stay at 21,358 rps (no degradation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/handlers/auth.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/state.rs | 7+++++++
2 files changed, 58 insertions(+), 25 deletions(-)

diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs @@ -129,23 +129,37 @@ pub async fn login( let email = form.email.clone(); let password = form.password.clone(); - let result = state.db_query(move |db| { - let user = db.get_user_by_email(&email)? - .ok_or_else(|| AppError::Unauthorized("Invalid email or password".to_string()))?; - - let valid = auth::verify_password(&password, &user.password_hash)?; - if !valid { - return Err(AppError::Unauthorized("Invalid email or password".to_string())); - } + // Look up user (DB read — uses read pool) + let user = state.db_query(move |db| { + db.get_user_by_email(&email)? + .ok_or_else(|| AppError::Unauthorized("Invalid email or password".to_string())) + }).await.map_err(|e| render_login_error(&format!("{e}")))?; + + if !user.email_verified { + return Err(render_login_error( + "Please verify your email before logging in. Check your inbox or visit /resend-verification to get a new link." + )); + } - if !user.email_verified { - return Err(AppError::Unauthorized( - "Please verify your email before logging in. Check your inbox or visit /resend-verification to get a new link.".to_string() - )); - } + // Verify password (CPU-bound — acquire semaphore to avoid starving DB reads) + let hash = user.password_hash.clone(); + let _permit = state.hash_semaphore.acquire().await + .map_err(|e| render_login_error(&format!("{e}")))?; + let valid = tokio::task::spawn_blocking(move || { + auth::verify_password(&password, &hash) + }).await + .map_err(|e| render_login_error(&format!("{e}")))? + .map_err(|e| render_login_error(&format!("{e}")))?; + drop(_permit); + + if !valid { + return Err(render_login_error("Invalid email or password")); + } - let token = db.create_session(user.id)?; - Ok(token) + // Create session (DB write) + let user_id = user.id; + let result = state.db_query(move |db| { + db.create_session(user_id) }).await; match result { @@ -206,21 +220,33 @@ pub async fn register( }; let token_clone = token.clone(); - let result = state.db_query(move |db| { - // Check if username is taken - if db.get_user_by_username(&username)?.is_some() { + // Check uniqueness (DB reads) + let uname = username.clone(); + let em = email.clone(); + state.db_query(move |db| { + if db.get_user_by_username(&uname)?.is_some() { return Err(AppError::Database("Username is already taken".to_string())); } - - // Check if email is taken - if db.get_user_by_email(&email)?.is_some() { + if db.get_user_by_email(&em)?.is_some() { return Err(AppError::Database("Email is already registered".to_string())); } - - let password_hash = auth::hash_password(&password)?; - db.create_user(&email, &password_hash, &username, Some(&token_clone))?; Ok(()) - }).await; + }).await.map_err(|e| render_register_error(&form, &format!("{e}")))?; + + // Hash password (CPU-bound — uses semaphore) + let _permit = state.hash_semaphore.acquire().await + .map_err(|e| render_register_error(&form, &format!("{e}")))?; + let password_hash = tokio::task::spawn_blocking(move || { + auth::hash_password(&password) + }).await + .map_err(|e| render_register_error(&form, &format!("{e}")))? + .map_err(|e| render_register_error(&form, &format!("{e}")))?; + drop(_permit); + + // Create user (DB write) + let result = state.db_query(move |db| { + db.create_user(&email, &password_hash, &username, Some(&token_clone)) + }).await.map(|_| ()); match result { Ok(()) => { diff --git a/src/state.rs b/src/state.rs @@ -9,15 +9,22 @@ pub struct AppState { pub config: Arc<Config>, pub db: Arc<Database>, pub comment_tx: tokio::sync::broadcast::Sender<i64>, + /// Limits concurrent password hashing so argon2 can't fill + /// all blocking threads and starve DB reads. + pub hash_semaphore: Arc<tokio::sync::Semaphore>, } impl AppState { pub fn new(config: Config, db: Database) -> Self { let (comment_tx, _) = tokio::sync::broadcast::channel(64); + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); Self { config: Arc::new(config), db: Arc::new(db), comment_tx, + hash_semaphore: Arc::new(tokio::sync::Semaphore::new((cores / 2).max(1))), } }