Skip to main content

khive_db/
pool.rs

1//! Connection pool for SQLite: one exclusive writer, N concurrent readers.
2use crossbeam_queue::ArrayQueue;
3use parking_lot::Mutex;
4use rusqlite::{Connection, OpenFlags};
5use std::ops::{Deref, DerefMut};
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, 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; // 64 MiB
21const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
22
23/// Configuration for the connection pool.
24#[derive(Clone, Debug)]
25pub struct PoolConfig {
26    /// Database path. None = in-memory (pool degrades to single connection).
27    pub path: Option<PathBuf>,
28    /// Number of reader connections (default: min(num_cpus, 8)).
29    pub max_readers: usize,
30    /// WAL mode (must be true for pooling to work; default: true).
31    pub wal_mode: bool,
32    /// Busy timeout per connection (default: 30s).
33    ///
34    /// Overridable via `KHIVE_BUSY_TIMEOUT_SECS`.
35    pub busy_timeout: Duration,
36    /// Time to wait for a reader connection before returning an error (default: 5s).
37    ///
38    /// Overridable via `KHIVE_CHECKOUT_TIMEOUT_SECS`.
39    pub checkout_timeout: Duration,
40    /// Number of WAL pages that triggers an automatic checkpoint.
41    ///
42    /// Maps to `PRAGMA wal_autocheckpoint`. The default (4000 pages, ~16 MiB
43    /// at SQLite's default 4 KiB page size) matches the pre-config behaviour.
44    ///
45    /// Overridable via `KHIVE_WAL_AUTOCHECKPOINT_PAGES`.
46    pub wal_autocheckpoint_pages: u32,
47    /// Maximum WAL journal size in bytes before SQLite resets the WAL.
48    ///
49    /// Maps to `PRAGMA journal_size_limit`. Default: 64 MiB.
50    ///
51    /// Overridable via `KHIVE_JOURNAL_SIZE_LIMIT_BYTES`.
52    pub journal_size_limit_bytes: i64,
53    /// Open the database read-only (default: false).
54    ///
55    /// When true, the pool's writer connection is opened with
56    /// `SQLITE_OPEN_READ_ONLY` (no `SQLITE_OPEN_CREATE`, so a missing path is
57    /// rejected instead of created) and `PRAGMA query_only = ON` is set on
58    /// every connection that can execute SQL. Reader connections are already
59    /// opened read-only regardless of this flag.
60    pub read_only: bool,
61    /// Route migrated store write paths through the single-writer
62    /// `WriterTask` channel (ADR-067 Component A) instead of the legacy
63    /// per-call pool-mutex/standalone-connection path. Off by default.
64    ///
65    /// Slice 1 wires exactly one path (`SqlEntityStore::upsert_entities`)
66    /// behind this flag; enabling it does not yet claim ADR-067's
67    /// single-writer guarantee — other write paths still open their own
68    /// writers until later slices migrate them.
69    ///
70    /// Overridable via `KHIVE_WRITE_QUEUE` (`"1"` or `"true"`,
71    /// case-insensitive, enables it; anything else, or unset, leaves it off).
72    pub write_queue_enabled: bool,
73    /// Bounded channel capacity for the `WriterTask` write queue.
74    ///
75    /// Overridable via `KHIVE_WRITE_QUEUE_CAPACITY`. Default: 256 pending
76    /// operations (ADR-067 Component A recommended default).
77    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
122/// A read-write connection pool for SQLite.
123///
124/// Architecture:
125/// - 1 writer connection protected by a Mutex (exclusive access)
126/// - N reader connections in a lock-free queue (concurrent access)
127/// - All connections share the same database file in WAL mode
128///
129/// For in-memory databases, or when WAL mode is disabled/unavailable, the pool
130/// degrades to single-connection mode and routes all operations through the
131/// writer connection.
132pub struct ConnectionPool {
133    writer: Arc<Mutex<Connection>>,
134    readers: ArrayQueue<Connection>,
135    max_readers: usize,
136    config: PoolConfig,
137    /// The pool-wide ADR-067 Component A writer task, spawned lazily and at
138    /// most once per pool (per DB file) via [`Self::writer_task_handle`] —
139    /// see that method's doc comment for why this lives here rather than on
140    /// each store.
141    writer_task: OnceLock<Option<WriterTaskHandle>>,
142    /// Test-only instrumentation: counts how many times the writer-task
143    /// init closure actually ran. Must never exceed 1 per pool no matter how
144    /// many stores are constructed over it — that is the invariant
145    /// `OnceLock::get_or_init` exists to guarantee, and what
146    /// `pool.rs`'s and `entity_tests.rs`'s one-writer-per-pool tests assert.
147    #[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
156/// A reader connection checked out from the pool.
157/// Returns the connection to the pool on drop.
158pub struct ReaderGuard<'pool> {
159    lease: Option<ReaderLease<'pool>>,
160    pool: &'pool ConnectionPool,
161}
162
163impl<'pool> ReaderGuard<'pool> {
164    /// Access the connection.
165    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
198/// A writer connection checked out from the pool.
199/// The Mutex ensures only one writer at a time.
200pub struct WriterGuard<'pool> {
201    guard: parking_lot::MutexGuard<'pool, Connection>,
202}
203
204impl<'pool> WriterGuard<'pool> {
205    /// Returns a shared reference to the underlying connection.
206    pub fn conn(&self) -> &Connection {
207        &self.guard
208    }
209
210    /// Returns a mutable reference to the underlying connection.
211    pub fn conn_mut(&mut self) -> &mut Connection {
212        &mut self.guard
213    }
214
215    /// Execute a write transaction.
216    /// Wraps the closure in BEGIN IMMEDIATE ... COMMIT.
217    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    /// Create a new connection pool.
256    ///
257    /// Opens 1 writer + N reader connections to the same database when pooling
258    /// is enabled. All connections are configured consistently (busy timeout,
259    /// foreign keys, cache, mmap, temp store). For in-memory databases, or when
260    /// WAL is disabled or unavailable, the pool falls back to single-connection
261    /// mode.
262    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    /// Check out a reader connection.
290    ///
291    /// Tries to pop from the lock-free queue. If empty, spins briefly then
292    /// waits with exponential backoff up to `checkout_timeout`.
293    ///
294    /// # Deadlock Warning
295    ///
296    /// In degraded mode (WAL unavailable, `max_readers == 0`), this method locks
297    /// the writer mutex. If the calling thread already holds a [`WriterGuard`],
298    /// this will deadlock (parking_lot `Mutex` is not reentrant). Never call
299    /// `reader()` while holding a `WriterGuard` on the same pool.
300    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    /// Check out the writer connection.
349    ///
350    /// Waits up to `checkout_timeout` for the writer Mutex and returns
351    /// `Err(SqliteError::InvalidData)` if the timeout is exceeded.
352    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    /// Non-panicking writer checkout.
366    ///
367    /// Returns `Err` on timeout instead of panicking. Use this in request
368    /// handlers where a 500 is preferable to crashing the process.
369    pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
370        self.writer()
371    }
372
373    /// Zero-wait writer checkout for background tasks.
374    ///
375    /// Uses `try_lock()` (no timeout, no spin) — returns `Err` immediately when
376    /// any other caller holds the writer Mutex. Background tasks (e.g. the WAL
377    /// checkpoint task) MUST use this instead of `try_writer` so that a busy
378    /// writer causes the background task to skip its current tick rather than
379    /// stalling for up to `checkout_timeout` (default 5s) while write traffic
380    /// is in progress.
381    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    /// Get the current number of available reader connections.
391    pub fn available_readers(&self) -> usize {
392        self.readers.len()
393    }
394
395    /// Get the total number of reader connections in the pool.
396    pub fn max_readers(&self) -> usize {
397        self.max_readers
398    }
399
400    /// Return the pool configuration.
401    pub fn config(&self) -> &PoolConfig {
402        &self.config
403    }
404
405    /// Return the pool-wide ADR-067 Component A writer task, spawning it
406    /// lazily on first access if `PoolConfig::write_queue_enabled` is set.
407    /// Exactly one writer task exists per `ConnectionPool` (per DB file); see
408    /// crates/khive-db/docs/api/pool.md#connectionpoolwriter_task_handle--single-writer-task-rationale
409    /// for why a per-store writer task would defeat the single-writer
410    /// guarantee.
411    ///
412    /// Returns `Ok(None)` if the flag is off, or if the writer task failed to
413    /// spawn for a reason other than a missing runtime (for example, an
414    /// in-memory pool has no standalone-connection support) — callers fall
415    /// back to the legacy pool-mutex write path in either case. A spawn
416    /// failure is logged once here (at first access), not once per store.
417    ///
418    /// Returns `Err(StorageError::WriterTaskNoRuntime)` instead of panicking
419    /// when `write_queue_enabled` is set but this is the first access and no
420    /// Tokio runtime is available on the calling thread (checked via
421    /// [`tokio::runtime::Handle::try_current`]) — spawning the writer task
422    /// requires `tokio::spawn`, which panics outside a runtime. Callers that
423    /// already treat a missing writer task as best-effort (construction-time
424    /// degrade to the legacy path, matching slice 1's documented policy) can
425    /// collapse this into `None` with `.ok().flatten()`; callers that need to
426    /// fail loud on a genuine misconfiguration (write queue requested but no
427    /// runtime to run it on) can propagate the `Err` directly.
428    pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
429        if !self.config.write_queue_enabled {
430            return Ok(None);
431        }
432        // Fast path: already resolved (spawned, degraded, or off) by an
433        // earlier call — no need to re-check the runtime.
434        if let Some(existing) = self.writer_task.get() {
435            return Ok(existing.clone());
436        }
437        // Not yet initialized and the flag is on: spawning requires
438        // `tokio::spawn`, which panics outside a runtime context. Check
439        // first and fail loud with a typed error instead.
440        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    /// Test-only: how many times the writer-task init closure actually ran.
466    /// Must be at most 1 for the pool's whole lifetime, regardless of how
467    /// many times [`Self::writer_task_handle`] is called or how many stores
468    /// are constructed over this pool.
469    #[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    /// Compatibility method: returns the writer connection wrapped in `Arc<Mutex>`.
476    ///
477    /// WARNING: This exists only for backward compatibility with code that
478    /// calls `store.conn()`. New code should use `reader()` and `writer()`.
479    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    /// Open a standalone read-write connection to the same file-backed database.
493    ///
494    /// Stores whose trait methods take `Send + 'static` closures (executed via
495    /// `spawn_blocking`) cannot hold the pooled `WriterGuard`'s `MutexGuard`
496    /// across the call — it opens an independent connection instead. This
497    /// must still honor `PoolConfig::read_only`: opening
498    /// `SQLITE_OPEN_READ_WRITE` unconditionally here would let a read-only
499    /// backend's graph/event/text stores bypass the flag that the pooled
500    /// writer enforces via `query_only`.
501    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    /// Open a standalone read-only connection to the same file-backed database.
527    ///
528    /// Companion to `open_standalone_writer` for stores that also need an
529    /// independent reader connection outside the pooled reader queue.
530    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
607/// Read-only writer-slot open flags: no `SQLITE_OPEN_CREATE`, so a missing
608/// path is rejected rather than silently created.
609fn 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        // Read-only writer slot: skip write-intent PRAGMAs (journal_mode,
623        // wal_autocheckpoint, journal_size_limit all require write access to
624        // change) and lock the connection down with query_only instead.
625        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        // Ensure defaults are not accidentally changed.
739        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        // After drop, writer should be re-acquirable.
884        let _writer2 = pool
885            .writer()
886            .expect("second writer checkout should succeed");
887    }
888
889    /// ADR-091 Plank 0: `WriterGuard::transaction` registers/deregisters a
890    /// tx_registry entry around the closure. See
891    /// crates/khive-db/docs/api/pool.md#writer_guard_transaction_registers_during_closure_only
892    #[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    /// ADR-067 Component A: `writer_task_handle` must fail loud (typed
924    /// error, not panic) with no Tokio runtime available. See
925    /// crates/khive-db/docs/api/pool.md#writer_task_handle_fails_loud_without_tokio_runtime
926    #[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}