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:
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);