Skip to main content

khive_db/
pool.rs

1//! Connection pool for SQLite: one exclusive writer, N concurrent readers.
2use crossbeam_queue::ArrayQueue;
3use parking_lot::Mutex;
4use rusqlite::{Connection, OpenFlags};
5use std::ops::{Deref, DerefMut};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::thread;
9use std::time::{Duration, Instant};
10
11use crate::error::SqliteError;
12
13const CACHE_SIZE_KIB: &str = "-65536";
14const MMAP_SIZE_BYTES: &str = "1073741824";
15const DEFAULT_READER_CAP: usize = 8;
16
17const DEFAULT_WAL_AUTOCHECKPOINT_PAGES: u32 = 4000;
18const DEFAULT_JOURNAL_SIZE_LIMIT_BYTES: i64 = 67_108_864; // 64 MiB
19
20/// Configuration for the connection pool.
21#[derive(Clone, Debug)]
22pub struct PoolConfig {
23    /// Database path. None = in-memory (pool degrades to single connection).
24    pub path: Option<PathBuf>,
25    /// Number of reader connections (default: min(num_cpus, 8)).
26    pub max_readers: usize,
27    /// WAL mode (must be true for pooling to work; default: true).
28    pub wal_mode: bool,
29    /// Busy timeout per connection (default: 30s).
30    ///
31    /// Overridable via `KHIVE_BUSY_TIMEOUT_SECS`.
32    pub busy_timeout: Duration,
33    /// Time to wait for a reader connection before returning an error (default: 5s).
34    ///
35    /// Overridable via `KHIVE_CHECKOUT_TIMEOUT_SECS`.
36    pub checkout_timeout: Duration,
37    /// Number of WAL pages that triggers an automatic checkpoint.
38    ///
39    /// Maps to `PRAGMA wal_autocheckpoint`. The default (4000 pages, ~16 MiB
40    /// at SQLite's default 4 KiB page size) matches the pre-config behaviour.
41    ///
42    /// Overridable via `KHIVE_WAL_AUTOCHECKPOINT_PAGES`.
43    pub wal_autocheckpoint_pages: u32,
44    /// Maximum WAL journal size in bytes before SQLite resets the WAL.
45    ///
46    /// Maps to `PRAGMA journal_size_limit`. Default: 64 MiB.
47    ///
48    /// Overridable via `KHIVE_JOURNAL_SIZE_LIMIT_BYTES`.
49    pub journal_size_limit_bytes: i64,
50}
51
52impl Default for PoolConfig {
53    fn default() -> Self {
54        Self {
55            path: None,
56            max_readers: std::thread::available_parallelism()
57                .map(|n| n.get())
58                .unwrap_or(1)
59                .clamp(1, DEFAULT_READER_CAP),
60            wal_mode: true,
61            busy_timeout: Duration::from_secs(
62                std::env::var("KHIVE_BUSY_TIMEOUT_SECS")
63                    .ok()
64                    .and_then(|v| v.parse::<u64>().ok())
65                    .unwrap_or(30),
66            ),
67            checkout_timeout: Duration::from_secs(
68                std::env::var("KHIVE_CHECKOUT_TIMEOUT_SECS")
69                    .ok()
70                    .and_then(|v| v.parse::<u64>().ok())
71                    .unwrap_or(5),
72            ),
73            wal_autocheckpoint_pages: std::env::var("KHIVE_WAL_AUTOCHECKPOINT_PAGES")
74                .ok()
75                .and_then(|v| v.parse::<u32>().ok())
76                .unwrap_or(DEFAULT_WAL_AUTOCHECKPOINT_PAGES),
77            journal_size_limit_bytes: std::env::var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES")
78                .ok()
79                .and_then(|v| v.parse::<i64>().ok())
80                .unwrap_or(DEFAULT_JOURNAL_SIZE_LIMIT_BYTES),
81        }
82    }
83}
84
85/// A read-write connection pool for SQLite.
86///
87/// Architecture:
88/// - 1 writer connection protected by a Mutex (exclusive access)
89/// - N reader connections in a lock-free queue (concurrent access)
90/// - All connections share the same database file in WAL mode
91///
92/// For in-memory databases, or when WAL mode is disabled/unavailable, the pool
93/// degrades to single-connection mode and routes all operations through the
94/// writer connection.
95pub struct ConnectionPool {
96    writer: Arc<Mutex<Connection>>,
97    readers: ArrayQueue<Connection>,
98    max_readers: usize,
99    config: PoolConfig,
100}
101
102enum ReaderLease<'pool> {
103    Pooled(Connection),
104    Shared(parking_lot::MutexGuard<'pool, Connection>),
105}
106
107/// A reader connection checked out from the pool.
108/// Returns the connection to the pool on drop.
109pub struct ReaderGuard<'pool> {
110    lease: Option<ReaderLease<'pool>>,
111    pool: &'pool ConnectionPool,
112}
113
114impl<'pool> ReaderGuard<'pool> {
115    /// Access the connection.
116    pub fn conn(&self) -> &Connection {
117        match self
118            .lease
119            .as_ref()
120            .expect("reader guard missing connection")
121        {
122            ReaderLease::Pooled(conn) => conn,
123            ReaderLease::Shared(guard) => guard,
124        }
125    }
126}
127
128impl<'pool> Deref for ReaderGuard<'pool> {
129    type Target = Connection;
130
131    fn deref(&self) -> &Self::Target {
132        self.conn()
133    }
134}
135
136impl<'pool> Drop for ReaderGuard<'pool> {
137    fn drop(&mut self) {
138        let Some(lease) = self.lease.take() else {
139            return;
140        };
141
142        match lease {
143            ReaderLease::Pooled(conn) => self.pool.return_reader(conn),
144            ReaderLease::Shared(_guard) => {}
145        }
146    }
147}
148
149/// A writer connection checked out from the pool.
150/// The Mutex ensures only one writer at a time.
151pub struct WriterGuard<'pool> {
152    guard: parking_lot::MutexGuard<'pool, Connection>,
153}
154
155impl<'pool> WriterGuard<'pool> {
156    /// Returns a shared reference to the underlying connection.
157    pub fn conn(&self) -> &Connection {
158        &self.guard
159    }
160
161    /// Returns a mutable reference to the underlying connection.
162    pub fn conn_mut(&mut self) -> &mut Connection {
163        &mut self.guard
164    }
165
166    /// Execute a write transaction.
167    /// Wraps the closure in BEGIN IMMEDIATE ... COMMIT.
168    pub fn transaction<F, R>(&self, f: F) -> Result<R, SqliteError>
169    where
170        F: FnOnce(&Connection) -> Result<R, SqliteError>,
171    {
172        self.guard.execute_batch("BEGIN IMMEDIATE")?;
173
174        match f(&self.guard) {
175            Ok(result) => {
176                if let Err(err) = self.guard.execute_batch("COMMIT") {
177                    let _ = self.guard.execute_batch("ROLLBACK");
178                    return Err(err.into());
179                }
180                Ok(result)
181            }
182            Err(err) => {
183                let _ = self.guard.execute_batch("ROLLBACK");
184                Err(err)
185            }
186        }
187    }
188}
189
190impl<'pool> Deref for WriterGuard<'pool> {
191    type Target = Connection;
192
193    fn deref(&self) -> &Self::Target {
194        self.conn()
195    }
196}
197
198impl<'pool> DerefMut for WriterGuard<'pool> {
199    fn deref_mut(&mut self) -> &mut Self::Target {
200        self.conn_mut()
201    }
202}
203
204impl ConnectionPool {
205    /// Create a new connection pool.
206    ///
207    /// Opens 1 writer + N reader connections to the same database when pooling
208    /// is enabled. All connections are configured consistently (busy timeout,
209    /// foreign keys, cache, mmap, temp store). For in-memory databases, or when
210    /// WAL is disabled or unavailable, the pool falls back to single-connection
211    /// mode.
212    pub fn new(config: PoolConfig) -> Result<Self, SqliteError> {
213        let writer = open_writer_connection(&config)?;
214        let wal_enabled = configure_writer_connection(&writer, &config)?;
215        let max_readers = effective_reader_count(&config, wal_enabled);
216
217        let readers = ArrayQueue::new(max_readers.max(1));
218
219        let pool = Self {
220            writer: Arc::new(Mutex::new(writer)),
221            readers,
222            max_readers,
223            config,
224        };
225
226        for _ in 0..pool.max_readers {
227            let conn = pool.open_reader_connection()?;
228            pool.readers
229                .push(conn)
230                .expect("reader queue must have capacity during pool initialization");
231        }
232
233        Ok(pool)
234    }
235
236    /// Check out a reader connection.
237    ///
238    /// Tries to pop from the lock-free queue. If empty, spins briefly then
239    /// waits with exponential backoff up to `checkout_timeout`.
240    ///
241    /// # Deadlock Warning
242    ///
243    /// In degraded mode (WAL unavailable, `max_readers == 0`), this method locks
244    /// the writer mutex. If the calling thread already holds a [`WriterGuard`],
245    /// this will deadlock (parking_lot `Mutex` is not reentrant). Never call
246    /// `reader()` while holding a `WriterGuard` on the same pool.
247    pub fn reader(&self) -> Result<ReaderGuard<'_>, SqliteError> {
248        if self.max_readers == 0 {
249            return Ok(ReaderGuard {
250                lease: Some(ReaderLease::Shared(self.writer.lock())),
251                pool: self,
252            });
253        }
254
255        let started = Instant::now();
256        let mut attempt = 0u32;
257
258        loop {
259            if let Some(conn) = self.readers.pop() {
260                return Ok(ReaderGuard {
261                    lease: Some(ReaderLease::Pooled(conn)),
262                    pool: self,
263                });
264            }
265
266            if started.elapsed() >= self.config.checkout_timeout {
267                return Err(pool_exhausted_error(
268                    self.config.checkout_timeout,
269                    self.max_readers,
270                ));
271            }
272
273            match attempt {
274                0..=7 => {
275                    let spins = 1usize << attempt;
276                    for _ in 0..spins {
277                        std::hint::spin_loop();
278                    }
279                }
280                8..=15 => thread::yield_now(),
281                _ => {
282                    let remaining = self
283                        .config
284                        .checkout_timeout
285                        .saturating_sub(started.elapsed());
286                    let sleep = Duration::from_micros(50 * (1u64 << (attempt - 16).min(6)));
287                    thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
288                }
289            }
290
291            attempt = attempt.saturating_add(1);
292        }
293    }
294
295    /// Check out the writer connection.
296    ///
297    /// Waits up to `checkout_timeout` for the writer Mutex and returns
298    /// `Err(SqliteError::InvalidData)` if the timeout is exceeded.
299    pub fn writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
300        let guard = self
301            .writer
302            .try_lock_for(self.config.checkout_timeout)
303            .ok_or_else(|| {
304                SqliteError::InvalidData(format!(
305                    "timed out after {:?} waiting for sqlite writer connection",
306                    self.config.checkout_timeout
307                ))
308            })?;
309        Ok(WriterGuard { guard })
310    }
311
312    /// Non-panicking writer checkout.
313    ///
314    /// Returns `Err` on timeout instead of panicking. Use this in request
315    /// handlers where a 500 is preferable to crashing the process.
316    pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
317        self.writer()
318    }
319
320    /// Zero-wait writer checkout for background tasks.
321    ///
322    /// Uses `try_lock()` (no timeout, no spin) — returns `Err` immediately when
323    /// any other caller holds the writer Mutex. Background tasks (e.g. the WAL
324    /// checkpoint task) MUST use this instead of `try_writer` so that a busy
325    /// writer causes the background task to skip its current tick rather than
326    /// stalling for up to `checkout_timeout` (default 5s) while write traffic
327    /// is in progress.
328    pub fn try_writer_nowait(&self) -> Result<WriterGuard<'_>, SqliteError> {
329        let guard = self.writer.try_lock().ok_or_else(|| {
330            SqliteError::InvalidData(
331                "writer connection busy (checkpoint skipped this tick)".to_string(),
332            )
333        })?;
334        Ok(WriterGuard { guard })
335    }
336
337    /// Get the current number of available reader connections.
338    pub fn available_readers(&self) -> usize {
339        self.readers.len()
340    }
341
342    /// Get the total number of reader connections in the pool.
343    pub fn max_readers(&self) -> usize {
344        self.max_readers
345    }
346
347    /// Return the pool configuration.
348    pub fn config(&self) -> &PoolConfig {
349        &self.config
350    }
351
352    /// Compatibility method: returns the writer connection wrapped in `Arc<Mutex>`.
353    ///
354    /// WARNING: This exists only for backward compatibility with code that
355    /// calls `store.conn()`. New code should use `reader()` and `writer()`.
356    pub fn legacy_conn(&self) -> Arc<Mutex<Connection>> {
357        Arc::clone(&self.writer)
358    }
359
360    fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
361        let path = self
362            .config
363            .path
364            .as_ref()
365            .expect("reader connections require a file-backed database");
366        open_reader_connection(path, &self.config)
367    }
368
369    fn return_reader(&self, conn: Connection) {
370        if self.max_readers == 0 {
371            return;
372        }
373
374        let conn = if reset_reader_connection(&conn) && reader_connection_is_healthy(&conn) {
375            Some(conn)
376        } else {
377            close_connection_quietly(conn);
378            self.open_reader_connection().ok()
379        };
380
381        if let Some(conn) = conn {
382            if let Err(conn) = self.readers.push(conn) {
383                eprintln!(
384                    "[sqlite-pool] reader pool queue full, discarding replacement connection"
385                );
386                close_connection_quietly(conn);
387            }
388        }
389    }
390}
391
392fn effective_reader_count(config: &PoolConfig, wal_enabled: bool) -> usize {
393    if config.path.is_some() && config.wal_mode && wal_enabled {
394        config.max_readers
395    } else {
396        0
397    }
398}
399
400fn open_writer_connection(config: &PoolConfig) -> Result<Connection, SqliteError> {
401    match config.path.as_ref() {
402        Some(path) => Connection::open_with_flags(path, writer_open_flags()).map_err(Into::into),
403        None => Connection::open_in_memory().map_err(Into::into),
404    }
405}
406
407fn open_reader_connection(path: &Path, config: &PoolConfig) -> Result<Connection, SqliteError> {
408    let conn = Connection::open_with_flags(path, reader_open_flags())?;
409    configure_reader_connection(&conn, config)?;
410    Ok(conn)
411}
412
413fn writer_open_flags() -> OpenFlags {
414    OpenFlags::SQLITE_OPEN_READ_WRITE
415        | OpenFlags::SQLITE_OPEN_CREATE
416        | OpenFlags::SQLITE_OPEN_URI
417        | OpenFlags::SQLITE_OPEN_NO_MUTEX
418}
419
420fn reader_open_flags() -> OpenFlags {
421    OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
422}
423
424fn configure_writer_connection(
425    conn: &Connection,
426    config: &PoolConfig,
427) -> Result<bool, SqliteError> {
428    let wants_wal = config.path.is_some() && config.wal_mode;
429
430    if wants_wal {
431        conn.pragma_update(None, "journal_mode", "WAL")?;
432    }
433
434    conn.pragma_update(None, "synchronous", "NORMAL")?;
435    conn.pragma_update(None, "foreign_keys", "ON")?;
436    conn.busy_timeout(config.busy_timeout)?;
437    conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
438    conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
439    conn.pragma_update(None, "temp_store", "MEMORY")?;
440
441    let wal_enabled = wants_wal && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
442
443    if wal_enabled {
444        conn.pragma_update(None, "wal_autocheckpoint", config.wal_autocheckpoint_pages)?;
445        conn.pragma_update(None, "journal_size_limit", config.journal_size_limit_bytes)?;
446    }
447
448    Ok(wal_enabled)
449}
450
451fn configure_reader_connection(conn: &Connection, config: &PoolConfig) -> Result<(), SqliteError> {
452    conn.pragma_update(None, "foreign_keys", "ON")?;
453    conn.busy_timeout(config.busy_timeout)?;
454    conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
455    conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
456    conn.pragma_update(None, "temp_store", "MEMORY")?;
457    Ok(())
458}
459
460fn current_journal_mode(conn: &Connection) -> Result<String, SqliteError> {
461    conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
462        .map(|mode| mode.to_ascii_lowercase())
463        .map_err(Into::into)
464}
465
466fn reset_reader_connection(conn: &Connection) -> bool {
467    if conn.is_autocommit() {
468        return true;
469    }
470
471    match conn.execute_batch("ROLLBACK") {
472        Ok(()) => conn.is_autocommit(),
473        Err(rusqlite::Error::SqliteFailure(err, _)) => {
474            if matches!(
475                err.code,
476                rusqlite::ErrorCode::CannotOpen
477                    | rusqlite::ErrorCode::DatabaseCorrupt
478                    | rusqlite::ErrorCode::NotADatabase
479                    | rusqlite::ErrorCode::DiskFull
480            ) {
481                return false;
482            }
483            conn.is_autocommit()
484        }
485        Err(_) => false,
486    }
487}
488
489fn reader_connection_is_healthy(conn: &Connection) -> bool {
490    match conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) {
491        Ok(_) => true,
492        Err(rusqlite::Error::SqliteFailure(err, _)) => !matches!(
493            err.code,
494            rusqlite::ErrorCode::CannotOpen
495                | rusqlite::ErrorCode::NotADatabase
496                | rusqlite::ErrorCode::DatabaseCorrupt
497                | rusqlite::ErrorCode::PermissionDenied
498                | rusqlite::ErrorCode::SystemIoFailure
499        ),
500        Err(_) => true,
501    }
502}
503
504fn close_connection_quietly(conn: Connection) {
505    match conn.close() {
506        Ok(()) => {}
507        Err((conn, _)) => drop(conn),
508    }
509}
510
511fn pool_exhausted_error(timeout: Duration, max_readers: usize) -> SqliteError {
512    rusqlite::Error::SqliteFailure(
513        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
514        Some(format!(
515            "Pool exhausted: no reader available after {timeout:?} (max_readers={max_readers})"
516        )),
517    )
518    .into()
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use serial_test::serial;
525
526    #[test]
527    fn pool_config_default_values_match_constants() {
528        // Ensure defaults are not accidentally changed.
529        let cfg = PoolConfig::default();
530        assert_eq!(
531            cfg.wal_autocheckpoint_pages,
532            DEFAULT_WAL_AUTOCHECKPOINT_PAGES
533        );
534        assert_eq!(
535            cfg.journal_size_limit_bytes,
536            DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
537        );
538        assert_eq!(cfg.busy_timeout, Duration::from_secs(30));
539        assert_eq!(cfg.checkout_timeout, Duration::from_secs(5));
540    }
541
542    #[test]
543    #[serial]
544    fn pool_config_env_override_wal_autocheckpoint() {
545        std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "8000");
546        let cfg = PoolConfig::default();
547        std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
548        assert_eq!(cfg.wal_autocheckpoint_pages, 8000);
549    }
550
551    #[test]
552    #[serial]
553    fn pool_config_env_override_journal_size_limit() {
554        std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "134217728");
555        let cfg = PoolConfig::default();
556        std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
557        assert_eq!(cfg.journal_size_limit_bytes, 134_217_728);
558    }
559
560    #[test]
561    #[serial]
562    fn pool_config_env_override_busy_timeout() {
563        std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "60");
564        let cfg = PoolConfig::default();
565        std::env::remove_var("KHIVE_BUSY_TIMEOUT_SECS");
566        assert_eq!(cfg.busy_timeout, Duration::from_secs(60));
567    }
568
569    #[test]
570    #[serial]
571    fn pool_config_env_override_checkout_timeout() {
572        std::env::set_var("KHIVE_CHECKOUT_TIMEOUT_SECS", "10");
573        let cfg = PoolConfig::default();
574        std::env::remove_var("KHIVE_CHECKOUT_TIMEOUT_SECS");
575        assert_eq!(cfg.checkout_timeout, Duration::from_secs(10));
576    }
577
578    #[test]
579    #[serial]
580    fn pool_config_env_invalid_falls_back_to_default() {
581        std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "not_a_number");
582        std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "");
583        let cfg = PoolConfig::default();
584        std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
585        std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
586        assert_eq!(
587            cfg.wal_autocheckpoint_pages,
588            DEFAULT_WAL_AUTOCHECKPOINT_PAGES
589        );
590        assert_eq!(
591            cfg.journal_size_limit_bytes,
592            DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
593        );
594    }
595
596    #[test]
597    fn file_backed_pool_opens_successfully() {
598        let dir = tempfile::tempdir().unwrap();
599        let path = dir.path().join("test_pool.db");
600        let cfg = PoolConfig {
601            path: Some(path.clone()),
602            ..PoolConfig::default()
603        };
604        let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
605        assert!(path.exists());
606        assert!(pool.max_readers() > 0);
607    }
608
609    #[test]
610    fn in_memory_pool_degrades_to_single_connection() {
611        let cfg = PoolConfig {
612            path: None,
613            ..PoolConfig::default()
614        };
615        let pool = ConnectionPool::new(cfg).expect("in-memory pool should open");
616        assert_eq!(pool.max_readers(), 0);
617    }
618
619    #[test]
620    fn writer_checkout_and_release_works() {
621        let cfg = PoolConfig {
622            path: None,
623            ..PoolConfig::default()
624        };
625        let pool = ConnectionPool::new(cfg).unwrap();
626        {
627            let _writer = pool.writer().expect("writer checkout should succeed");
628        }
629        // After drop, writer should be re-acquirable.
630        let _writer2 = pool
631            .writer()
632            .expect("second writer checkout should succeed");
633    }
634}