1use 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; #[derive(Clone, Debug)]
22pub struct PoolConfig {
23 pub path: Option<PathBuf>,
25 pub max_readers: usize,
27 pub wal_mode: bool,
29 pub busy_timeout: Duration,
33 pub checkout_timeout: Duration,
37 pub wal_autocheckpoint_pages: u32,
44 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
85pub 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
107pub struct ReaderGuard<'pool> {
110 lease: Option<ReaderLease<'pool>>,
111 pool: &'pool ConnectionPool,
112}
113
114impl<'pool> ReaderGuard<'pool> {
115 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
149pub struct WriterGuard<'pool> {
152 guard: parking_lot::MutexGuard<'pool, Connection>,
153}
154
155impl<'pool> WriterGuard<'pool> {
156 pub fn conn(&self) -> &Connection {
158 &self.guard
159 }
160
161 pub fn conn_mut(&mut self) -> &mut Connection {
163 &mut self.guard
164 }
165
166 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 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 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 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 pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
317 self.writer()
318 }
319
320 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 pub fn available_readers(&self) -> usize {
339 self.readers.len()
340 }
341
342 pub fn max_readers(&self) -> usize {
344 self.max_readers
345 }
346
347 pub fn config(&self) -> &PoolConfig {
349 &self.config
350 }
351
352 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 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 let _writer2 = pool
631 .writer()
632 .expect("second writer checkout should succeed");
633 }
634}