simple-web-app

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

commit 8dd22711e7f8d2332bfc74cbac6d59efdc541f8c
parent 569eb827d6e7b45e8a482bacc42d1e03c4122b6d
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 13 Jun 2026 12:00:06 +0200

perf: thread-local reader connections, cap blocking threads to core count

Replace mutex-based connection pool with thread-local SQLite
connections — zero synchronization overhead for reads. Each
spawn_blocking thread gets its own connection on first use.

Cap tokio's blocking thread pool to available_parallelism()
(core count) since more threads than cores just adds context
switching. Results on Ryzen 7 7700:

- c=500: 5,033 → 31,426 rps (6.2x improvement)
- Throughput now increases with concurrency instead of degrading
- p99 latency at c=500: 36ms

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

Diffstat:
Msrc/database.rs | 60++++++++++++++++--------------------------------------------
Msrc/main.rs | 15+++++++++++++--
2 files changed, 29 insertions(+), 46 deletions(-)

diff --git a/src/database.rs b/src/database.rs @@ -104,7 +104,6 @@ pub struct StoryMeta { // Database // ========================================================================== -const READ_POOL_SIZE: usize = 10; const PRAGMAS: &str = "\ PRAGMA journal_mode=WAL;\ PRAGMA synchronous=NORMAL;\ @@ -117,32 +116,11 @@ const PRAGMAS: &str = "\ pub struct Database { path: String, - readers: Mutex<Vec<Connection>>, writer: Mutex<Connection>, } -/// RAII guard that returns a read connection to the pool on drop. -pub struct ReadConn<'a> { - conn: Option<Connection>, - db: &'a Database, -} - -impl<'a> std::ops::Deref for ReadConn<'a> { - type Target = Connection; - fn deref(&self) -> &Connection { - self.conn.as_ref().unwrap() - } -} - -impl<'a> Drop for ReadConn<'a> { - fn drop(&mut self) { - if let Some(conn) = self.conn.take() { - let mut readers = self.db.readers.lock().unwrap(); - if readers.len() < READ_POOL_SIZE { - readers.push(conn); - } - } - } +thread_local! { + static READ_CONN: std::cell::UnsafeCell<Option<Connection>> = const { std::cell::UnsafeCell::new(None) }; } fn open_conn(path: &str) -> Result<Connection, AppError> { @@ -164,32 +142,26 @@ impl Database { 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(), - readers: Mutex::new(readers), writer: Mutex::new(writer), }) } - /// Get a read-only connection from the pool. - fn read_conn(&self) -> Result<ReadConn<'_>, AppError> { - let conn = { - let mut readers = self.readers.lock().unwrap(); - readers.pop() - }; - - let conn = match conn { - Some(c) => c, - None => open_conn(&self.path)?, - }; - - Ok(ReadConn { conn: Some(conn), db: self }) + /// Get a thread-local read connection. Zero contention. + /// + /// SAFETY: thread-locals are only accessed from one thread. The reference + /// is valid for the duration of the query (same spawn_blocking scope). + fn read_conn(&self) -> Result<&Connection, AppError> { + READ_CONN.with(|cell| { + let ptr = cell.get(); + unsafe { + if (*ptr).is_none() { + *ptr = Some(open_conn(&self.path)?); + } + Ok((*ptr).as_ref().unwrap()) + } + }) } /// Get the exclusive writer connection. diff --git a/src/main.rs b/src/main.rs @@ -14,8 +14,19 @@ use config::Config; use database::Database; use state::AppState; -#[tokio::main] -async fn main() -> Result<(), Box<dyn std::error::Error>> { +fn main() -> Result<(), Box<dyn std::error::Error>> { + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .max_blocking_threads(cores) + .build()? + .block_on(async_main()) +} + +async fn async_main() -> Result<(), Box<dyn std::error::Error>> { let config = Config::from_env(); eprintln!("Starting server with DEBUG={}", config.debug);