1use crossbeam_queue::ArrayQueue;
3use parking_lot::Mutex;
4use rusqlite::{Connection, OpenFlags};
5use std::fs;
6use std::ops::{Deref, DerefMut};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::error::SqliteError;
13use crate::writer_task::WriterTaskHandle;
14use khive_storage::error::StorageError;
15use khive_storage::tx_registry::{DbIdentity, TxOrigin};
16
17const CACHE_SIZE_KIB: &str = "-65536";
18const MMAP_SIZE_BYTES: &str = "1073741824";
19const DEFAULT_READER_CAP: usize = 8;
20
21const DEFAULT_WAL_AUTOCHECKPOINT_PAGES: u32 = 4000;
22const DEFAULT_JOURNAL_SIZE_LIMIT_BYTES: i64 = 67_108_864; const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
24
25const TEST_HARNESS_ENV: &str = "KHIVE_TEST_HARNESS";
26
27#[derive(Clone, Debug)]
29pub struct PoolConfig {
30 pub path: Option<PathBuf>,
32 pub max_readers: usize,
34 pub wal_mode: bool,
36 pub busy_timeout: Duration,
40 pub checkout_timeout: Duration,
44 pub wal_autocheckpoint_pages: u32,
51 pub journal_size_limit_bytes: i64,
57 pub read_only: bool,
65 pub write_queue_enabled: bool,
77 pub write_queue_capacity: usize,
82}
83
84impl Default for PoolConfig {
85 fn default() -> Self {
86 Self {
87 path: None,
88 max_readers: std::thread::available_parallelism()
89 .map(|n| n.get())
90 .unwrap_or(1)
91 .clamp(1, DEFAULT_READER_CAP),
92 wal_mode: true,
93 busy_timeout: Duration::from_secs(
94 std::env::var("KHIVE_BUSY_TIMEOUT_SECS")
95 .ok()
96 .and_then(|v| v.parse::<u64>().ok())
97 .unwrap_or(30),
98 ),
99 checkout_timeout: Duration::from_secs(
100 std::env::var("KHIVE_CHECKOUT_TIMEOUT_SECS")
101 .ok()
102 .and_then(|v| v.parse::<u64>().ok())
103 .unwrap_or(5),
104 ),
105 wal_autocheckpoint_pages: std::env::var("KHIVE_WAL_AUTOCHECKPOINT_PAGES")
106 .ok()
107 .and_then(|v| v.parse::<u32>().ok())
108 .unwrap_or(DEFAULT_WAL_AUTOCHECKPOINT_PAGES),
109 journal_size_limit_bytes: std::env::var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES")
110 .ok()
111 .and_then(|v| v.parse::<i64>().ok())
112 .unwrap_or(DEFAULT_JOURNAL_SIZE_LIMIT_BYTES),
113 read_only: false,
114 write_queue_enabled: std::env::var("KHIVE_WRITE_QUEUE")
115 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
116 .unwrap_or(false),
117 write_queue_capacity: std::env::var("KHIVE_WRITE_QUEUE_CAPACITY")
118 .ok()
119 .and_then(|v| v.parse::<usize>().ok())
120 .filter(|&n| n > 0)
121 .unwrap_or(DEFAULT_WRITE_QUEUE_CAPACITY),
122 }
123 }
124}
125
126fn refuse_home_data_store_in_tests(config: &PoolConfig) -> Result<(), SqliteError> {
143 if std::env::var(TEST_HARNESS_ENV).as_deref() != Ok("1") {
144 return Ok(());
145 }
146
147 let Some(path) = config.path.as_deref() else {
148 return Ok(());
149 };
150 if path
151 .as_os_str()
152 .as_encoded_bytes()
153 .get(..5)
154 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file:"))
155 {
156 return Err(SqliteError::InvalidData(format!(
157 "test harness refused SQLite URI database path {}; use a filesystem path outside \
158 HOME/.khive (deliberate sessions against a real store run the built binary \
159 directly, outside the Cargo test environment)",
160 path.display()
161 )));
162 }
163
164 let Some(home) = std::env::var_os("HOME") else {
165 return Ok(());
166 };
167 let canonical_path = canonicalize_deepest_existing(path)?;
168 let canonical_home_data_dir =
169 canonicalize_deepest_existing(&PathBuf::from(home).join(".khive"))?;
170 if canonical_path.starts_with(&canonical_home_data_dir) {
171 return Err(SqliteError::InvalidData(format!(
172 "test harness refused to open SQLite database under HOME/.khive: {} \
173 (deliberate sessions against a real store run the built binary directly, \
174 outside the Cargo test environment)",
175 canonical_path.display()
176 )));
177 }
178 Ok(())
179}
180
181fn canonicalize_deepest_existing(path: &Path) -> Result<PathBuf, SqliteError> {
182 let absolute = if path.is_absolute() {
183 path.to_path_buf()
184 } else {
185 std::env::current_dir().map_err(SqliteError::Io)?.join(path)
186 };
187
188 for ancestor in absolute.ancestors() {
189 match fs::canonicalize(ancestor) {
190 Ok(mut canonical) => {
191 let missing = absolute.strip_prefix(ancestor).map_err(|error| {
192 SqliteError::InvalidData(format!(
193 "failed to preserve missing path components for {}: {error}",
194 absolute.display()
195 ))
196 })?;
197 canonical.push(missing);
198 return Ok(canonical);
199 }
200 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
201 Err(error) => {
202 return Err(SqliteError::InvalidData(format!(
203 "failed to canonicalize database path ancestor {}: {error}",
204 ancestor.display()
205 )));
206 }
207 }
208 }
209
210 Err(SqliteError::InvalidData(format!(
211 "database path has no canonicalizable ancestor: {}",
212 absolute.display()
213 )))
214}
215
216pub struct ConnectionPool {
227 writer: Arc<Mutex<Connection>>,
228 readers: ArrayQueue<Connection>,
229 max_readers: usize,
230 config: PoolConfig,
231 writer_task: OnceLock<Option<WriterTaskHandle>>,
236 origin: TxOrigin,
242 identity_path: Option<PathBuf>,
248 #[cfg(test)]
254 writer_task_spawn_count: std::sync::atomic::AtomicUsize,
255}
256
257enum ReaderLease<'pool> {
258 Pooled(Connection),
259 Shared(parking_lot::MutexGuard<'pool, Connection>),
260}
261
262pub struct ReaderGuard<'pool> {
265 lease: Option<ReaderLease<'pool>>,
266 pool: &'pool ConnectionPool,
267}
268
269impl<'pool> ReaderGuard<'pool> {
270 pub fn conn(&self) -> &Connection {
272 match self
273 .lease
274 .as_ref()
275 .expect("reader guard missing connection")
276 {
277 ReaderLease::Pooled(conn) => conn,
278 ReaderLease::Shared(guard) => guard,
279 }
280 }
281}
282
283impl<'pool> Deref for ReaderGuard<'pool> {
284 type Target = Connection;
285
286 fn deref(&self) -> &Self::Target {
287 self.conn()
288 }
289}
290
291impl<'pool> Drop for ReaderGuard<'pool> {
292 fn drop(&mut self) {
293 let Some(lease) = self.lease.take() else {
294 return;
295 };
296
297 match lease {
298 ReaderLease::Pooled(conn) => self.pool.return_reader(conn),
299 ReaderLease::Shared(_guard) => {}
300 }
301 }
302}
303
304pub struct WriterGuard<'pool> {
307 guard: parking_lot::MutexGuard<'pool, Connection>,
308 origin: TxOrigin,
312}
313
314impl<'pool> WriterGuard<'pool> {
315 pub fn conn(&self) -> &Connection {
317 &self.guard
318 }
319
320 pub fn conn_mut(&mut self) -> &mut Connection {
322 &mut self.guard
323 }
324
325 pub fn transaction<F, R>(&self, f: F) -> Result<R, SqliteError>
328 where
329 F: FnOnce(&Connection) -> Result<R, SqliteError>,
330 {
331 self.guard.execute_batch("BEGIN IMMEDIATE")?;
332 let _tx_handle = khive_storage::tx_registry::register_scoped(
333 Some("writer_guard_tx".to_string()),
334 self.origin.clone(),
335 );
336
337 match f(&self.guard) {
338 Ok(result) => {
339 if let Err(err) = self.guard.execute_batch("COMMIT") {
340 let _ = self.guard.execute_batch("ROLLBACK");
341 return Err(err.into());
342 }
343 Ok(result)
344 }
345 Err(err) => {
346 let _ = self.guard.execute_batch("ROLLBACK");
347 Err(err)
348 }
349 }
350 }
351}
352
353impl<'pool> Deref for WriterGuard<'pool> {
354 type Target = Connection;
355
356 fn deref(&self) -> &Self::Target {
357 self.conn()
358 }
359}
360
361impl<'pool> DerefMut for WriterGuard<'pool> {
362 fn deref_mut(&mut self) -> &mut Self::Target {
363 self.conn_mut()
364 }
365}
366
367impl ConnectionPool {
368 pub fn new(config: PoolConfig) -> Result<Self, SqliteError> {
376 refuse_home_data_store_in_tests(&config)?;
377
378 let writer = open_writer_connection(&config)?;
379 let wal_enabled = configure_writer_connection(&writer, &config)?;
380 let max_readers = effective_reader_count(&config, wal_enabled);
381
382 let readers = ArrayQueue::new(max_readers.max(1));
383
384 let (origin, identity_path) = match config.path.as_ref() {
385 Some(path) => {
386 let (identity, canonical) = mint_db_identity(path)?;
387 (TxOrigin::Database(identity), Some(canonical))
388 }
389 None => (TxOrigin::Memory, None),
390 };
391
392 let pool = Self {
393 writer: Arc::new(Mutex::new(writer)),
394 readers,
395 max_readers,
396 config,
397 writer_task: OnceLock::new(),
398 origin,
399 identity_path,
400 #[cfg(test)]
401 writer_task_spawn_count: std::sync::atomic::AtomicUsize::new(0),
402 };
403
404 for _ in 0..pool.max_readers {
405 let conn = pool.open_reader_connection()?;
406 pool.readers
407 .push(conn)
408 .expect("reader queue must have capacity during pool initialization");
409 }
410
411 Ok(pool)
412 }
413
414 pub fn reader(&self) -> Result<ReaderGuard<'_>, SqliteError> {
426 if self.max_readers == 0 {
427 return Ok(ReaderGuard {
428 lease: Some(ReaderLease::Shared(self.writer.lock())),
429 pool: self,
430 });
431 }
432
433 let started = Instant::now();
434 let mut attempt = 0u32;
435
436 loop {
437 if let Some(conn) = self.readers.pop() {
438 return Ok(ReaderGuard {
439 lease: Some(ReaderLease::Pooled(conn)),
440 pool: self,
441 });
442 }
443
444 if started.elapsed() >= self.config.checkout_timeout {
445 return Err(pool_exhausted_error(
446 self.config.checkout_timeout,
447 self.max_readers,
448 ));
449 }
450
451 match attempt {
452 0..=7 => {
453 let spins = 1usize << attempt;
454 for _ in 0..spins {
455 std::hint::spin_loop();
456 }
457 }
458 8..=15 => thread::yield_now(),
459 _ => {
460 let remaining = self
461 .config
462 .checkout_timeout
463 .saturating_sub(started.elapsed());
464 let sleep = Duration::from_micros(50 * (1u64 << (attempt - 16).min(6)));
465 thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
466 }
467 }
468
469 attempt = attempt.saturating_add(1);
470 }
471 }
472
473 pub fn writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
478 let guard = self
479 .writer
480 .try_lock_for(self.config.checkout_timeout)
481 .ok_or_else(|| {
482 SqliteError::InvalidData(format!(
483 "timed out after {:?} waiting for sqlite writer connection",
484 self.config.checkout_timeout
485 ))
486 })?;
487 Ok(WriterGuard {
488 guard,
489 origin: self.origin(),
490 })
491 }
492
493 pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
498 self.writer()
499 }
500
501 pub fn try_writer_nowait(&self) -> Result<WriterGuard<'_>, SqliteError> {
510 let guard = self.writer.try_lock().ok_or_else(|| {
511 SqliteError::InvalidData(
512 "writer connection busy (checkpoint skipped this tick)".to_string(),
513 )
514 })?;
515 Ok(WriterGuard {
516 guard,
517 origin: self.origin(),
518 })
519 }
520
521 pub fn available_readers(&self) -> usize {
523 self.readers.len()
524 }
525
526 pub fn max_readers(&self) -> usize {
528 self.max_readers
529 }
530
531 pub fn config(&self) -> &PoolConfig {
533 &self.config
534 }
535
536 pub fn origin(&self) -> TxOrigin {
542 self.origin.clone()
543 }
544
545 pub fn canonical_path(&self) -> Option<&Path> {
551 self.identity_path.as_deref()
552 }
553
554 pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
578 if !self.config.write_queue_enabled {
579 return Ok(None);
580 }
581 if let Some(existing) = self.writer_task.get() {
584 return Ok(existing.clone());
585 }
586 if tokio::runtime::Handle::try_current().is_err() {
590 return Err(StorageError::WriterTaskNoRuntime);
591 }
592 Ok(self
593 .writer_task
594 .get_or_init(|| {
595 #[cfg(test)]
596 self.writer_task_spawn_count
597 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
598
599 match crate::writer_task::spawn(self, self.config.write_queue_capacity) {
600 Ok(handle) => Some(handle),
601 Err(e) => {
602 tracing::warn!(
603 error = %e,
604 "KHIVE_WRITE_QUEUE=1 but the writer task failed to spawn; \
605 writes fall back to the pool-mutex path"
606 );
607 None
608 }
609 }
610 })
611 .clone())
612 }
613
614 #[cfg(test)]
619 pub(crate) fn writer_task_spawn_count(&self) -> usize {
620 self.writer_task_spawn_count
621 .load(std::sync::atomic::Ordering::SeqCst)
622 }
623
624 pub fn legacy_conn(&self) -> Arc<Mutex<Connection>> {
629 Arc::clone(&self.writer)
630 }
631
632 fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
633 let path = self
634 .config
635 .path
636 .as_ref()
637 .expect("reader connections require a file-backed database");
638 open_reader_connection(path, &self.config)
639 }
640
641 pub fn open_standalone_writer(&self) -> Result<Connection, SqliteError> {
651 let path = self.config.path.as_ref().ok_or_else(|| {
652 SqliteError::InvalidData(
653 "in-memory databases do not support standalone connections".to_string(),
654 )
655 })?;
656
657 if self.config.read_only {
658 return Err(SqliteError::InvalidData(
659 "database is read-only: standalone write connections are not permitted".to_string(),
660 ));
661 }
662
663 let conn = Connection::open_with_flags(
664 path,
665 OpenFlags::SQLITE_OPEN_READ_WRITE
666 | OpenFlags::SQLITE_OPEN_NO_MUTEX
667 | OpenFlags::SQLITE_OPEN_URI,
668 )?;
669 conn.busy_timeout(self.config.busy_timeout)?;
670 conn.pragma_update(None, "foreign_keys", "ON")?;
671 conn.pragma_update(None, "synchronous", "NORMAL")?;
672 Ok(conn)
673 }
674
675 pub fn open_standalone_reader(&self) -> Result<Connection, SqliteError> {
680 let path = self.config.path.as_ref().ok_or_else(|| {
681 SqliteError::InvalidData(
682 "in-memory databases do not support standalone connections".to_string(),
683 )
684 })?;
685
686 let conn = Connection::open_with_flags(
687 path,
688 OpenFlags::SQLITE_OPEN_READ_ONLY
689 | OpenFlags::SQLITE_OPEN_NO_MUTEX
690 | OpenFlags::SQLITE_OPEN_URI,
691 )?;
692 conn.busy_timeout(self.config.busy_timeout)?;
693 conn.pragma_update(None, "foreign_keys", "ON")?;
694 conn.pragma_update(None, "synchronous", "NORMAL")?;
695 Ok(conn)
696 }
697
698 fn return_reader(&self, conn: Connection) {
699 if self.max_readers == 0 {
700 return;
701 }
702
703 let conn = if reset_reader_connection(&conn) && reader_connection_is_healthy(&conn) {
704 Some(conn)
705 } else {
706 close_connection_quietly(conn);
707 self.open_reader_connection().ok()
708 };
709
710 if let Some(conn) = conn {
711 if let Err(conn) = self.readers.push(conn) {
712 eprintln!(
713 "[sqlite-pool] reader pool queue full, discarding replacement connection"
714 );
715 close_connection_quietly(conn);
716 }
717 }
718 }
719}
720
721const MAX_SYMLINK_DEPTH: u32 = 40;
726
727fn mint_db_identity(configured_path: &Path) -> Result<(DbIdentity, PathBuf), SqliteError> {
760 let absolute = if configured_path.is_absolute() {
761 configured_path.to_path_buf()
762 } else {
763 let cwd = std::env::current_dir().map_err(|e| {
764 SqliteError::InvalidData(format!(
765 "cannot mint database identity for {configured_path:?}: failed to resolve the \
766 process current directory: {e}"
767 ))
768 })?;
769 cwd.join(configured_path)
770 };
771
772 if absolute.exists() {
773 let canonical = absolute.canonicalize().map_err(|e| {
774 SqliteError::InvalidData(format!(
775 "cannot mint database identity: failed to canonicalize existing path \
776 {absolute:?}: {e}"
777 ))
778 })?;
779 return Ok((
780 DbIdentity::new(canonical.clone().into_os_string()),
781 canonical,
782 ));
783 }
784
785 let resolved_target = resolve_symlink_chain(&absolute)?;
786 let parent = resolved_target.parent().ok_or_else(|| {
787 SqliteError::InvalidData(format!(
788 "cannot mint database identity for {resolved_target:?}: path has no parent \
789 directory"
790 ))
791 })?;
792 let file_name = resolved_target.file_name().ok_or_else(|| {
793 SqliteError::InvalidData(format!(
794 "cannot mint database identity for {resolved_target:?}: path has no file name"
795 ))
796 })?;
797 let canonical_parent = parent.canonicalize().map_err(|e| {
798 SqliteError::InvalidData(format!(
799 "cannot mint database identity: parent directory {parent:?} of first-open path \
800 {resolved_target:?} does not exist or is inaccessible: {e}"
801 ))
802 })?;
803 let mut identity_path = canonical_parent;
804 identity_path.push(file_name);
805 Ok((
806 DbIdentity::new(identity_path.clone().into_os_string()),
807 identity_path,
808 ))
809}
810
811fn resolve_symlink_chain(path: &Path) -> Result<PathBuf, SqliteError> {
817 let mut current = path.to_path_buf();
818 for _ in 0..MAX_SYMLINK_DEPTH {
819 match fs::symlink_metadata(¤t) {
820 Ok(meta) if meta.file_type().is_symlink() => {
821 let target = fs::read_link(¤t).map_err(|e| {
822 SqliteError::InvalidData(format!(
823 "cannot mint database identity: failed to read symlink {current:?}: {e}"
824 ))
825 })?;
826 current = if target.is_absolute() {
827 target
828 } else {
829 match current.parent() {
830 Some(parent) => parent.join(&target),
831 None => target,
832 }
833 };
834 }
835 _ => return Ok(current),
836 }
837 }
838 Err(SqliteError::InvalidData(format!(
839 "cannot mint database identity for {path:?}: symlink chain exceeds \
840 {MAX_SYMLINK_DEPTH} levels"
841 )))
842}
843
844fn effective_reader_count(config: &PoolConfig, wal_enabled: bool) -> usize {
845 if config.path.is_some() && config.wal_mode && wal_enabled {
846 config.max_readers
847 } else {
848 0
849 }
850}
851
852fn open_writer_connection(config: &PoolConfig) -> Result<Connection, SqliteError> {
853 match config.path.as_ref() {
854 Some(path) => {
855 let flags = if config.read_only {
856 writer_read_only_open_flags()
857 } else {
858 writer_open_flags()
859 };
860 Connection::open_with_flags(path, flags).map_err(Into::into)
861 }
862 None => Connection::open_in_memory().map_err(Into::into),
863 }
864}
865
866fn open_reader_connection(path: &Path, config: &PoolConfig) -> Result<Connection, SqliteError> {
867 let conn = Connection::open_with_flags(path, reader_open_flags())?;
868 configure_reader_connection(&conn, config)?;
869 Ok(conn)
870}
871
872fn writer_open_flags() -> OpenFlags {
873 OpenFlags::SQLITE_OPEN_READ_WRITE
874 | OpenFlags::SQLITE_OPEN_CREATE
875 | OpenFlags::SQLITE_OPEN_URI
876 | OpenFlags::SQLITE_OPEN_NO_MUTEX
877}
878
879fn writer_read_only_open_flags() -> OpenFlags {
882 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
883}
884
885fn reader_open_flags() -> OpenFlags {
886 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX
887}
888
889fn configure_writer_connection(
890 conn: &Connection,
891 config: &PoolConfig,
892) -> Result<bool, SqliteError> {
893 if config.read_only {
894 conn.pragma_update(None, "foreign_keys", "ON")?;
898 conn.busy_timeout(config.busy_timeout)?;
899 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
900 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
901 conn.pragma_update(None, "temp_store", "MEMORY")?;
902 conn.pragma_update(None, "query_only", "ON")?;
903
904 let wal_enabled =
905 config.wal_mode && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
906 return Ok(wal_enabled);
907 }
908
909 let wants_wal = config.path.is_some() && config.wal_mode;
910
911 if wants_wal {
912 conn.pragma_update(None, "journal_mode", "WAL")?;
913 }
914
915 conn.pragma_update(None, "synchronous", "NORMAL")?;
916 conn.pragma_update(None, "foreign_keys", "ON")?;
917 conn.busy_timeout(config.busy_timeout)?;
918 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
919 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
920 conn.pragma_update(None, "temp_store", "MEMORY")?;
921
922 let wal_enabled = wants_wal && current_journal_mode(conn)?.eq_ignore_ascii_case("wal");
923
924 if wal_enabled {
925 conn.pragma_update(None, "wal_autocheckpoint", config.wal_autocheckpoint_pages)?;
926 conn.pragma_update(None, "journal_size_limit", config.journal_size_limit_bytes)?;
927 }
928
929 Ok(wal_enabled)
930}
931
932fn configure_reader_connection(conn: &Connection, config: &PoolConfig) -> Result<(), SqliteError> {
933 conn.pragma_update(None, "foreign_keys", "ON")?;
934 conn.busy_timeout(config.busy_timeout)?;
935 conn.pragma_update(None, "cache_size", CACHE_SIZE_KIB)?;
936 conn.pragma_update(None, "mmap_size", MMAP_SIZE_BYTES)?;
937 conn.pragma_update(None, "temp_store", "MEMORY")?;
938 Ok(())
939}
940
941fn current_journal_mode(conn: &Connection) -> Result<String, SqliteError> {
942 conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
943 .map(|mode| mode.to_ascii_lowercase())
944 .map_err(Into::into)
945}
946
947fn reset_reader_connection(conn: &Connection) -> bool {
948 if conn.is_autocommit() {
949 return true;
950 }
951
952 match conn.execute_batch("ROLLBACK") {
953 Ok(()) => conn.is_autocommit(),
954 Err(rusqlite::Error::SqliteFailure(err, _)) => {
955 if matches!(
956 err.code,
957 rusqlite::ErrorCode::CannotOpen
958 | rusqlite::ErrorCode::DatabaseCorrupt
959 | rusqlite::ErrorCode::NotADatabase
960 | rusqlite::ErrorCode::DiskFull
961 ) {
962 return false;
963 }
964 conn.is_autocommit()
965 }
966 Err(_) => false,
967 }
968}
969
970fn reader_connection_is_healthy(conn: &Connection) -> bool {
971 match conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) {
972 Ok(_) => true,
973 Err(rusqlite::Error::SqliteFailure(err, _)) => !matches!(
974 err.code,
975 rusqlite::ErrorCode::CannotOpen
976 | rusqlite::ErrorCode::NotADatabase
977 | rusqlite::ErrorCode::DatabaseCorrupt
978 | rusqlite::ErrorCode::PermissionDenied
979 | rusqlite::ErrorCode::SystemIoFailure
980 ),
981 Err(_) => true,
982 }
983}
984
985fn close_connection_quietly(conn: Connection) {
986 match conn.close() {
987 Ok(()) => {}
988 Err((conn, _)) => drop(conn),
989 }
990}
991
992fn pool_exhausted_error(timeout: Duration, max_readers: usize) -> SqliteError {
993 rusqlite::Error::SqliteFailure(
994 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
995 Some(format!(
996 "Pool exhausted: no reader available after {timeout:?} (max_readers={max_readers})"
997 )),
998 )
999 .into()
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use serial_test::serial;
1006
1007 struct CwdGuard {
1012 original: PathBuf,
1013 }
1014
1015 impl CwdGuard {
1016 fn enter(dir: &Path) -> Self {
1017 let original = std::env::current_dir().unwrap();
1018 std::env::set_current_dir(dir).unwrap();
1019 Self { original }
1020 }
1021 }
1022
1023 impl Drop for CwdGuard {
1024 fn drop(&mut self) {
1025 let _ = std::env::set_current_dir(&self.original);
1026 }
1027 }
1028
1029 const POOL_ENV_VARS: [&str; 6] = [
1030 "KHIVE_BUSY_TIMEOUT_SECS",
1031 "KHIVE_CHECKOUT_TIMEOUT_SECS",
1032 "KHIVE_WAL_AUTOCHECKPOINT_PAGES",
1033 "KHIVE_JOURNAL_SIZE_LIMIT_BYTES",
1034 "KHIVE_WRITE_QUEUE",
1035 "KHIVE_WRITE_QUEUE_CAPACITY",
1036 ];
1037
1038 struct PoolEnvGuard {
1039 saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
1040 }
1041
1042 impl PoolEnvGuard {
1043 fn capture() -> Self {
1044 Self {
1045 saved: POOL_ENV_VARS
1046 .into_iter()
1047 .map(|key| (key, std::env::var_os(key)))
1048 .collect(),
1049 }
1050 }
1051 }
1052
1053 impl Drop for PoolEnvGuard {
1054 fn drop(&mut self) {
1055 for (key, value) in &self.saved {
1056 match value {
1057 Some(value) => std::env::set_var(key, value),
1058 None => std::env::remove_var(key),
1059 }
1060 }
1061 }
1062 }
1063
1064 fn clear_pool_env() -> PoolEnvGuard {
1065 let guard = PoolEnvGuard::capture();
1066 for var in POOL_ENV_VARS {
1067 std::env::remove_var(var);
1068 }
1069 guard
1070 }
1071
1072 #[test]
1073 #[serial]
1074 fn pool_config_default_values_match_constants() {
1075 let _pool_env = clear_pool_env();
1079 let cfg = PoolConfig::default();
1080 assert_eq!(
1081 cfg.wal_autocheckpoint_pages,
1082 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
1083 );
1084 assert_eq!(
1085 cfg.journal_size_limit_bytes,
1086 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
1087 );
1088 assert_eq!(cfg.busy_timeout, Duration::from_secs(30));
1089 assert_eq!(cfg.checkout_timeout, Duration::from_secs(5));
1090 }
1091
1092 #[test]
1093 #[serial]
1094 fn pool_config_env_override_wal_autocheckpoint() {
1095 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "8000");
1096 let cfg = PoolConfig::default();
1097 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
1098 assert_eq!(cfg.wal_autocheckpoint_pages, 8000);
1099 }
1100
1101 #[test]
1102 #[serial]
1103 fn pool_config_env_override_journal_size_limit() {
1104 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "134217728");
1105 let cfg = PoolConfig::default();
1106 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
1107 assert_eq!(cfg.journal_size_limit_bytes, 134_217_728);
1108 }
1109
1110 #[test]
1111 #[serial]
1112 fn pool_config_env_override_busy_timeout() {
1113 std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "60");
1114 let cfg = PoolConfig::default();
1115 std::env::remove_var("KHIVE_BUSY_TIMEOUT_SECS");
1116 assert_eq!(cfg.busy_timeout, Duration::from_secs(60));
1117 }
1118
1119 #[test]
1120 #[serial]
1121 fn pool_config_env_override_checkout_timeout() {
1122 std::env::set_var("KHIVE_CHECKOUT_TIMEOUT_SECS", "10");
1123 let cfg = PoolConfig::default();
1124 std::env::remove_var("KHIVE_CHECKOUT_TIMEOUT_SECS");
1125 assert_eq!(cfg.checkout_timeout, Duration::from_secs(10));
1126 }
1127
1128 #[test]
1129 #[serial]
1130 fn pool_config_write_queue_defaults_off() {
1131 let _pool_env = clear_pool_env();
1132 let cfg = PoolConfig::default();
1133 assert!(!cfg.write_queue_enabled);
1134 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
1135 }
1136
1137 #[test]
1138 #[serial]
1139 fn clear_pool_env_restores_overrides_on_drop() {
1140 let _ambient_env = PoolEnvGuard::capture();
1141 std::env::set_var("KHIVE_BUSY_TIMEOUT_SECS", "73");
1142
1143 {
1144 let _pool_env = clear_pool_env();
1145 assert_eq!(std::env::var_os("KHIVE_BUSY_TIMEOUT_SECS"), None);
1146 }
1147
1148 assert_eq!(
1149 std::env::var_os("KHIVE_BUSY_TIMEOUT_SECS"),
1150 Some(std::ffi::OsString::from("73"))
1151 );
1152 }
1153
1154 #[test]
1155 #[serial]
1156 fn pool_config_env_override_write_queue_enabled() {
1157 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
1158 let cfg = PoolConfig::default();
1159 std::env::remove_var("KHIVE_WRITE_QUEUE");
1160 assert!(cfg.write_queue_enabled);
1161 }
1162
1163 #[test]
1164 #[serial]
1165 fn pool_config_env_override_write_queue_enabled_accepts_true_case_insensitive() {
1166 std::env::set_var("KHIVE_WRITE_QUEUE", "True");
1167 let cfg = PoolConfig::default();
1168 std::env::remove_var("KHIVE_WRITE_QUEUE");
1169 assert!(cfg.write_queue_enabled);
1170 }
1171
1172 #[test]
1173 #[serial]
1174 fn pool_config_env_override_write_queue_capacity() {
1175 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "64");
1176 let cfg = PoolConfig::default();
1177 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
1178 assert_eq!(cfg.write_queue_capacity, 64);
1179 }
1180
1181 #[test]
1182 #[serial]
1183 fn pool_config_env_invalid_write_queue_capacity_falls_back_to_default() {
1184 std::env::set_var("KHIVE_WRITE_QUEUE_CAPACITY", "0");
1185 let cfg = PoolConfig::default();
1186 std::env::remove_var("KHIVE_WRITE_QUEUE_CAPACITY");
1187 assert_eq!(cfg.write_queue_capacity, DEFAULT_WRITE_QUEUE_CAPACITY);
1188 }
1189
1190 #[test]
1191 #[serial]
1192 fn pool_config_env_invalid_falls_back_to_default() {
1193 std::env::set_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES", "not_a_number");
1194 std::env::set_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES", "");
1195 let cfg = PoolConfig::default();
1196 std::env::remove_var("KHIVE_WAL_AUTOCHECKPOINT_PAGES");
1197 std::env::remove_var("KHIVE_JOURNAL_SIZE_LIMIT_BYTES");
1198 assert_eq!(
1199 cfg.wal_autocheckpoint_pages,
1200 DEFAULT_WAL_AUTOCHECKPOINT_PAGES
1201 );
1202 assert_eq!(
1203 cfg.journal_size_limit_bytes,
1204 DEFAULT_JOURNAL_SIZE_LIMIT_BYTES
1205 );
1206 }
1207
1208 #[test]
1209 fn file_backed_pool_opens_successfully() {
1210 let dir = tempfile::tempdir().unwrap();
1211 let path = dir.path().join("test_pool.db");
1212 let cfg = PoolConfig {
1213 path: Some(path.clone()),
1214 ..PoolConfig::default()
1215 };
1216 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
1217 assert!(path.exists());
1218 assert!(pool.max_readers() > 0);
1219 }
1220
1221 #[test]
1222 fn in_memory_pool_degrades_to_single_connection() {
1223 let cfg = PoolConfig {
1224 path: None,
1225 ..PoolConfig::default()
1226 };
1227 let pool = ConnectionPool::new(cfg).expect("in-memory pool should open");
1228 assert_eq!(pool.max_readers(), 0);
1229 }
1230
1231 #[test]
1232 fn writer_checkout_and_release_works() {
1233 let cfg = PoolConfig {
1234 path: None,
1235 ..PoolConfig::default()
1236 };
1237 let pool = ConnectionPool::new(cfg).unwrap();
1238 {
1239 let _writer = pool.writer().expect("writer checkout should succeed");
1240 }
1241 let _writer2 = pool
1243 .writer()
1244 .expect("second writer checkout should succeed");
1245 }
1246
1247 #[test]
1251 #[serial(tx_registry)]
1252 fn writer_guard_transaction_registers_during_closure_only() {
1253 let cfg = PoolConfig {
1254 path: None,
1255 ..PoolConfig::default()
1256 };
1257 let pool = ConnectionPool::new(cfg).unwrap();
1258 let guard = pool.writer().unwrap();
1259
1260 let mut seen_during_closure = false;
1261 let result: Result<(), SqliteError> = guard.transaction(|_conn| {
1262 seen_during_closure = khive_storage::tx_registry::snapshot()
1263 .iter()
1264 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx"));
1265 Ok(())
1266 });
1267 result.expect("transaction should commit");
1268
1269 assert!(
1270 seen_during_closure,
1271 "expected a writer_guard_tx entry visible inside the closure"
1272 );
1273 assert!(
1274 !khive_storage::tx_registry::snapshot()
1275 .iter()
1276 .any(|(_, label)| label.as_deref() == Some("writer_guard_tx")),
1277 "expected the entry to be gone after the transaction completes"
1278 );
1279 }
1280
1281 #[test]
1285 fn writer_task_handle_fails_loud_without_tokio_runtime() {
1286 let dir = tempfile::tempdir().unwrap();
1287 let path = dir.path().join("writer_task_no_runtime.db");
1288 let cfg = PoolConfig {
1289 path: Some(path),
1290 write_queue_enabled: true,
1291 ..PoolConfig::default()
1292 };
1293 let pool = ConnectionPool::new(cfg).expect("file-backed pool should open");
1294
1295 let result = pool.writer_task_handle();
1296
1297 assert!(
1298 matches!(result, Err(StorageError::WriterTaskNoRuntime)),
1299 "expected Err(StorageError::WriterTaskNoRuntime) outside a Tokio \
1300 runtime, got {result:?}"
1301 );
1302 assert_eq!(
1303 pool.writer_task_spawn_count(),
1304 0,
1305 "the guard must reject before ever attempting tokio::spawn"
1306 );
1307 }
1308
1309 #[test]
1314 #[serial(pool_cwd)]
1315 fn mint_db_identity_alias_convergence() {
1316 let dir = tempfile::tempdir().unwrap();
1317 let real_dir = dir.path().join("real");
1318 fs::create_dir(&real_dir).unwrap();
1319 let db_path = real_dir.join("khive.db");
1320 fs::write(&db_path, b"").unwrap();
1321
1322 let dir_symlink = dir.path().join("dir_link");
1323 let file_symlink = dir.path().join("file_link.db");
1324 #[cfg(unix)]
1325 {
1326 std::os::unix::fs::symlink(&real_dir, &dir_symlink).unwrap();
1327 std::os::unix::fs::symlink(&db_path, &file_symlink).unwrap();
1328 }
1329
1330 let (via_real, canonical_real) = mint_db_identity(&db_path).unwrap();
1331
1332 let relative_result = {
1334 let _cwd = CwdGuard::enter(&real_dir);
1335 mint_db_identity(&PathBuf::from("khive.db"))
1336 };
1337 let (via_relative, canonical_relative) = relative_result.unwrap();
1338 assert_eq!(canonical_real, canonical_relative);
1339 assert_eq!(via_real, via_relative);
1340
1341 #[cfg(unix)]
1342 {
1343 let (via_dir_symlink, canonical_dir_symlink) =
1344 mint_db_identity(&dir_symlink.join("khive.db")).unwrap();
1345 assert_eq!(canonical_real, canonical_dir_symlink);
1346 assert_eq!(via_real, via_dir_symlink);
1347
1348 let (via_file_symlink, canonical_file_symlink) =
1349 mint_db_identity(&file_symlink).unwrap();
1350 assert_eq!(canonical_real, canonical_file_symlink);
1351 assert_eq!(via_real, via_file_symlink);
1352 }
1353
1354 let bare_name_result = {
1356 let _cwd = CwdGuard::enter(&real_dir);
1357 mint_db_identity(&PathBuf::from("khive.db"))
1358 };
1359 let (via_bare_name, canonical_bare_name) = bare_name_result.unwrap();
1360 assert_eq!(canonical_real, canonical_bare_name);
1361 assert_eq!(via_real, via_bare_name);
1362 }
1363
1364 #[test]
1376 #[serial(pool_cwd)]
1377 fn sidecar_dir_for_alias_convergence() {
1378 let dir = tempfile::tempdir().unwrap();
1379 let real_dir = dir.path().join("real");
1380 fs::create_dir(&real_dir).unwrap();
1381 let db_path = real_dir.join("khive.db");
1382 fs::write(&db_path, b"").unwrap();
1383
1384 let dir_symlink = dir.path().join("dir_link");
1385 let file_symlink = dir.path().join("file_link.db");
1386 #[cfg(unix)]
1387 {
1388 std::os::unix::fs::symlink(&real_dir, &dir_symlink).unwrap();
1389 std::os::unix::fs::symlink(&db_path, &file_symlink).unwrap();
1390 }
1391
1392 let pool_for = |path: &Path| -> Arc<ConnectionPool> {
1393 let cfg = PoolConfig {
1394 path: Some(path.to_path_buf()),
1395 ..PoolConfig::default()
1396 };
1397 Arc::new(ConnectionPool::new(cfg).expect("file-backed pool should open"))
1398 };
1399 let sidecar_of = |pool: &ConnectionPool| -> PathBuf {
1400 crate::walpin::sidecar_dir_for(pool.canonical_path().expect("file-backed pool"))
1401 };
1402
1403 let via_real = pool_for(&db_path);
1404 let sidecar_real = sidecar_of(&via_real);
1405
1406 let via_relative = {
1407 let _cwd = CwdGuard::enter(&real_dir);
1408 pool_for(Path::new("khive.db"))
1409 };
1410 assert_eq!(
1411 sidecar_real,
1412 sidecar_of(&via_relative),
1413 "a relative spelling of the same database must derive the same sidecar directory"
1414 );
1415
1416 #[cfg(unix)]
1417 {
1418 let via_dir_symlink = pool_for(&dir_symlink.join("khive.db"));
1419 assert_eq!(
1420 sidecar_real,
1421 sidecar_of(&via_dir_symlink),
1422 "opening through a directory symlink must derive the same sidecar directory"
1423 );
1424
1425 let via_file_symlink = pool_for(&file_symlink);
1426 assert_eq!(
1427 sidecar_real,
1428 sidecar_of(&via_file_symlink),
1429 "opening through a file-level symlink must derive the same sidecar directory"
1430 );
1431 }
1432
1433 let via_bare_name = {
1434 let _cwd = CwdGuard::enter(&real_dir);
1435 pool_for(Path::new("khive.db"))
1436 };
1437 assert_eq!(
1438 sidecar_real,
1439 sidecar_of(&via_bare_name),
1440 "a bare file name resolved against the current directory must derive the same \
1441 sidecar directory"
1442 );
1443 }
1444
1445 #[cfg(unix)]
1451 #[test]
1452 fn mint_db_identity_dangling_symlink_first_open_convergence() {
1453 let dir = tempfile::tempdir().unwrap();
1454 let target = dir.path().join("target.db");
1455 let link = dir.path().join("link.db");
1456 std::os::unix::fs::symlink(&target, &link).unwrap();
1457 assert!(!target.exists(), "target must not exist yet (dangling)");
1458
1459 let (via_dangling_link, canonical_via_link) = mint_db_identity(&link).unwrap();
1460
1461 fs::write(&target, b"").unwrap();
1464 let (via_target, canonical_via_target) = mint_db_identity(&target).unwrap();
1465
1466 assert_eq!(canonical_via_link, canonical_via_target);
1467 assert_eq!(via_dangling_link, via_target);
1468 }
1469
1470 #[test]
1473 fn mint_db_identity_missing_parent_fails() {
1474 let dir = tempfile::tempdir().unwrap();
1475 let missing = dir.path().join("nonexistent_subdir").join("khive.db");
1476 let result = mint_db_identity(&missing);
1477 assert!(
1478 result.is_err(),
1479 "minting must fail when the parent directory does not exist"
1480 );
1481 }
1482
1483 #[cfg(unix)]
1486 #[test]
1487 fn mint_db_identity_non_utf8_path_round_trips() {
1488 use std::ffi::OsStr;
1489 use std::os::unix::ffi::OsStrExt;
1490
1491 let dir = tempfile::tempdir().unwrap();
1492 let raw_name = OsStr::from_bytes(b"khive-\xffdb.sqlite");
1494 let db_path = dir.path().join(raw_name);
1495 if let Err(e) = fs::write(&db_path, b"") {
1500 eprintln!(
1501 "skipping mint_db_identity_non_utf8_path_round_trips: filesystem rejected a \
1502 non-UTF-8 file name ({e}); this platform's filesystem does not support the \
1503 case under test"
1504 );
1505 return;
1506 }
1507
1508 let (identity, canonical) = mint_db_identity(&db_path).unwrap();
1509 assert_eq!(canonical.file_name().unwrap(), raw_name);
1510
1511 let (identity_again, canonical_again) = mint_db_identity(&db_path).unwrap();
1512 assert_eq!(identity, identity_again);
1513 assert_eq!(canonical, canonical_again);
1514 }
1515}