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    ///
408    /// Exactly one writer task exists per `ConnectionPool` (per DB file) no
409    /// matter how many stores or namespaces are constructed over it: the
410    /// `OnceLock` runs its init closure at most once, so concurrent callers
411    /// either race to run it once or block on the in-flight init and then
412    /// all receive a clone of the same resulting handle. This is what makes
413    /// the write queue an actual single-writer core rather than one writer
414    /// task per store — a per-store writer task would let concurrent
415    /// migrated stores over the same pool spawn independent writer
416    /// connections that contend with each other at `BEGIN IMMEDIATE`,
417    /// defeating the point of Component A.
418    ///
419    /// Returns `Ok(None)` if the flag is off, or if the writer task failed to
420    /// spawn for a reason other than a missing runtime (for example, an
421    /// in-memory pool has no standalone-connection support) — callers fall
422    /// back to the legacy pool-mutex write path in either case. A spawn
423    /// failure is logged once here (at first access), not once per store.
424    ///
425    /// Returns `Err(StorageError::WriterTaskNoRuntime)` instead of panicking
426    /// when `write_queue_enabled` is set but this is the first access and no
427    /// Tokio runtime is available on the calling thread (checked via
428    /// [`tokio::runtime::Handle::try_current`]) — spawning the writer task
429    /// requires `tokio::spawn`, which panics outside a runtime. Callers that
430    /// already treat a missing writer task as best-effort (construction-time
431    /// degrade to the legacy path, matching slice 1's documented policy) can
432    /// collapse this into `None` with `.ok().flatten()`; callers that need to
433    /// fail loud on a genuine misconfiguration (write queue requested but no
434    /// runtime to run it on) can propagate the `Err` directly.
435    pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
436        if !self.config.write_queue_enabled {
437            return Ok(None);
438        }
439        // Fast path: already resolved (spawned, degraded, or off) by an
440        // earlier call — no need to re-check the runtime.
441        if let Some(existing) = self.writer_task.get() {
442            return Ok(existing.clone());
443        }
444        // Not yet initialized and the flag is on: spawning requires
445        // `tokio::spawn`, which panics outside a runtime context. Check
446        // first and fail loud with a typed error instead.
447        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    /// Test-only: how many times the writer-task init closure actually ran.
473    /// Must be at most 1 for the pool's whole lifetime, regardless of how
474    /// many times [`Self::writer_task_handle`] is called or how many stores
475    /// are constructed over this pool.
476    #[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    /// Compatibility method: returns the writer connection wrapped in `Arc<Mutex>`.
483    ///
484    /// WARNING: This exists only for backward compatibility with code that
485    /// calls `store.conn()`. New code should use `reader()` and `writer()`.
486    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    /// Open a standalone read-write connection to the same file-backed database.
500    ///
501    /// Stores whose trait methods take `Send + 'static` closures (executed via
502    /// `spawn_blocking`) cannot hold the pooled `WriterGuard`'s `MutexGuard`
503    /// across the call — it opens an independent connection instead. This
504    /// must still honor `PoolConfig::read_only`: opening
505    /// `SQLITE_OPEN_READ_WRITE` unconditionally here would let a read-only
506    /// backend's graph/event/text stores bypass the flag that the pooled
507    /// writer enforces via `query_only`.
508    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    /// Open a standalone read-only connection to the same file-backed database.
534    ///
535    /// Companion to `open_standalone_writer` for stores that also need an
536    /// independent reader connection outside the pooled reader queue.
537    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
614/// Read-only writer-slot open flags: no `SQLITE_OPEN_CREATE`, so a missing
615/// path is rejected rather than silently created.
616fn 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        // Read-only writer slot: skip write-intent PRAGMAs (journal_mode,
630        // wal_autocheckpoint, journal_size_limit all require write access to
631        // change) and lock the connection down with query_only instead.
632        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        // Ensure defaults are not accidentally changed.
746        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        // After drop, writer should be re-acquirable.
891        let _writer2 = pool
892            .writer()
893            .expect("second writer checkout should succeed");
894    }
895
896    /// ADR-091 Plank 0: `WriterGuard::transaction` registers an entry with the
897    /// shared open-transaction registry for the duration of the closure, and
898    /// deregisters it once the closure (and its commit/rollback) completes.
899    ///
900    /// `#[serial(tx_registry)]`: the open-transaction registry is a
901    /// process-wide singleton (`khive_storage::tx_registry`) shared across
902    /// every test in this binary. This test filters by its own unique label
903    /// so it is not vulnerable to another test's entry being reported as
904    /// "oldest", but it still shares the same `tx_registry` serial group as
905    /// `checkpoint.rs`'s and `sql_bridge.rs`'s registry tests for
906    /// defense-in-depth against cross-test interference.
907    #[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    /// ADR-067 Component A runtime-handle guard: `write_queue_enabled` is set
939    /// but the calling thread has no Tokio runtime context, so spawning the
940    /// writer task (which requires `tokio::spawn`) is impossible.
941    /// `writer_task_handle` must return a clean typed error instead of
942    /// panicking.
943    ///
944    /// Deliberately a plain `#[test]` (no Tokio runtime) — mirrors
945    /// `writer_task::spawn_fails_on_in_memory_pool`'s shape: the failure must
946    /// be observable without ever entering an async context, since entering
947    /// one here would defeat the point of the test.
948    #[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}