simple-web-app

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

commit 569eb827d6e7b45e8a482bacc42d1e03c4122b6d
parent c91d4f26eb82f291337bcf6c896e864e41d62799
Author: Silas Brack <silasbrack@gmail.com>
Date:   Fri, 12 Jun 2026 23:28:32 +0200

perf: separate reader pool and writer connection

Reads (20 methods) use a pool of 10 connections that never
contend with writes. Writes (9 methods) use a single dedicated
connection behind its own Mutex. In WAL mode, readers never block
on the writer and vice versa.

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

Diffstat:
Msrc/database.rs | 142+++++++++++++++++++++++++++++++++++++++++--------------------------------------
1 file changed, 74 insertions(+), 68 deletions(-)

diff --git a/src/database.rs b/src/database.rs @@ -104,7 +104,7 @@ pub struct StoryMeta { // Database // ========================================================================== -const POOL_SIZE: usize = 10; +const READ_POOL_SIZE: usize = 10; const PRAGMAS: &str = "\ PRAGMA journal_mode=WAL;\ PRAGMA synchronous=NORMAL;\ @@ -117,33 +117,42 @@ const PRAGMAS: &str = "\ pub struct Database { path: String, - conns: Mutex<Vec<Connection>>, + readers: Mutex<Vec<Connection>>, + writer: Mutex<Connection>, } -/// RAII guard that returns a connection to the pool on drop. -pub struct PooledConn<'a> { +/// RAII guard that returns a read connection to the pool on drop. +pub struct ReadConn<'a> { conn: Option<Connection>, - pool: &'a Database, + db: &'a Database, } -impl<'a> std::ops::Deref for PooledConn<'a> { +impl<'a> std::ops::Deref for ReadConn<'a> { type Target = Connection; fn deref(&self) -> &Connection { self.conn.as_ref().unwrap() } } -impl<'a> Drop for PooledConn<'a> { +impl<'a> Drop for ReadConn<'a> { fn drop(&mut self) { if let Some(conn) = self.conn.take() { - let mut conns = self.pool.conns.lock().unwrap(); - if conns.len() < POOL_SIZE { - conns.push(conn); + let mut readers = self.db.readers.lock().unwrap(); + if readers.len() < READ_POOL_SIZE { + readers.push(conn); } } } } +fn open_conn(path: &str) -> Result<Connection, AppError> { + let c = Connection::open(path) + .map_err(|e| AppError::Database(format!("Failed to open db: {e}")))?; + c.execute_batch(PRAGMAS) + .map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {e}")))?; + Ok(c) +} + impl Database { pub fn new(path: &str) -> Result<Self, AppError> { if let Some(parent) = std::path::Path::new(path).parent() { @@ -151,49 +160,43 @@ impl Database { .map_err(|e| AppError::Database(format!("Failed to create db directory: {e}")))?; } - // Run migrations - let conn = Connection::open(path) - .map_err(|e| AppError::Database(format!("Failed to open db: {e}")))?; - conn.execute_batch(PRAGMAS) - .map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {e}")))?; - crate::migrations::run(&conn).map_err(AppError::Database)?; - - // Pre-fill pool - let mut conns = Vec::with_capacity(POOL_SIZE); - conns.push(conn); - for _ in 1..POOL_SIZE { - let c = Connection::open(path) - .map_err(|e| AppError::Database(format!("Failed to open db: {e}")))?; - c.execute_batch(PRAGMAS) - .map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {e}")))?; - conns.push(c); + // Run migrations on the writer connection + let writer = open_conn(path)?; + crate::migrations::run(&writer).map_err(AppError::Database)?; + + // Pre-fill read pool + let mut readers = Vec::with_capacity(READ_POOL_SIZE); + for _ in 0..READ_POOL_SIZE { + readers.push(open_conn(path)?); } Ok(Self { path: path.to_string(), - conns: Mutex::new(conns), + readers: Mutex::new(readers), + writer: Mutex::new(writer), }) } - fn get_conn(&self) -> Result<PooledConn<'_>, AppError> { + /// Get a read-only connection from the pool. + fn read_conn(&self) -> Result<ReadConn<'_>, AppError> { let conn = { - let mut conns = self.conns.lock().unwrap(); - conns.pop() + let mut readers = self.readers.lock().unwrap(); + readers.pop() }; let conn = match conn { Some(c) => c, - None => { - // Pool exhausted, open a new connection - let c = Connection::open(&self.path) - .map_err(|e| AppError::Database(format!("Failed to open db: {e}")))?; - c.execute_batch(PRAGMAS) - .map_err(|e| AppError::Database(format!("Failed to set PRAGMAs: {e}")))?; - c - } + None => open_conn(&self.path)?, }; - Ok(PooledConn { conn: Some(conn), pool: self }) + Ok(ReadConn { conn: Some(conn), db: self }) + } + + /// Get the exclusive writer connection. + fn write_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, AppError> { + self.writer + .lock() + .map_err(|e| AppError::Database(format!("Writer lock poisoned: {e}"))) } // ====================================================================== @@ -218,7 +221,7 @@ impl Database { } pub fn get_stories_new(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM story ORDER BY published DESC LIMIT ?1 OFFSET ?2" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -232,7 +235,7 @@ impl Database { } pub fn get_stories_top(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM story ORDER BY score DESC, published DESC LIMIT ?1 OFFSET ?2" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -247,7 +250,7 @@ impl Database { pub fn get_stories_hot(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { let fetch_limit = (limit + offset) * 2; - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM story ORDER BY published DESC LIMIT ?1" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -268,7 +271,7 @@ impl Database { } pub fn get_story(&self, id: i64) -> Result<Option<Story>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT * FROM story WHERE id = ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -291,7 +294,7 @@ impl Database { domain: Option<&str>, language: &str, ) -> Result<Story, AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; let published = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); conn.execute( @@ -301,6 +304,7 @@ impl Database { ).map_err(|e| AppError::Database(e.to_string()))?; let id = conn.last_insert_rowid(); + drop(conn); self.get_story(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created story".to_string())) } @@ -312,7 +316,7 @@ impl Database { text: &str, domain: Option<&str>, ) -> Result<(), AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; conn.execute( "UPDATE story SET title = ?1, url = ?2, text = ?3, domain = ?4 WHERE id = ?5", params![title, url, text, domain, id], @@ -321,7 +325,7 @@ impl Database { } pub fn get_stories_by_tag(&self, category_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT s.* FROM story s JOIN story_category sc ON sc.news_item_id = s.id @@ -339,7 +343,7 @@ impl Database { } pub fn get_stories_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM story WHERE created_by = ?1 ORDER BY published DESC LIMIT ?2 OFFSET ?3" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -353,7 +357,7 @@ impl Database { } pub fn search_stories(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT s.id, s.title, s.text, s.score, s.comment_count FROM story_fts fts @@ -399,7 +403,7 @@ impl Database { } pub fn get_comments_for_story(&self, story_id: i64) -> Result<Vec<Comment>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM comment WHERE story_id = ?1 ORDER BY path" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -419,7 +423,7 @@ impl Database { parent_id: Option<i64>, text: &str, ) -> Result<Comment, AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; // Calculate path and depth let (path, depth) = match parent_id { @@ -469,11 +473,12 @@ impl Database { ).map_err(|e| AppError::Database(e.to_string()))?; let id = conn.last_insert_rowid(); + drop(conn); self.get_comment(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created comment".to_string())) } pub fn get_comment(&self, id: i64) -> Result<Option<Comment>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT * FROM comment WHERE id = ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -488,7 +493,7 @@ impl Database { } pub fn get_comments_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Comment>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT * FROM comment WHERE created_by = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -507,7 +512,7 @@ impl Database { /// Toggle vote: INSERT if not exists, DELETE if exists. Returns true if now voted. pub fn toggle_vote(&self, user_id: i64, target_type: i64, target_id: i64) -> Result<bool, AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; // Try to insert let result = conn.execute( @@ -536,7 +541,7 @@ impl Database { return Ok(vec![]); } - let conn = self.get_conn()?; + let conn = self.read_conn()?; // Build placeholders for IN clause let placeholders: Vec<String> = target_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect(); @@ -569,7 +574,7 @@ impl Database { // ====================================================================== pub fn get_categories(&self, limit: usize) -> Result<Vec<Category>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT id, name FROM category LIMIT ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -587,7 +592,7 @@ impl Database { } pub fn get_categories_for_story(&self, story_id: i64) -> Result<Vec<Category>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare( "SELECT c.id, c.name FROM category c JOIN story_category sc ON sc.category_id = c.id @@ -608,7 +613,7 @@ impl Database { } pub fn add_tag_to_story(&self, story_id: i64, category_id: i64) -> Result<(), AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; conn.execute( "INSERT INTO story_category (news_item_id, category_id) VALUES (?1, ?2)", params![story_id, category_id], @@ -617,7 +622,7 @@ impl Database { } pub fn remove_all_tags_from_story(&self, story_id: i64) -> Result<(), AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; conn.execute( "DELETE FROM story_category WHERE news_item_id = ?1", params![story_id], @@ -645,7 +650,7 @@ impl Database { } pub fn get_user_by_id(&self, id: i64) -> Result<Option<User>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT * FROM user WHERE id = ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -660,7 +665,7 @@ impl Database { } pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT * FROM user WHERE email = ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -675,7 +680,7 @@ impl Database { } pub fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; let mut stmt = conn.prepare("SELECT * FROM user WHERE username = ?1") .map_err(|e| AppError::Database(e.to_string()))?; @@ -690,18 +695,19 @@ impl Database { } pub fn create_user(&self, email: &str, password_hash: &str, username: &str, verification_token: Option<&str>) -> Result<User, AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; conn.execute( "INSERT INTO user (email, password_hash, username, verification_token) VALUES (?1, ?2, ?3, ?4)", params![email, password_hash, username, verification_token], ).map_err(|e| AppError::Database(e.to_string()))?; let id = conn.last_insert_rowid(); + drop(conn); self.get_user_by_id(id)?.ok_or_else(|| AppError::Database("Failed to retrieve created user".to_string())) } pub fn verify_email_token(&self, token: &str) -> Result<bool, AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; let rows_affected = conn.execute( "UPDATE user SET email_verified = 1, verification_token = NULL WHERE verification_token = ?1", params![token], @@ -710,7 +716,7 @@ impl Database { } pub fn update_verification_token(&self, user_id: i64, token: &str) -> Result<(), AppError> { - let conn = self.get_conn()?; + let conn = self.write_conn()?; conn.execute( "UPDATE user SET verification_token = ?1 WHERE id = ?2", params![token, user_id], @@ -727,7 +733,7 @@ impl Database { return Ok(vec![]); } - let conn = self.get_conn()?; + let conn = self.read_conn()?; // Fetch author usernames let placeholders: Vec<String> = story_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); @@ -793,7 +799,7 @@ impl Database { return Ok(HashMap::new()); } - let conn = self.get_conn()?; + let conn = self.read_conn()?; let placeholders: Vec<String> = user_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); let sql = format!( "SELECT id, username FROM user WHERE id IN ({})", @@ -820,14 +826,14 @@ impl Database { /// Get the score of a story pub fn get_story_score(&self, id: i64) -> Result<i64, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; conn.query_row("SELECT score FROM story WHERE id = ?1", params![id], |row| row.get(0)) .map_err(|e| AppError::Database(e.to_string())) } /// Get the score of a comment pub fn get_comment_score(&self, id: i64) -> Result<i64, AppError> { - let conn = self.get_conn()?; + let conn = self.read_conn()?; conn.query_row("SELECT score FROM comment WHERE id = ?1", params![id], |row| row.get(0)) .map_err(|e| AppError::Database(e.to_string())) }