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::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; // 64 MiB
23const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
24
25const TEST_HARNESS_ENV: &str = "KHIVE_TEST_HARNESS";
26
27/// Configuration for the connection pool.
28#[derive(Clone, Debug)]
29pub struct PoolConfig {
30    /// Database path. None = in-memory (pool degrades to single connection).
31    pub path: Option<PathBuf>,
32    /// Number of reader connections (default: min(num_cpus, 8)).
33    pub max_readers: usize,
34    /// WAL mode (must be true for pooling to work; default: true).
35    pub wal_mode: bool,
36    /// Busy timeout per connection (default: 30s).
37    ///
38    /// Overridable via `KHIVE_BUSY_TIMEOUT_SECS`.
39    pub busy_timeout: Duration,
40    /// Time to wait for a reader connection before returning an error (default: 5s).
41    ///
42    /// Overridable via `KHIVE_CHECKOUT_TIMEOUT_SECS`.
43    pub checkout_timeout: Duration,
44    /// Number of WAL pages that triggers an automatic checkpoint.
45    ///
46    /// Maps to `PRAGMA wal_autocheckpoint`. The default (4000 pages, ~16 MiB
47    /// at SQLite's default 4 KiB page size) matches the pre-config behaviour.
48    ///
49    /// Overridable via `KHIVE_WAL_AUTOCHECKPOINT_PAGES`.
50    pub wal_autocheckpoint_pages: u32,
51    /// Maximum WAL journal size in bytes before SQLite resets the WAL.
52    ///
53    /// Maps to `PRAGMA journal_size_limit`. Default: 64 MiB.
54    ///
55    /// Overridable via `KHIVE_JOURNAL_SIZE_LIMIT_BYTES`.
56    pub journal_size_limit_bytes: i64,
57    /// Open the database read-only (default: false).
58    ///
59    /// When true, the pool's writer connection is opened with
60    /// `SQLITE_OPEN_READ_ONLY` (no `SQLITE_OPEN_CREATE`, so a missing path is
61    /// rejected instead of created) and `PRAGMA query_only = ON` is set on
62    /// every connection that can execute SQL. Reader connections are already
63    /// opened read-only regardless of this flag.
64    pub read_only: bool,
65    /// Route migrated store write paths through the single-writer
66    /// `WriterTask` channel (ADR-067 Component A) instead of the legacy
67    /// per-call pool-mutex/standalone-connection path. Off by default.
68    ///
69    /// Slice 1 wires exactly one path (`SqlEntityStore::upsert_entities`)
70    /// behind this flag; enabling it does not yet claim ADR-067's
71    /// single-writer guarantee — other write paths still open their own
72    /// writers until later slices migrate them.
73    ///
74    /// Overridable via `KHIVE_WRITE_QUEUE` (`"1"` or `"true"`,
75    /// case-insensitive, enables it; anything else, or unset, leaves it off).
76    pub write_queue_enabled: bool,
77    /// Bounded channel capacity for the `WriterTask` write queue.
78    ///
79    /// Overridable via `KHIVE_WRITE_QUEUE_CAPACITY`. Default: 256 pending
80    /// operations (ADR-067 Component A recommended default).
81    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
126/// Prevent Cargo-launched tests and test subprocesses from opening the
127/// operator's default data tree in every build profile. Activation is solely
128/// the runtime `KHIVE_TEST_HARNESS=1` marker; production/installed binaries do
129/// not receive that workspace Cargo environment.
130///
131/// There is deliberately no environment override: any inheritable escape
132/// hatch set for one Cargo invocation leaks into the next `cargo test` in the
133/// same shell and re-opens the store the guard exists to protect. A deliberate
134/// session against the real store runs the built binary directly (for example
135/// `target/release/...` or an installed binary), which never receives the
136/// workspace Cargo environment and therefore never trips this guard.
137/// Existing path ancestors are canonicalized before comparison, resolving
138/// traversal, symlinks, and filesystem-provided case (including APFS case
139/// folding). Missing trailing components remain lexical because they have no
140/// filesystem identity yet. SQLite URI paths are rejected rather than trying
141/// to reproduce SQLite's URI normalization rules.
142fn 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
216/// A read-write connection pool for SQLite.
217///
218/// Architecture:
219/// - 1 writer connection protected by a Mutex (exclusive access)
220/// - N reader connections in a lock-free queue (concurrent access)
221/// - All connections share the same database file in WAL mode
222///
223/// For in-memory databases, or when WAL mode is disabled/unavailable, the pool
224/// degrades to single-connection mode and routes all operations through the
225/// writer connection.
226pub struct ConnectionPool {
227    writer: Arc<Mutex<Connection>>,
228    readers: ArrayQueue<Connection>,
229    max_readers: usize,
230    config: PoolConfig,
231    /// The pool-wide ADR-067 Component A writer task, spawned lazily and at
232    /// most once per pool (per DB file) via [`Self::writer_task_handle`] —
233    /// see that method's doc comment for why this lives here rather than on
234    /// each store.
235    writer_task: OnceLock<Option<WriterTaskHandle>>,
236    /// This pool's ADR-091 backend-scoped attribution origin, minted exactly
237    /// once at construction (see [`mint_db_identity`]): `Database(_)` for a
238    /// file-backed pool, `Memory` for an in-memory pool. Every
239    /// `tx_registry::register_scoped` call site in this crate reaches its
240    /// origin through [`Self::origin`] rather than re-deriving it.
241    origin: TxOrigin,
242    /// The canonical path `origin`'s `DbIdentity` was minted from, `None` for
243    /// an in-memory pool. `DbIdentity` is deliberately opaque (no path
244    /// accessor) — filesystem consumers that need the actual path (sidecar
245    /// derivation) use this, the same canonical value the identity was
246    /// minted from, via [`Self::canonical_path`].
247    identity_path: Option<PathBuf>,
248    /// Test-only instrumentation: counts how many times the writer-task
249    /// init closure actually ran. Must never exceed 1 per pool no matter how
250    /// many stores are constructed over it — that is the invariant
251    /// `OnceLock::get_or_init` exists to guarantee, and what
252    /// `pool.rs`'s and `entity_tests.rs`'s one-writer-per-pool tests assert.
253    #[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
262/// A reader connection checked out from the pool.
263/// Returns the connection to the pool on drop.
264pub struct ReaderGuard<'pool> {
265    lease: Option<ReaderLease<'pool>>,
266    pool: &'pool ConnectionPool,
267}
268
269impl<'pool> ReaderGuard<'pool> {
270    /// Access the connection.
271    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
304/// A writer connection checked out from the pool.
305/// The Mutex ensures only one writer at a time.
306pub struct WriterGuard<'pool> {
307    guard: parking_lot::MutexGuard<'pool, Connection>,
308    /// The origin (ADR-091 backend-scoped attribution) of the pool this
309    /// guard was checked out from, carried so `transaction` can register its
310    /// span with the correct origin without holding a `&ConnectionPool`.
311    origin: TxOrigin,
312}
313
314impl<'pool> WriterGuard<'pool> {
315    /// Returns a shared reference to the underlying connection.
316    pub fn conn(&self) -> &Connection {
317        &self.guard
318    }
319
320    /// Returns a mutable reference to the underlying connection.
321    pub fn conn_mut(&mut self) -> &mut Connection {
322        &mut self.guard
323    }
324
325    /// Execute a write transaction.
326    /// Wraps the closure in BEGIN IMMEDIATE ... COMMIT.
327    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    /// Create a new connection pool.
369    ///
370    /// Opens 1 writer + N reader connections to the same database when pooling
371    /// is enabled. All connections are configured consistently (busy timeout,
372    /// foreign keys, cache, mmap, temp store). For in-memory databases, or when
373    /// WAL is disabled or unavailable, the pool falls back to single-connection
374    /// mode.
375    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    /// Check out a reader connection.
415    ///
416    /// Tries to pop from the lock-free queue. If empty, spins briefly then
417    /// waits with exponential backoff up to `checkout_timeout`.
418    ///
419    /// # Deadlock Warning
420    ///
421    /// In degraded mode (WAL unavailable, `max_readers == 0`), this method locks
422    /// the writer mutex. If the calling thread already holds a [`WriterGuard`],
423    /// this will deadlock (parking_lot `Mutex` is not reentrant). Never call
424    /// `reader()` while holding a `WriterGuard` on the same pool.
425    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    /// Check out the writer connection.
474    ///
475    /// Waits up to `checkout_timeout` for the writer Mutex and returns
476    /// `Err(SqliteError::InvalidData)` if the timeout is exceeded.
477    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    /// Non-panicking writer checkout.
494    ///
495    /// Returns `Err` on timeout instead of panicking. Use this in request
496    /// handlers where a 500 is preferable to crashing the process.
497    pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
498        self.writer()
499    }
500
501    /// Zero-wait writer checkout for background tasks.
502    ///
503    /// Uses `try_lock()` (no timeout, no spin) — returns `Err` immediately when
504    /// any other caller holds the writer Mutex. Background tasks (e.g. the WAL
505    /// checkpoint task) MUST use this instead of `try_writer` so that a busy
506    /// writer causes the background task to skip its current tick rather than
507    /// stalling for up to `checkout_timeout` (default 5s) while write traffic
508    /// is in progress.
509    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    /// Get the current number of available reader connections.
522    pub fn available_readers(&self) -> usize {
523        self.readers.len()
524    }
525
526    /// Get the total number of reader connections in the pool.
527    pub fn max_readers(&self) -> usize {
528        self.max_readers
529    }
530
531    /// Return the pool configuration.
532    pub fn config(&self) -> &PoolConfig {
533        &self.config
534    }
535
536    /// This pool's ADR-091 backend-scoped attribution origin (ADR-091,
537    /// backend-scoped WAL-pin attribution design note): `Database(_)` for a
538    /// file-backed pool, `Memory` for an in-memory pool. Every
539    /// `tx_registry::register_scoped` call site threaded in this crate
540    /// passes this value as the span's origin.
541    pub fn origin(&self) -> TxOrigin {
542        self.origin.clone()
543    }
544
545    /// The canonical path this pool's `origin()` identity was minted from,
546    /// `None` for an in-memory pool. `DbIdentity` has no path accessor by
547    /// design; sidecar derivation and other filesystem consumers use this —
548    /// the same canonical value the identity was minted from — instead of
549    /// re-deriving a path from the raw configured one.
550    pub fn canonical_path(&self) -> Option<&Path> {
551        self.identity_path.as_deref()
552    }
553
554    /// Return the pool-wide ADR-067 Component A writer task, spawning it
555    /// lazily on first access if `PoolConfig::write_queue_enabled` is set.
556    /// Exactly one writer task exists per `ConnectionPool` (per DB file); see
557    /// crates/khive-db/docs/api/pool.md#connectionpoolwriter_task_handle--single-writer-task-rationale
558    /// for why a per-store writer task would defeat the single-writer
559    /// guarantee.
560    ///
561    /// Returns `Ok(None)` if the flag is off, or if the writer task failed to
562    /// spawn for a reason other than a missing runtime (for example, an
563    /// in-memory pool has no standalone-connection support) — callers fall
564    /// back to the legacy pool-mutex write path in either case. A spawn
565    /// failure is logged once here (at first access), not once per store.
566    ///
567    /// Returns `Err(StorageError::WriterTaskNoRuntime)` instead of panicking
568    /// when `write_queue_enabled` is set but this is the first access and no
569    /// Tokio runtime is available on the calling thread (checked via
570    /// [`tokio::runtime::Handle::try_current`]) — spawning the writer task
571    /// requires `tokio::spawn`, which panics outside a runtime. Callers that
572    /// already treat a missing writer task as best-effort (construction-time
573    /// degrade to the legacy path, matching slice 1's documented policy) can
574    /// collapse this into `None` with `.ok().flatten()`; callers that need to
575    /// fail loud on a genuine misconfiguration (write queue requested but no
576    /// runtime to run it on) can propagate the `Err` directly.
577    pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
578        if !self.config.write_queue_enabled {
579            return Ok(None);
580        }
581        // Fast path: already resolved (spawned, degraded, or off) by an
582        // earlier call — no need to re-check the runtime.
583        if let Some(existing) = self.writer_task.get() {
584            return Ok(existing.clone());
585        }
586        // Not yet initialized and the flag is on: spawning requires
587        // `tokio::spawn`, which panics outside a runtime context. Check
588        // first and fail loud with a typed error instead.
589        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    /// Test-only: how many times the writer-task init closure actually ran.
615    /// Must be at most 1 for the pool's whole lifetime, regardless of how
616    /// many times [`Self::writer_task_handle`] is called or how many stores
617    /// are constructed over this pool.
618    #[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    /// Compatibility method: returns the writer connection wrapped in `Arc<Mutex>`.
625    ///
626    /// WARNING: This exists only for backward compatibility with code that
627    /// calls `store.conn()`. New code should use `reader()` and `writer()`.
628    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    /// Open a standalone read-write connection to the same file-backed database.
642    ///
643    /// Stores whose trait methods take `Send + 'static` closures (executed via
644    /// `spawn_blocking`) cannot hold the pooled `WriterGuard`'s `MutexGuard`
645    /// across the call — it opens an independent connection instead. This
646    /// must still honor `PoolConfig::read_only`: opening
647    /// `SQLITE_OPEN_READ_WRITE` unconditionally here would let a read-only
648    /// backend's graph/event/text stores bypass the flag that the pooled
649    /// writer enforces via `query_only`.
650    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    /// Open a standalone read-only connection to the same file-backed database.
676    ///
677    /// Companion to `open_standalone_writer` for stores that also need an
678    /// independent reader connection outside the pooled reader queue.
679    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
721/// Bound on the final-component symlink chain [`resolve_symlink_chain`]
722/// follows before failing loud, mirroring the OS's own loop limit (e.g.
723/// Linux/macOS `ELOOP`, commonly 40 hops) rather than looping forever on a
724/// cycle.
725const MAX_SYMLINK_DEPTH: u32 = 40;
726
727/// Mint the canonical [`DbIdentity`] for a configured database path.
728///
729/// The sole minting point (ADR-091 backend-scoped attribution design note):
730/// `tx_registry` origin threading and `sidecar_dir_for` re-keying both
731/// consume this function's output rather than re-deriving it. Operationally
732/// three steps:
733///
734/// 1. A relative configured path is resolved against the process's current
735///    directory BEFORE any canonicalization — a bare file name has an empty
736///    parent, and canonicalizing an empty path fails.
737/// 2. If the resolved path exists, canonicalize the full path: this
738///    resolves symlinks at every level, including a symlink at the
739///    database-file level itself (a `link.sqlite` pointing at the real file
740///    mints the target's identity).
741/// 3. If the resolved path does not yet exist (first open), a dangling
742///    file-level symlink is a valid first-open state — SQLite creates the
743///    target through the link on first write, and minting the link's own
744///    name would diverge from a later opener using the target path
745///    directly. The final-component symlink chain is followed to its
746///    ultimate target first (bounded, see [`MAX_SYMLINK_DEPTH`]), then that
747///    target's PARENT directory is canonicalized and the file name is
748///    appended unchanged — the same pattern `FsBlobStore` uses for its
749///    root-keyed write locks (`stores/blob.rs::write_lock_for_root`), and
750///    for the same reason: `Path::canonicalize` requires an existing path.
751///
752/// A resolved target whose parent directory does not exist fails minting
753/// exactly as the subsequent database open itself would fail.
754///
755/// Returns the minted [`DbIdentity`] alongside the canonical [`PathBuf`] it
756/// was built from — `DbIdentity` has no path accessor by design, so callers
757/// that need the filesystem path (sidecar derivation) keep this pairing
758/// rather than re-deriving it from the raw configured path.
759fn 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
811/// Follow a (possibly dangling) final-component symlink chain to its
812/// ultimate target, bounded at [`MAX_SYMLINK_DEPTH`] hops. A path that is
813/// not itself a symlink — including one that does not exist at all —
814/// returns unchanged on the first iteration; this is the common case, a
815/// first-open path with no symlink involved.
816fn 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(&current) {
820            Ok(meta) if meta.file_type().is_symlink() => {
821                let target = fs::read_link(&current).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
879/// Read-only writer-slot open flags: no `SQLITE_OPEN_CREATE`, so a missing
880/// path is rejected rather than silently created.
881fn 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        // Read-only writer slot: skip write-intent PRAGMAs (journal_mode,
895        // wal_autocheckpoint, journal_size_limit all require write access to
896        // change) and lock the connection down with query_only instead.
897        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    /// Restores the process CWD on drop — including on panic — so a mid-test
1008    /// assertion failure (or an unexpected panic from the code under test)
1009    /// can never leave the process chdir'd into a `tempfile::tempdir()` that
1010    /// unwinds out from under every later test sharing this process.
1011    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        // Ensure defaults are not accidentally changed. The process env may
1076        // legitimately carry overrides (CI jobs set KHIVE_CHECKOUT_TIMEOUT_SECS),
1077        // so clear them first — this test asserts the constants, not the env.
1078        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        // After drop, writer should be re-acquirable.
1242        let _writer2 = pool
1243            .writer()
1244            .expect("second writer checkout should succeed");
1245    }
1246
1247    /// ADR-091 Plank 0: `WriterGuard::transaction` registers/deregisters a
1248    /// tx_registry entry around the closure. See
1249    /// crates/khive-db/docs/api/pool.md#writer_guard_transaction_registers_during_closure_only
1250    #[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    /// ADR-067 Component A: `writer_task_handle` must fail loud (typed
1282    /// error, not panic) with no Tokio runtime available. See
1283    /// crates/khive-db/docs/api/pool.md#writer_task_handle_fails_loud_without_tokio_runtime
1284    #[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    /// ADR-091 backend-scoped attribution: the real path, a directory
1310    /// symlink, a file-level symlink, a relative spelling, and a bare file
1311    /// name (resolved against the current directory) must all mint an
1312    /// identical `DbIdentity` and canonical path for the same database.
1313    #[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        // Relative spelling: resolved against the process CWD (step 1).
1333        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        // Bare file name: resolved against the current directory (step 1).
1355        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    /// ADR-091 backend-scoped attribution: `DbIdentity`/canonical-path
1365    /// equality across alias spellings (proven above by
1366    /// `mint_db_identity_alias_convergence`) does not by itself prove the
1367    /// walpin sidecar re-key — `sidecar_dir_for` is a separate, purely
1368    /// lexical derivation (`walpin::sidecar_dir_for`) that must be fed the
1369    /// *minted* canonical path, never the raw configured one. This test
1370    /// opens a real `ConnectionPool` (not the private `mint_db_identity` free
1371    /// function) through each alias spelling and asserts
1372    /// `sidecar_dir_for(pool.canonical_path())` converges to one directory —
1373    /// exercising the actual `ConnectionPool::new` → `canonical_path()` wiring
1374    /// every sidecar consumer (`checkpoint.rs`) reads from.
1375    #[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    /// ADR-091 backend-scoped attribution: opening via a file-level symlink
1446    /// whose target does not exist yet (a valid first-open state), then
1447    /// after the target is created, opening via the target path directly,
1448    /// must mint identical `DbIdentity` values — the first-open path
1449    /// resolves the final component before canonicalizing the parent.
1450    #[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        // Now create the target (as SQLite would on first write) and mint
1462        // again directly against the target path.
1463        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    /// A resolved target whose parent directory does not exist must fail
1471    /// minting exactly as the subsequent database open itself would fail.
1472    #[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    /// Non-UTF-8 database paths (Unix) must round-trip through
1484    /// `DbIdentity`/canonicalization without loss.
1485    #[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        // 0xFF is not valid UTF-8 as a standalone byte.
1493        let raw_name = OsStr::from_bytes(b"khive-\xffdb.sqlite");
1494        let db_path = dir.path().join(raw_name);
1495        // Some Unix filesystems (notably macOS's APFS) reject non-UTF-8
1496        // names outright at the syscall level — that is a filesystem
1497        // limitation, not a `mint_db_identity` bug, so skip rather than
1498        // fail where the underlying `write` itself cannot succeed.
1499        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}