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> {
436 if !self.config.write_queue_enabled {
437 return Ok(None);
438 }
439 if let Some(existing) = self.writer_task.get() {
442 return Ok(existing.clone());
443 }
444 if tokio::runtime::Handle::try_current().is_err() {
448 return Err(StorageError::WriterTaskNoRuntime);
449 }
450 Ok(self
451 .writer_task
452 .get_or_init(|| {
453 #[cfg(test)]
454 self.writer_task_spawn_count
455 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
456
457 match crate::writer_task::spawn(self, self.config.write_queue_capacity) {
458 Ok(handle) => Some(handle),
459 Err(e) => {
460 tracing::warn!(
461 error = %e,
462 "KHIVE_WRITE_QUEUE=1 but the writer task failed to spawn; \
463 writes fall back to the pool-mutex path"
464 );
465 None
466 }
467 }
468 })
469 .clone())
470 }
471
472 #[cfg(test)]
477 pub(crate) fn writer_task_spawn_count(&self) -> usize {
478 self.writer_task_spawn_count
479 .load(std::sync::atomic::Ordering::SeqCst)
480 }
481
482 pub fn legacy_conn(&self) -> Arc<Mutex<Connection>> {
487 Arc::clone(&self.writer)
488 }
489
490 fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
491 let path = self
492 .config
493 .path
494 .as_ref()
495 .expect("reader connections require a file-backed database");
496 open_reader_connection(path, &self.config)
497 }
498
499 pub fn open_standalone_writer(&self) -> Result<Connection, SqliteError> {
509 let path = self.config.path.as_ref().ok_or_else(|| {
510 SqliteError::InvalidData(
511 "in-memory databases do not support standalone connections".to_string(),
512 )
513 })?;
514
515 if self.config.read_only {
516 return Err(SqliteError::InvalidData(
517 "database is read-only: standalone write connections are not permitted".to_string(),
518 ));
519 }
520
521 let conn = Connection::open_with_flags(
522 path,
523 OpenFlags::SQLITE_OPEN_READ_WRITE
524 | OpenFlags::SQLITE_OPEN_NO_MUTEX
525 | OpenFlags::SQLITE_OPEN_URI,
526 )?;
527 conn.busy_timeout(self.config.busy_timeout)?;
528 conn.pragma_update(None, "foreign_keys", "ON")?;
529 conn.pragma_update(None, "synchronous", "NORMAL")?;
530 Ok(conn)
531 }
532
533 pub fn open_standalone_reader(&self) -> Result<Connection, SqliteError> {
538 let path = self.config.path.as_ref().ok_or_else(|| {
539 SqliteError::InvalidData(
540 "in-memory databases do not support standalone connections".to_string(),
541 )
542 })?;
543
544 let conn = Connection::open_with_flags(
545 path,
546 OpenFlags::SQLITE_OPEN_READ_ONLY
547 | OpenFlags::SQLITE_OPEN_NO_MUTEX
548 | OpenFlags::SQLITE_OPEN_URI,
549 )?;
550 conn.busy_timeout(self.config.busy_timeout)?;
551 conn.pragma_update(None, "foreign_keys", "ON")?;
552 conn.pragma_update(None, "synchronous", "NORMAL")?;
553 Ok(conn)
554 }
555
556 fn return_reader(&self, conn: Connection) {
557 if self.max_readers == 0 {
558 return;
559 }
560
561 let conn = if reset_reader_connection(&conn) && reader_connection_is_healthy(&conn) {
562 Some(conn)
563 } else {
564 close_connection_quietly(conn);
565 self.open_reader_connection().ok()
566 };
567
568 if let Some(conn) = conn {
569 if let Err(conn) = self.readers.push(conn) {
570 eprintln!(
571 "[sqlite-pool] reader pool queue full, discarding replacement connection"
572 );
573 close_connection_quietly(conn);
574 }
575 }
576 }
577}
578
579fn effective_reader_count(config: &PoolConfig, wal_enabled: bool) -> usize {
580 if config.path.is_some() && config.wal_mode && wal_enabled {
581 config.max_readers
582 } else {
583 0
584 }
585}
586
587fn open_writer_connection(config: &PoolConfig) -> Result<Connection, SqliteError> {
588 match config.path.as_ref() {
589 Some(path) => {
590 let flags = if config.read_only {
591 writer_read_only_open_flags()
592 } else {
593 writer_open_flags()
594 };
595 Connection::open_with_flags(path, flags).map_err(Into::into)
596 }
597 None => Connection::open_in_memory().map_err(Into::into),
598 }
599}
600
601fn open_reader_connection(path: &Path, config: &PoolConfig) -> Result<Connection, SqliteError> {
602 let conn = Connection::open_with_flags(path, reader_open_flags())?;
603 configure_reader_connection(&conn, config)?;
604 Ok(conn)
605}
606
607fn writer_open_flags() -> OpenFlags {
608 OpenFlags::SQLITE_OPEN_READ_WRITE
609 | OpenFlags::SQLITE_OPEN_CREATE
610 | OpenFlags::SQLITE_OPEN_URI
611 | OpenFlags::SQLITE_OPEN_NO_MUTEX
612}
613
614fn writer_read_only_open_flags() -> OpenFlags {
617 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
618}
619
620fn reader_open_flags() -> OpenFlags {
621 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
622}
623
624fn configure_writer_connection(
625 conn: &Connection,
626 config: &PoolConfig,
627) -> Result<bool, SqliteError> {
628 if config.read_only {
629 conn.pragma_update(None, "foreign_keys", "ON")?;
633 conn.busy_timeout(config.busy_timeout)?;
634 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
635 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
636 conn.pragma_update(None, "temp_store", "MEMORY")?;
637 conn.pragma_update(None, "query_only", "ON")?;
638
639 let wal_enabled =
640 config.wal_mode && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
641 return Ok(wal_enabled);
642 }
643
644 let wants_wal = config.path.is_some() && config.wal_mode;
645
646 if wants_wal {
647 conn.pragma_update(None, "journal_mode", "WAL")?;
648 }
649
650 conn.pragma_update(None, "synchronous", "NORMAL")?;
651 conn.pragma_update(None, "foreign_keys", "ON")?;
652 conn.busy_timeout(config.busy_timeout)?;
653 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
654 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
655 conn.pragma_update(None, "temp_store", "MEMORY")?;
656
657 let wal_enabled = wants_wal && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
658
659 if wal_enabled {
660 conn.pragma_update(None, "wal_autocheckpoint", config.wal_autocheckpoint_pages)?;
661 conn.pragma_update(None, "journal_size_limit", config.journal_size_limit_bytes)?;
662 }
663
664 Ok(wal_enabled)
665}
666
667fn configure_reader_connection(conn: &Connection, config: &PoolConfig) -> Result<(), SqliteError> {
668 conn.pragma_update(None, "foreign_keys", "ON")?;
669 conn.busy_timeout(config.busy_timeout)?;
670 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
671 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
672 conn.pragma_update(None, "temp_store", "MEMORY")?;
673 Ok(())
674}
675
676fn current_journal_mode(conn: &Connection) -> Result<String, SqliteError> {
677 conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
678 .map(|mode| mode.to_ascii_lowercase())
679 .map_err(Into::into)
680}
681
682fn reset_reader_connection(conn: &Connection) -> bool {
683 if conn.is_autocommit() {
684 return true;
685 }
686
687 match conn.execute_batch("ROLLBACK") {
688 Ok(()) => conn.is_autocommit(),
689 Err(rusqlite::Error::SqliteFailure(err, _)) => {
690 if matches!(
691 err.code,
692 rusqlite::ErrorCode::CannotOpen
693 | rusqlite::ErrorCode::DatabaseCorrupt
694 | rusqlite::ErrorCode::NotADatabase
695 | rusqlite::ErrorCode::DiskFull
696 ) {
697 return false;
698 }
699 conn.is_autocommit()
700 }
701 Err(_) => false,
702 }
703}
704
705fn reader_connection_is_healthy(conn: &Connection) -> bool {
706 match conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) {
707 Ok(_) => true,
708 Err(rusqlite::Error::SqliteFailure(err, _)) => !matches!(
709 err.code,
710 rusqlite::ErrorCode::CannotOpen
711 | rusqlite::ErrorCode::NotADatabase
712 | rusqlite::ErrorCode::DatabaseCorrupt
713 | rusqlite::ErrorCode::PermissionDenied
714 | rusqlite::ErrorCode::SystemIoFailure
715 ),
716 Err(_) => true,
717 }
718}
719
720fn close_connection_quietly(conn: Connection) {
721 match conn.close() {
722 Ok(()) => {}
723 Err((conn, _)) => drop(conn),
724 }
725}
726
727fn pool_exhausted_error(timeout: Duration, max_readers: usize) -> SqliteError {
728 rusqlite::Error::SqliteFailure(
729 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
730 Some(format!(
731 "Pool exhausted: no reader available after {timeout:?} (max_readers={max_readers})"
732 )),
733 )
734 .into()
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use serial_test::serial;
741
742 #[test]
743 #[serial]
744 fn pool_config_default_values_match_constants() {
745 let cfg = PoolConfig::default();
747 assert_eq!(
748 cfg.wal_autocheckpoint_pages,
749 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
750 );
751 assert_eq!(
752 cfg.journal_size_limit_bytes,
753 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
754 );
755 assert_eq!(cfg.busy_timeout, Duration::from_secs(30));
756 assert_eq!(cfg.checkout_timeout, Duration::from_secs(5));
757 }
758
759 #[test]
760 #[serial]
761 fn pool_config_env_override_wal_autocheckpoint() {
762 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "8000");
763 let cfg = PoolConfig::default();
764 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
765 assert_eq!(cfg.wal_autocheckpoint_pages, 8000);
766 }
767
768 #[test]
769 #[serial]
770 fn pool_config_env_override_journal_size_limit() {
771 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "134217728");
772 let cfg = PoolConfig::default();
773 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
774 assert_eq!(cfg.journal_size_limit_bytes, 134_217_728);
775 }
776
777 #[test]
778 #[serial]
779 fn pool_config_env_override_busy_timeout() {
780 std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "60");
781 let cfg = PoolConfig::default();
782 std::env::remove_var("KHIVE_BUSY_TIMEOUT_SECS");
783 assert_eq!(cfg.busy_timeout, Duration::from_secs(60));
784 }
785
786 #[test]
787 #[serial]
788 fn pool_config_env_override_checkout_timeout() {
789 std::env::set_var("KHIVE_CHECKOUT_TIMEOUT_SECS", "10");
790 let cfg = PoolConfig::default();
791 std::env::remove_var("KHIVE_CHECKOUT_TIMEOUT_SECS");
792 assert_eq!(cfg.checkout_timeout, Duration::from_secs(10));
793 }
794
795 #[test]
796 #[serial]
797 fn pool_config_write_queue_defaults_off() {
798 let cfg = PoolConfig::default();
799 assert!(!cfg.write_queue_enabled);
800 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
801 }
802
803 #[test]
804 #[serial]
805 fn pool_config_env_override_write_queue_enabled() {
806 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
807 let cfg = PoolConfig::default();
808 std::env::remove_var("KHIVE_WRITE_QUEUE");
809 assert!(cfg.write_queue_enabled);
810 }
811
812 #[test]
813 #[serial]
814 fn pool_config_env_override_write_queue_enabled_accepts_true_case_insensitive() {
815 std::env::set_var("KHIVE_WRITE_QUEUE", "True");
816 let cfg = PoolConfig::default();
817 std::env::remove_var("KHIVE_WRITE_QUEUE");
818 assert!(cfg.write_queue_enabled);
819 }
820
821 #[test]
822 #[serial]
823 fn pool_config_env_override_write_queue_capacity() {
824 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "64");
825 let cfg = PoolConfig::default();
826 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
827 assert_eq!(cfg.write_queue_capacity, 64);
828 }
829
830 #[test]
831 #[serial]
832 fn pool_config_env_invalid_write_queue_capacity_falls_back_to_default() {
833 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "0");
834 let cfg = PoolConfig::default();
835 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
836 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
837 }
838
839 #[test]
840 #[serial]
841 fn pool_config_env_invalid_falls_back_to_default() {
842 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "not_a_number");
843 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "");
844 let cfg = PoolConfig::default();
845 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
846 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
847 assert_eq!(
848 cfg.wal_autocheckpoint_pages,
849 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
850 );
851 assert_eq!(
852 cfg.journal_size_limit_bytes,
853 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
854 );
855 }
856
857 #[test]
858 fn file_backed_pool_opens_successfully() {
859 let dir = tempfile::tempdir().unwrap();
860 let path = dir.path().join("test_pool.db");
861 let cfg = PoolConfig {
862 path: Some(path.clone()),
863 ..PoolConfig::default()
864 };
865 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
866 assert!(path.exists());
867 assert!(pool.max_readers() > 0);
868 }
869
870 #[test]
871 fn in_memory_pool_degrades_to_single_connection() {
872 let cfg = PoolConfig {
873 path: None,
874 ..PoolConfig::default()
875 };
876 let pool = ConnectionPool::new(cfg).expect("in-memory pool should open");
877 assert_eq!(pool.max_readers(), 0);
878 }
879
880 #[test]
881 fn writer_checkout_and_release_works() {
882 let cfg = PoolConfig {
883 path: None,
884 ..PoolConfig::default()
885 };
886 let pool = ConnectionPool::new(cfg).unwrap();
887 {
888 let _writer = pool.writer().expect("writer checkout should succeed");
889 }
890 let _writer2 = pool
892 .writer()
893 .expect("second writer checkout should succeed");
894 }
895
896 #[test]
908 #[serial(tx_registry)]
909 fn writer_guard_transaction_registers_during_closure_only() {
910 let cfg = PoolConfig {
911 path: None,
912 ..PoolConfig::default()
913 };
914 let pool = ConnectionPool::new(cfg).unwrap();
915 let guard = pool.writer().unwrap();
916
917 let mut seen_during_closure = false;
918 let result: Result<(), SqliteError> = guard.transaction(|_conn| {
919 seen_during_closure = khive_storage::tx_registry::snapshot()
920 .iter()
921 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx"));
922 Ok(())
923 });
924 result.expect("transaction should commit");
925
926 assert!(
927 seen_during_closure,
928 "expected a writer_guard_tx entry visible inside the closure"
929 );
930 assert!(
931 !khive_storage::tx_registry::snapshot()
932 .iter()
933 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx")),
934 "expected the entry to be gone after the transaction completes"
935 );
936 }
937
938 #[test]
949 fn writer_task_handle_fails_loud_without_tokio_runtime() {
950 let dir = tempfile::tempdir().unwrap();
951 let path = dir.path().join("writer_task_no_runtime.db");
952 let cfg = PoolConfig {
953 path: Some(path),
954 write_queue_enabled: true,
955 ..PoolConfig::default()
956 };
957 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
958
959 let result = pool.writer_task_handle();
960
961 assert!(
962 matches!(result, Err(StorageError::WriterTaskNoRuntime)),
963 "expected Err(StorageError::WriterTaskNoRuntime) outside a Tokio \
964 runtime, got {result:?}"
965 );
966 assert_eq!(
967 pool.writer_task_spawn_count(),
968 0,
969 "the guard must reject before ever attempting tokio::spawn"
970 );
971 }
972}