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, OnceLock};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use crate::error::SqliteError;
12use crate::writer_task::WriterTaskHandle;
13use khive_storage::error::StorageError;
14
15const CACHE_SIZE_KIB: &str = "-65536";
16const MMAP_SIZE_BYTES: &str = "1073741824";
17const DEFAULT_READER_CAP: usize = 8;
18
19const DEFAULT_WAL_AUTOCHECKPOINT_PAGES: u32 = 4000;
20const DEFAULT_JOURNAL_SIZE_LIMIT_BYTES: i64 = 67_108_864; const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
22
23#[derive(Clone, Debug)]
25pub struct PoolConfig {
26 pub path: Option<PathBuf>,
28 pub max_readers: usize,
30 pub wal_mode: bool,
32 pub busy_timeout: Duration,
36 pub checkout_timeout: Duration,
40 pub wal_autocheckpoint_pages: u32,
47 pub journal_size_limit_bytes: i64,
53 pub read_only: bool,
61 pub write_queue_enabled: bool,
73 pub write_queue_capacity: usize,
78}
79
80impl Default for PoolConfig {
81 fn default() -> Self {
82 Self {
83 path: None,
84 max_readers: std::thread::available_parallelism()
85 .map(|n| n.get())
86 .unwrap_or(1)
87 .clamp(1, DEFAULT_READER_CAP),
88 wal_mode: true,
89 busy_timeout: Duration::from_secs(
90 std::env::var("KHIVE_BUSY_TIMEOUT_SECS")
91 .ok()
92 .and_then(|v| v.parse::<u64>().ok())
93 .unwrap_or(30),
94 ),
95 checkout_timeout: Duration::from_secs(
96 std::env::var("KHIVE_CHECKOUT_TIMEOUT_SECS")
97 .ok()
98 .and_then(|v| v.parse::<u64>().ok())
99 .unwrap_or(5),
100 ),
101 wal_autocheckpoint_pages: std::env::var("KHIVE_WAL_AUTOCHECKPOINT_PAGES")
102 .ok()
103 .and_then(|v| v.parse::<u32>().ok())
104 .unwrap_or(DEFAULT_WAL_AUTOCHECKPOINT_PAGES),
105 journal_size_limit_bytes: std::env::var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES")
106 .ok()
107 .and_then(|v| v.parse::<i64>().ok())
108 .unwrap_or(DEFAULT_JOURNAL_SIZE_LIMIT_BYTES),
109 read_only: false,
110 write_queue_enabled: std::env::var("KHIVE_WRITE_QUEUE")
111 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
112 .unwrap_or(false),
113 write_queue_capacity: std::env::var("KHIVE_WRITE_QUEUE_CAPACITY")
114 .ok()
115 .and_then(|v| v.parse::<usize>().ok())
116 .filter(|&n| n > 0)
117 .unwrap_or(DEFAULT_WRITE_QUEUE_CAPACITY),
118 }
119 }
120}
121
122pub struct ConnectionPool {
133 writer: Arc<Mutex<Connection>>,
134 readers: ArrayQueue<Connection>,
135 max_readers: usize,
136 config: PoolConfig,
137 writer_task: OnceLock<Option<WriterTaskHandle>>,
142 #[cfg(test)]
148 writer_task_spawn_count: std::sync::atomic::AtomicUsize,
149}
150
151enum ReaderLease<'pool> {
152 Pooled(Connection),
153 Shared(parking_lot::MutexGuard<'pool, Connection>),
154}
155
156pub struct ReaderGuard<'pool> {
159 lease: Option<ReaderLease<'pool>>,
160 pool: &'pool ConnectionPool,
161}
162
163impl<'pool> ReaderGuard<'pool> {
164 pub fn conn(&self) -> &Connection {
166 match self
167 .lease
168 .as_ref()
169 .expect("reader guard missing connection")
170 {
171 ReaderLease::Pooled(conn) => conn,
172 ReaderLease::Shared(guard) => guard,
173 }
174 }
175}
176
177impl<'pool> Deref for ReaderGuard<'pool> {
178 type Target = Connection;
179
180 fn deref(&self) -> &Self::Target {
181 self.conn()
182 }
183}
184
185impl<'pool> Drop for ReaderGuard<'pool> {
186 fn drop(&mut self) {
187 let Some(lease) = self.lease.take() else {
188 return;
189 };
190
191 match lease {
192 ReaderLease::Pooled(conn) => self.pool.return_reader(conn),
193 ReaderLease::Shared(_guard) => {}
194 }
195 }
196}
197
198pub struct WriterGuard<'pool> {
201 guard: parking_lot::MutexGuard<'pool, Connection>,
202}
203
204impl<'pool> WriterGuard<'pool> {
205 pub fn conn(&self) -> &Connection {
207 &self.guard
208 }
209
210 pub fn conn_mut(&mut self) -> &mut Connection {
212 &mut self.guard
213 }
214
215 pub fn transaction<F, R>(&self, f: F) -> Result<R, SqliteError>
218 where
219 F: FnOnce(&Connection) -> Result<R, SqliteError>,
220 {
221 self.guard.execute_batch("BEGIN IMMEDIATE")?;
222 let _tx_handle = khive_storage::tx_registry::register(Some("writer_guard_tx".to_string()));
223
224 match f(&self.guard) {
225 Ok(result) => {
226 if let Err(err) = self.guard.execute_batch("COMMIT") {
227 let _ = self.guard.execute_batch("ROLLBACK");
228 return Err(err.into());
229 }
230 Ok(result)
231 }
232 Err(err) => {
233 let _ = self.guard.execute_batch("ROLLBACK");
234 Err(err)
235 }
236 }
237 }
238}
239
240impl<'pool> Deref for WriterGuard<'pool> {
241 type Target = Connection;
242
243 fn deref(&self) -> &Self::Target {
244 self.conn()
245 }
246}
247
248impl<'pool> DerefMut for WriterGuard<'pool> {
249 fn deref_mut(&mut self) -> &mut Self::Target {
250 self.conn_mut()
251 }
252}
253
254impl ConnectionPool {
255 pub fn new(config: PoolConfig) -> Result<Self, SqliteError> {
263 let writer = open_writer_connection(&config)?;
264 let wal_enabled = configure_writer_connection(&writer, &config)?;
265 let max_readers = effective_reader_count(&config, wal_enabled);
266
267 let readers = ArrayQueue::new(max_readers.max(1));
268
269 let pool = Self {
270 writer: Arc::new(Mutex::new(writer)),
271 readers,
272 max_readers,
273 config,
274 writer_task: OnceLock::new(),
275 #[cfg(test)]
276 writer_task_spawn_count: std::sync::atomic::AtomicUsize::new(0),
277 };
278
279 for _ in 0..pool.max_readers {
280 let conn = pool.open_reader_connection()?;
281 pool.readers
282 .push(conn)
283 .expect("reader queue must have capacity during pool initialization");
284 }
285
286 Ok(pool)
287 }
288
289 pub fn reader(&self) -> Result<ReaderGuard<'_>, SqliteError> {
301 if self.max_readers == 0 {
302 return Ok(ReaderGuard {
303 lease: Some(ReaderLease::Shared(self.writer.lock())),
304 pool: self,
305 });
306 }
307
308 let started = Instant::now();
309 let mut attempt = 0u32;
310
311 loop {
312 if let Some(conn) = self.readers.pop() {
313 return Ok(ReaderGuard {
314 lease: Some(ReaderLease::Pooled(conn)),
315 pool: self,
316 });
317 }
318
319 if started.elapsed() >= self.config.checkout_timeout {
320 return Err(pool_exhausted_error(
321 self.config.checkout_timeout,
322 self.max_readers,
323 ));
324 }
325
326 match attempt {
327 0..=7 => {
328 let spins = 1usize << attempt;
329 for _ in 0..spins {
330 std::hint::spin_loop();
331 }
332 }
333 8..=15 => thread::yield_now(),
334 _ => {
335 let remaining = self
336 .config
337 .checkout_timeout
338 .saturating_sub(started.elapsed());
339 let sleep = Duration::from_micros(50 * (1u64 << (attempt - 16).min(6)));
340 thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
341 }
342 }
343
344 attempt = attempt.saturating_add(1);
345 }
346 }
347
348 pub fn writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
353 let guard = self
354 .writer
355 .try_lock_for(self.config.checkout_timeout)
356 .ok_or_else(|| {
357 SqliteError::InvalidData(format!(
358 "timed out after {:?} waiting for sqlite writer connection",
359 self.config.checkout_timeout
360 ))
361 })?;
362 Ok(WriterGuard { guard })
363 }
364
365 pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
370 self.writer()
371 }
372
373 pub fn try_writer_nowait(&self) -> Result<WriterGuard<'_>, SqliteError> {
382 let guard = self.writer.try_lock().ok_or_else(|| {
383 SqliteError::InvalidData(
384 "writer connection busy (checkpoint skipped this tick)".to_string(),
385 )
386 })?;
387 Ok(WriterGuard { guard })
388 }
389
390 pub fn available_readers(&self) -> usize {
392 self.readers.len()
393 }
394
395 pub fn max_readers(&self) -> usize {
397 self.max_readers
398 }
399
400 pub fn config(&self) -> &PoolConfig {
402 &self.config
403 }
404
405 pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
429 if !self.config.write_queue_enabled {
430 return Ok(None);
431 }
432 if let Some(existing) = self.writer_task.get() {
435 return Ok(existing.clone());
436 }
437 if tokio::runtime::Handle::try_current().is_err() {
441 return Err(StorageError::WriterTaskNoRuntime);
442 }
443 Ok(self
444 .writer_task
445 .get_or_init(|| {
446 #[cfg(test)]
447 self.writer_task_spawn_count
448 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
449
450 match crate::writer_task::spawn(self, self.config.write_queue_capacity) {
451 Ok(handle) => Some(handle),
452 Err(e) => {
453 tracing::warn!(
454 error = %e,
455 "KHIVE_WRITE_QUEUE=1 but the writer task failed to spawn; \
456 writes fall back to the pool-mutex path"
457 );
458 None
459 }
460 }
461 })
462 .clone())
463 }
464
465 #[cfg(test)]
470 pub(crate) fn writer_task_spawn_count(&self) -> usize {
471 self.writer_task_spawn_count
472 .load(std::sync::atomic::Ordering::SeqCst)
473 }
474
475 pub fn legacy_conn(&self) -> Arc<Mutex<Connection>> {
480 Arc::clone(&self.writer)
481 }
482
483 fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
484 let path = self
485 .config
486 .path
487 .as_ref()
488 .expect("reader connections require a file-backed database");
489 open_reader_connection(path, &self.config)
490 }
491
492 pub fn open_standalone_writer(&self) -> Result<Connection, SqliteError> {
502 let path = self.config.path.as_ref().ok_or_else(|| {
503 SqliteError::InvalidData(
504 "in-memory databases do not support standalone connections".to_string(),
505 )
506 })?;
507
508 if self.config.read_only {
509 return Err(SqliteError::InvalidData(
510 "database is read-only: standalone write connections are not permitted".to_string(),
511 ));
512 }
513
514 let conn = Connection::open_with_flags(
515 path,
516 OpenFlags::SQLITE_OPEN_READ_WRITE
517 | OpenFlags::SQLITE_OPEN_NO_MUTEX
518 | OpenFlags::SQLITE_OPEN_URI,
519 )?;
520 conn.busy_timeout(self.config.busy_timeout)?;
521 conn.pragma_update(None, "foreign_keys", "ON")?;
522 conn.pragma_update(None, "synchronous", "NORMAL")?;
523 Ok(conn)
524 }
525
526 pub fn open_standalone_reader(&self) -> Result<Connection, SqliteError> {
531 let path = self.config.path.as_ref().ok_or_else(|| {
532 SqliteError::InvalidData(
533 "in-memory databases do not support standalone connections".to_string(),
534 )
535 })?;
536
537 let conn = Connection::open_with_flags(
538 path,
539 OpenFlags::SQLITE_OPEN_READ_ONLY
540 | OpenFlags::SQLITE_OPEN_NO_MUTEX
541 | OpenFlags::SQLITE_OPEN_URI,
542 )?;
543 conn.busy_timeout(self.config.busy_timeout)?;
544 conn.pragma_update(None, "foreign_keys", "ON")?;
545 conn.pragma_update(None, "synchronous", "NORMAL")?;
546 Ok(conn)
547 }
548
549 fn return_reader(&self, conn: Connection) {
550 if self.max_readers == 0 {
551 return;
552 }
553
554 let conn = if reset_reader_connection(&conn) && reader_connection_is_healthy(&conn) {
555 Some(conn)
556 } else {
557 close_connection_quietly(conn);
558 self.open_reader_connection().ok()
559 };
560
561 if let Some(conn) = conn {
562 if let Err(conn) = self.readers.push(conn) {
563 eprintln!(
564 "[sqlite-pool] reader pool queue full, discarding replacement connection"
565 );
566 close_connection_quietly(conn);
567 }
568 }
569 }
570}
571
572fn effective_reader_count(config: &PoolConfig, wal_enabled: bool) -> usize {
573 if config.path.is_some() && config.wal_mode && wal_enabled {
574 config.max_readers
575 } else {
576 0
577 }
578}
579
580fn open_writer_connection(config: &PoolConfig) -> Result<Connection, SqliteError> {
581 match config.path.as_ref() {
582 Some(path) => {
583 let flags = if config.read_only {
584 writer_read_only_open_flags()
585 } else {
586 writer_open_flags()
587 };
588 Connection::open_with_flags(path, flags).map_err(Into::into)
589 }
590 None => Connection::open_in_memory().map_err(Into::into),
591 }
592}
593
594fn open_reader_connection(path: &Path, config: &PoolConfig) -> Result<Connection, SqliteError> {
595 let conn = Connection::open_with_flags(path, reader_open_flags())?;
596 configure_reader_connection(&conn, config)?;
597 Ok(conn)
598}
599
600fn writer_open_flags() -> OpenFlags {
601 OpenFlags::SQLITE_OPEN_READ_WRITE
602 | OpenFlags::SQLITE_OPEN_CREATE
603 | OpenFlags::SQLITE_OPEN_URI
604 | OpenFlags::SQLITE_OPEN_NO_MUTEX
605}
606
607fn writer_read_only_open_flags() -> OpenFlags {
610 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
611}
612
613fn reader_open_flags() -> OpenFlags {
614 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
615}
616
617fn configure_writer_connection(
618 conn: &Connection,
619 config: &PoolConfig,
620) -> Result<bool, SqliteError> {
621 if config.read_only {
622 conn.pragma_update(None, "foreign_keys", "ON")?;
626 conn.busy_timeout(config.busy_timeout)?;
627 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
628 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
629 conn.pragma_update(None, "temp_store", "MEMORY")?;
630 conn.pragma_update(None, "query_only", "ON")?;
631
632 let wal_enabled =
633 config.wal_mode && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
634 return Ok(wal_enabled);
635 }
636
637 let wants_wal = config.path.is_some() && config.wal_mode;
638
639 if wants_wal {
640 conn.pragma_update(None, "journal_mode", "WAL")?;
641 }
642
643 conn.pragma_update(None, "synchronous", "NORMAL")?;
644 conn.pragma_update(None, "foreign_keys", "ON")?;
645 conn.busy_timeout(config.busy_timeout)?;
646 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
647 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
648 conn.pragma_update(None, "temp_store", "MEMORY")?;
649
650 let wal_enabled = wants_wal && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
651
652 if wal_enabled {
653 conn.pragma_update(None, "wal_autocheckpoint", config.wal_autocheckpoint_pages)?;
654 conn.pragma_update(None, "journal_size_limit", config.journal_size_limit_bytes)?;
655 }
656
657 Ok(wal_enabled)
658}
659
660fn configure_reader_connection(conn: &Connection, config: &PoolConfig) -> Result<(), SqliteError> {
661 conn.pragma_update(None, "foreign_keys", "ON")?;
662 conn.busy_timeout(config.busy_timeout)?;
663 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
664 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
665 conn.pragma_update(None, "temp_store", "MEMORY")?;
666 Ok(())
667}
668
669fn current_journal_mode(conn: &Connection) -> Result<String, SqliteError> {
670 conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
671 .map(|mode| mode.to_ascii_lowercase())
672 .map_err(Into::into)
673}
674
675fn reset_reader_connection(conn: &Connection) -> bool {
676 if conn.is_autocommit() {
677 return true;
678 }
679
680 match conn.execute_batch("ROLLBACK") {
681 Ok(()) => conn.is_autocommit(),
682 Err(rusqlite::Error::SqliteFailure(err, _)) => {
683 if matches!(
684 err.code,
685 rusqlite::ErrorCode::CannotOpen
686 | rusqlite::ErrorCode::DatabaseCorrupt
687 | rusqlite::ErrorCode::NotADatabase
688 | rusqlite::ErrorCode::DiskFull
689 ) {
690 return false;
691 }
692 conn.is_autocommit()
693 }
694 Err(_) => false,
695 }
696}
697
698fn reader_connection_is_healthy(conn: &Connection) -> bool {
699 match conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) {
700 Ok(_) => true,
701 Err(rusqlite::Error::SqliteFailure(err, _)) => !matches!(
702 err.code,
703 rusqlite::ErrorCode::CannotOpen
704 | rusqlite::ErrorCode::NotADatabase
705 | rusqlite::ErrorCode::DatabaseCorrupt
706 | rusqlite::ErrorCode::PermissionDenied
707 | rusqlite::ErrorCode::SystemIoFailure
708 ),
709 Err(_) => true,
710 }
711}
712
713fn close_connection_quietly(conn: Connection) {
714 match conn.close() {
715 Ok(()) => {}
716 Err((conn, _)) => drop(conn),
717 }
718}
719
720fn pool_exhausted_error(timeout: Duration, max_readers: usize) -> SqliteError {
721 rusqlite::Error::SqliteFailure(
722 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
723 Some(format!(
724 "Pool exhausted: no reader available after {timeout:?} (max_readers={max_readers})"
725 )),
726 )
727 .into()
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use serial_test::serial;
734
735 #[test]
736 #[serial]
737 fn pool_config_default_values_match_constants() {
738 let cfg = PoolConfig::default();
740 assert_eq!(
741 cfg.wal_autocheckpoint_pages,
742 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
743 );
744 assert_eq!(
745 cfg.journal_size_limit_bytes,
746 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
747 );
748 assert_eq!(cfg.busy_timeout, Duration::from_secs(30));
749 assert_eq!(cfg.checkout_timeout, Duration::from_secs(5));
750 }
751
752 #[test]
753 #[serial]
754 fn pool_config_env_override_wal_autocheckpoint() {
755 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "8000");
756 let cfg = PoolConfig::default();
757 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
758 assert_eq!(cfg.wal_autocheckpoint_pages, 8000);
759 }
760
761 #[test]
762 #[serial]
763 fn pool_config_env_override_journal_size_limit() {
764 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "134217728");
765 let cfg = PoolConfig::default();
766 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
767 assert_eq!(cfg.journal_size_limit_bytes, 134_217_728);
768 }
769
770 #[test]
771 #[serial]
772 fn pool_config_env_override_busy_timeout() {
773 std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "60");
774 let cfg = PoolConfig::default();
775 std::env::remove_var("KHIVE_BUSY_TIMEOUT_SECS");
776 assert_eq!(cfg.busy_timeout, Duration::from_secs(60));
777 }
778
779 #[test]
780 #[serial]
781 fn pool_config_env_override_checkout_timeout() {
782 std::env::set_var("KHIVE_CHECKOUT_TIMEOUT_SECS", "10");
783 let cfg = PoolConfig::default();
784 std::env::remove_var("KHIVE_CHECKOUT_TIMEOUT_SECS");
785 assert_eq!(cfg.checkout_timeout, Duration::from_secs(10));
786 }
787
788 #[test]
789 #[serial]
790 fn pool_config_write_queue_defaults_off() {
791 let cfg = PoolConfig::default();
792 assert!(!cfg.write_queue_enabled);
793 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
794 }
795
796 #[test]
797 #[serial]
798 fn pool_config_env_override_write_queue_enabled() {
799 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
800 let cfg = PoolConfig::default();
801 std::env::remove_var("KHIVE_WRITE_QUEUE");
802 assert!(cfg.write_queue_enabled);
803 }
804
805 #[test]
806 #[serial]
807 fn pool_config_env_override_write_queue_enabled_accepts_true_case_insensitive() {
808 std::env::set_var("KHIVE_WRITE_QUEUE", "True");
809 let cfg = PoolConfig::default();
810 std::env::remove_var("KHIVE_WRITE_QUEUE");
811 assert!(cfg.write_queue_enabled);
812 }
813
814 #[test]
815 #[serial]
816 fn pool_config_env_override_write_queue_capacity() {
817 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "64");
818 let cfg = PoolConfig::default();
819 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
820 assert_eq!(cfg.write_queue_capacity, 64);
821 }
822
823 #[test]
824 #[serial]
825 fn pool_config_env_invalid_write_queue_capacity_falls_back_to_default() {
826 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "0");
827 let cfg = PoolConfig::default();
828 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
829 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
830 }
831
832 #[test]
833 #[serial]
834 fn pool_config_env_invalid_falls_back_to_default() {
835 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "not_a_number");
836 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "");
837 let cfg = PoolConfig::default();
838 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
839 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
840 assert_eq!(
841 cfg.wal_autocheckpoint_pages,
842 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
843 );
844 assert_eq!(
845 cfg.journal_size_limit_bytes,
846 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
847 );
848 }
849
850 #[test]
851 fn file_backed_pool_opens_successfully() {
852 let dir = tempfile::tempdir().unwrap();
853 let path = dir.path().join("test_pool.db");
854 let cfg = PoolConfig {
855 path: Some(path.clone()),
856 ..PoolConfig::default()
857 };
858 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
859 assert!(path.exists());
860 assert!(pool.max_readers() > 0);
861 }
862
863 #[test]
864 fn in_memory_pool_degrades_to_single_connection() {
865 let cfg = PoolConfig {
866 path: None,
867 ..PoolConfig::default()
868 };
869 let pool = ConnectionPool::new(cfg).expect("in-memory pool should open");
870 assert_eq!(pool.max_readers(), 0);
871 }
872
873 #[test]
874 fn writer_checkout_and_release_works() {
875 let cfg = PoolConfig {
876 path: None,
877 ..PoolConfig::default()
878 };
879 let pool = ConnectionPool::new(cfg).unwrap();
880 {
881 let _writer = pool.writer().expect("writer checkout should succeed");
882 }
883 let _writer2 = pool
885 .writer()
886 .expect("second writer checkout should succeed");
887 }
888
889 #[test]
893 #[serial(tx_registry)]
894 fn writer_guard_transaction_registers_during_closure_only() {
895 let cfg = PoolConfig {
896 path: None,
897 ..PoolConfig::default()
898 };
899 let pool = ConnectionPool::new(cfg).unwrap();
900 let guard = pool.writer().unwrap();
901
902 let mut seen_during_closure = false;
903 let result: Result<(), SqliteError> = guard.transaction(|_conn| {
904 seen_during_closure = khive_storage::tx_registry::snapshot()
905 .iter()
906 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx"));
907 Ok(())
908 });
909 result.expect("transaction should commit");
910
911 assert!(
912 seen_during_closure,
913 "expected a writer_guard_tx entry visible inside the closure"
914 );
915 assert!(
916 !khive_storage::tx_registry::snapshot()
917 .iter()
918 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx")),
919 "expected the entry to be gone after the transaction completes"
920 );
921 }
922
923 #[test]
927 fn writer_task_handle_fails_loud_without_tokio_runtime() {
928 let dir = tempfile::tempdir().unwrap();
929 let path = dir.path().join("writer_task_no_runtime.db");
930 let cfg = PoolConfig {
931 path: Some(path),
932 write_queue_enabled: true,
933 ..PoolConfig::default()
934 };
935 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
936
937 let result = pool.writer_task_handle();
938
939 assert!(
940 matches!(result, Err(StorageError::WriterTaskNoRuntime)),
941 "expected Err(StorageError::WriterTaskNoRuntime) outside a Tokio \
942 runtime, got {result:?}"
943 );
944 assert_eq!(
945 pool.writer_task_spawn_count(),
946 0,
947 "the guard must reject before ever attempting tokio::spawn"
948 );
949 }
950}