Skip to main content

khive_db/
pool.rs

1//! Connection pool for SQLite: one exclusive writer, N concurrent readers.
2mod acquisition;
3mod connection_setup;
4mod construction;
5mod guards;
6mod identity;
7mod pool_impl;
8mod reader_counters;
9
10pub(crate) use connection_setup::open_read_only_snapshot_connection;
11#[cfg(feature = "namespace-trigram-proto")]
12use connection_setup::register_namespace_trigram;
13#[cfg(test)]
14use connection_setup::sqlite_header_uses_wal;
15use connection_setup::{
16    close_connection_quietly, configure_reader_connection, configure_writer_connection,
17    current_journal_mode, effective_reader_count, open_reader_connection, open_writer_connection,
18    pool_exhausted_error, read_only_open_target, reader_connection_is_healthy,
19    reset_reader_connection, restore_shared_reader_state, validate_wal_ceiling_at_open,
20    writer_open_flags, writer_read_only_open_flags,
21};
22#[cfg(any(unix, windows))]
23use identity::database_file_identity_if_exists;
24#[cfg(test)]
25use identity::initialize_database_id;
26#[cfg(any(unix, windows))]
27pub(crate) use identity::opened_sqlite_file_identity;
28use identity::{initialize_database_id_with_admission, mint_db_identity, read_database_id};
29
30use guards::{PooledReader, ReaderLease};
31pub(crate) use guards::{ReaderAdmission, SharedReaderTransactionGuard};
32pub use guards::{ReaderGuard, ReaderRow};
33use reader_counters::ReaderAcquisitionCounters;
34pub use reader_counters::ReaderAcquisitionSnapshot;
35pub(crate) use reader_counters::StandaloneReaderPurpose;
36
37mod code_map;
38mod sql_functions;
39#[cfg(test)]
40pub(crate) use sql_functions::register_rfc3339_key;
41use sql_functions::{register_read_functions, register_writer_clock};
42pub(crate) use sql_functions::{rfc3339_instant_key, strict_rfc3339_key};
43#[path = "pool/identity_registry.rs"]
44mod identity_registry;
45#[cfg(any(test, feature = "test-support"))]
46#[path = "pool/test_volume_lock_dir.rs"]
47mod test_volume_lock_dir;
48#[path = "pool/write_units.rs"]
49mod write_units;
50#[path = "pool/writer_acquisition.rs"]
51mod writer_acquisition;
52#[cfg(test)]
53use identity_registry::pool_identity_suffix;
54use identity_registry::PoolIdentityRegistration;
55#[cfg(any(test, feature = "test-support"))]
56use test_volume_lock_dir::test_volume_lock_dir;
57#[cfg(test)]
58use write_units::STARTUP_SPACE_PROBE;
59pub use write_units::{CheckpointGuard, CheckpointResult, WriterAcquisitionSnapshot, WriterGuard};
60pub(crate) use write_units::{
61    PooledAutocommitWriteUnit, PooledTransactionWriteUnit, StandaloneTransactionWriteUnit,
62    WriteAdmission, WriterAcquisitionCounters,
63};
64
65use crossbeam_queue::ArrayQueue;
66use parking_lot::{Condvar, Mutex};
67use rusqlite::hooks::{AuthContext, Authorization};
68use rusqlite::{Connection, OpenFlags};
69use serde::Serialize;
70use sha2::{Digest, Sha256};
71#[cfg(test)]
72use std::cell::Cell;
73use std::collections::{BTreeMap, HashMap};
74use std::fs;
75use std::ops::{Deref, DerefMut};
76use std::path::{Path, PathBuf};
77use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
78use std::sync::{Arc, OnceLock};
79#[cfg(test)]
80use std::thread;
81use std::time::{Duration, Instant};
82use tokio::sync::Semaphore;
83
84#[cfg(test)]
85use crate::database_owner_identity::DatabaseOwnerIdentityError;
86use crate::disk_guard::{LeaseRefusal, VolumeIdentity, VolumeLease};
87use crate::disk_guard_config::{EffectiveDiskGuardConfig, DEFAULT_DISK_GUARD_DEADLINE_MS};
88use crate::error::SqliteError;
89#[cfg(all(test, any(unix, windows)))]
90use crate::file_identity::database_file_identity;
91#[cfg(any(unix, windows))]
92use crate::file_identity::DatabaseFileIdentity;
93use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
94use khive_storage::error::StorageError;
95use khive_storage::tx_registry::TxOrigin;
96use khive_storage::CapacityUnavailablePhase;
97use khive_storage::StorageCapability;
98
99mod claimed_file_identity;
100
101#[cfg(unix)]
102mod claimed_file_observer;
103
104#[cfg(unix)]
105pub use claimed_file_observer::initialize as initialize_claimed_file_observer;
106
107#[cfg(all(test, unix))]
108#[path = "pool/claimed_file_identity_tests.rs"]
109mod claimed_file_identity_tests;
110
111/// Default SQLite page cache per pooled connection, in KiB.
112///
113/// Every connection keeps its own page cache on the process heap, so a
114/// process's ceiling is this value times its open connections, and a daemon
115/// serving several databases holds dozens of them. Pages the cache drops are
116/// re-read from the operating system's file cache, which the kernel reclaims
117/// on its own. `KHIVE_SQLITE_CACHE_KIB` overrides it within
118/// `SQLITE_CACHE_KIB_RANGE`.
119pub(crate) const DEFAULT_SQLITE_CACHE_KIB: i64 = 8 * 1024;
120pub(crate) const SQLITE_CACHE_KIB_RANGE: std::ops::RangeInclusive<i64> = 512..=1_048_576;
121const SQLITE_CACHE_KIB_ENV: &str = "KHIVE_SQLITE_CACHE_KIB";
122
123/// Default SQLite memory-map window per pooled connection, in bytes.
124///
125/// Mapped pages are clean and file-backed: the kernel drops them under memory
126/// pressure without writing them anywhere. Reads past the window go through
127/// the page cache above instead. `KHIVE_SQLITE_MMAP_BYTES` overrides it
128/// within `SQLITE_MMAP_BYTES_RANGE`; `0` turns memory-mapped reads off.
129pub(crate) const DEFAULT_SQLITE_MMAP_BYTES: i64 = 1 << 30;
130pub(crate) const SQLITE_MMAP_BYTES_RANGE: std::ops::RangeInclusive<i64> = 0..=(1 << 40);
131const SQLITE_MMAP_BYTES_ENV: &str = "KHIVE_SQLITE_MMAP_BYTES";
132const DEFAULT_READER_CAP: usize = 8;
133
134/// The `cache_size` pragma value every pooled connection is configured with
135/// (negative, so SQLite reads it as KiB), resolved once per process.
136pub(crate) fn sqlite_cache_size_pragma() -> i64 {
137    static KIB: OnceLock<i64> = OnceLock::new();
138    -*KIB.get_or_init(|| {
139        sqlite_size_setting(
140            SQLITE_CACHE_KIB_ENV,
141            sqlite_size_env(SQLITE_CACHE_KIB_ENV).as_deref(),
142            DEFAULT_SQLITE_CACHE_KIB,
143            &SQLITE_CACHE_KIB_RANGE,
144        )
145    })
146}
147
148/// The `mmap_size` pragma value every pooled connection is configured with,
149/// resolved once per process.
150pub(crate) fn sqlite_mmap_size_pragma() -> i64 {
151    static BYTES: OnceLock<i64> = OnceLock::new();
152    *BYTES.get_or_init(|| {
153        sqlite_size_setting(
154            SQLITE_MMAP_BYTES_ENV,
155            sqlite_size_env(SQLITE_MMAP_BYTES_ENV).as_deref(),
156            DEFAULT_SQLITE_MMAP_BYTES,
157            &SQLITE_MMAP_BYTES_RANGE,
158        )
159    })
160}
161
162/// Reads a size setting with `var_os`, not `var`: a set value that is not valid
163/// Unicode still counts as set, so it reaches the range check and is logged
164/// rather than silently taking the default.
165fn sqlite_size_env(name: &str) -> Option<String> {
166    std::env::var_os(name).map(|raw| raw.to_string_lossy().into_owned())
167}
168
169/// An unset setting takes the default. A set value that is not an integer
170/// inside `range` also takes the default, and says so in the log, because a
171/// typo in a memory setting must not stop the store from opening.
172pub(crate) fn sqlite_size_setting(
173    name: &str,
174    raw: Option<&str>,
175    default: i64,
176    range: &std::ops::RangeInclusive<i64>,
177) -> i64 {
178    let Some(raw) = raw else {
179        return default;
180    };
181    match raw.parse::<i64>() {
182        Ok(value) if range.contains(&value) => value,
183        _ => {
184            tracing::warn!(
185                setting = name,
186                value = raw,
187                default,
188                min = *range.start(),
189                max = *range.end(),
190                "ignoring a SQLite size setting outside its range; using the default"
191            );
192            default
193        }
194    }
195}
196
197const DEFAULT_JOURNAL_SIZE_LIMIT_BYTES: i64 = 67_108_864; // 64 MiB
198const DEFAULT_WRITE_QUEUE_CAPACITY: usize = 256;
199const DATABASE_ID_TABLE: &str = "_khive_database_identity";
200static NEXT_MAIN_POOL_GENERATION: AtomicU64 = AtomicU64::new(1);
201
202#[cfg(test)]
203#[derive(Clone, Copy, PartialEq, Eq)]
204enum IdentityOpenStage {
205    AfterMainOpenBeforeFirstStat,
206    AfterInitialIdentityWrite,
207    BeforeStandaloneOpen,
208    AfterStandaloneOpen,
209}
210
211#[cfg(test)]
212type IdentityOpenHook = Box<dyn Fn(&Path, IdentityOpenStage, Option<&Connection>)>;
213
214#[cfg(test)]
215thread_local! {
216    static IDENTITY_OPEN_HOOK: std::cell::RefCell<Option<IdentityOpenHook>> =
217        const { std::cell::RefCell::new(None) };
218}
219
220#[cfg(test)]
221fn run_identity_open_hook(path: &Path, stage: IdentityOpenStage, conn: Option<&Connection>) {
222    IDENTITY_OPEN_HOOK.with(|hook| {
223        if let Some(hook) = hook.borrow().as_ref() {
224            hook(path, stage, conn);
225        }
226    });
227}
228
229#[path = "pool/admission.rs"]
230mod admission;
231use admission::CheckpointOwnershipGate;
232pub use admission::RuntimeWriteOperation;
233pub(crate) use admission::FALLBACK_WAL_AUTOCHECKPOINT_PAGES;
234#[cfg(test)]
235use admission::{CheckpointConnectionConfigPause, CheckpointOwnership};
236
237fn deny_retired_writer(_context: AuthContext<'_>) -> Authorization {
238    Authorization::Deny
239}
240
241mod config;
242
243pub(crate) use config::TEST_HARNESS_ENV;
244use config::{refuse_home_data_store_in_tests, validate_write_admission_deadline};
245pub use config::{PoolConfig, WalCeilingPolicy, WalCeilingSource};
246
247/// Pool-scoped counters for ADR-166's search mechanism guards.
248#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
249pub struct SearchMechanismSnapshot {
250    /// Coordinator calls actually issued to each registered backend, keyed by
251    /// the request's canonical kind. A backend skipped by served-kind routing
252    /// has no entry for that kind.
253    pub dispatches_by_backend_and_kind: BTreeMap<String, BTreeMap<String, u64>>,
254    /// Candidate note rows fetched after the text/vector fusion and fresh-tail
255    /// merge, including rows later filtered as deleted. Result metadata fetched
256    /// later by the KG handler is excluded.
257    pub note_candidate_hydration_rows: u64,
258}
259
260/// A read-write connection pool for SQLite.
261///
262/// Architecture:
263/// - 1 writer connection protected by a Mutex (exclusive access)
264/// - N reader connections in a lock-free queue (concurrent access)
265/// - All connections share the same database file in WAL mode
266///
267/// Writable in-memory databases, or writable file databases when WAL mode is
268/// disabled/unavailable, degrade to single-connection mode and route all
269/// operations through the writer connection. A file-backed read-only pool
270/// always retains at least one dedicated read-only connection: rollback-journal
271/// snapshots do not need WAL to support concurrent readers, and inspection must
272/// never alias a read onto the query-only writer slot.
273pub struct ConnectionPool {
274    writer: Arc<Mutex<Connection>>,
275    // Prepared before admission so terminal cleanup cannot fail while allocating a replacement.
276    retirement_connection: Mutex<Option<Connection>>,
277    #[cfg(any(test, feature = "test-support"))]
278    statement_observer: Arc<crate::statement_observer::StatementObserverHub>,
279    main_pool_generation: OnceLock<u64>,
280    /// Three-state gate for whether the ADR-091 scheduled task has claimed
281    /// routine WAL reclamation for this pool. Until claimed, every
282    /// writer-capable connection keeps a bounded SQLite autocheckpoint
283    /// ([`FALLBACK_WAL_AUTOCHECKPOINT_PAGES`]) so a writable pool without a
284    /// checkpoint task cannot grow its WAL without bound. After
285    /// [`Self::claim_checkpoint_ownership`], writer-capable connections open
286    /// with `wal_autocheckpoint = 0` and routine checkpoint I/O stays off
287    /// application commit paths.
288    checkpoint_ownership: CheckpointOwnershipGate,
289    /// Fail-closed guard for the legacy pool-mutex writer. A transaction
290    /// owner retires this connection after a body panic or when it cannot
291    /// prove that finalization restored autocommit mode; subsequent checkouts
292    /// must never reuse it.
293    pooled_writer_retired: AtomicBool,
294    /// Process-local writer acquisition counters shared with the pool's
295    /// lifetime-owned writer task. Keeping the counters at the actual
296    /// acquisition boundaries means new verbs inherit instrumentation without
297    /// per-verb classification (ADR-133 D8 / issue #1389).
298    writer_acquisition_counters: Arc<WriterAcquisitionCounters>,
299    /// Shared with the long-lived writer task so it can resample at every
300    /// dequeued request rather than only when its connection is opened.
301    write_admission: Arc<WriteAdmission>,
302    /// Effective source and values captured at open for diagnostic reporting;
303    /// the live volume identifier is deliberately resolved separately.
304    disk_guard_config: Option<EffectiveDiskGuardConfig>,
305    /// Pool-scoped reader route, saturation, and hold-lifecycle counters.
306    /// Instrumentation lives at the acquisition boundary so every typed
307    /// store and raw-SQL caller inherits it without per-verb bookkeeping
308    /// (ADR-165 Slice 2 / ADR-166 G2).
309    reader_acquisition_counters: ReaderAcquisitionCounters,
310    reader_checkouts: Mutex<BTreeMap<Instant, usize>>,
311    /// ADR-166 G4/G5 process-lifetime mechanism counters for this physical
312    /// backend. Backend IDs remain separate even when aliases share a pool.
313    search_dispatches: Mutex<BTreeMap<String, BTreeMap<String, u64>>>,
314    note_candidate_hydration_rows: AtomicU64,
315    readers: ArrayQueue<Box<PooledReader>>,
316    max_readers: usize,
317    config: PoolConfig,
318    /// Canonical physical target used by every connection in a file-backed
319    /// read-only pool. Classification and open must share this exact spelling:
320    /// deriving WAL sidecars from a configured symlink while SQLite follows it
321    /// to another file can hide committed frames or a live writable `-shm`.
322    /// The value is an `immutable=1` URI only for a clean, checkpointed WAL;
323    /// rollback-journal databases and frozen WAL+SHM snapshots retain the
324    /// canonical ordinary path and SQLite locking/change detection.
325    read_only_open_target: Option<PathBuf>,
326    sql_bridge_reader_slots: Arc<Semaphore>,
327    sql_bridge_writer_slots: Arc<Semaphore>,
328    /// The pool-wide ADR-067 Component A writer task, spawned lazily and at
329    /// most once per pool (per DB file) via [`Self::writer_task_handle`] —
330    /// see that method's doc comment for why this lives here rather than on
331    /// each store.
332    writer_task: OnceLock<Option<WriterTaskHandle>>,
333    /// The `tokio::spawn` JoinHandle of the writer task above, stored by
334    /// [`crate::writer_task::spawn`] so short-lived callers (batch CLI
335    /// paths) can await the task's exit — and therefore its connection's
336    /// close-time WAL checkpoint — before treating the database file state
337    /// as settled. Long-running callers never take it; dropping an untaken
338    /// JoinHandle detaches the task, which is exactly the pre-existing
339    /// behavior.
340    writer_task_join: Mutex<Option<tokio::task::JoinHandle<()>>>,
341    /// Monotonic "a writer-task JoinHandle was stored at least once" flag
342    /// backing [`Self::set_writer_task_join`]'s at-most-once guard: it holds
343    /// the invariant even after [`Self::take_writer_task_join`] empties the
344    /// slot, so a second store never re-arms it.
345    writer_task_join_stored: AtomicBool,
346    /// This pool's ADR-091 backend-scoped attribution origin, minted exactly
347    /// once at construction (see [`mint_db_identity`]): `Database(_)` for a
348    /// file-backed pool, `Memory` for an in-memory pool. Every
349    /// `tx_registry::register_scoped` call site in this crate reaches its
350    /// origin through [`Self::origin`] rather than re-deriving it.
351    origin: TxOrigin,
352    /// The canonical path `origin`'s `DbIdentity` was minted from, `None` for
353    /// an in-memory pool. `DbIdentity` is deliberately opaque (no path
354    /// accessor) — filesystem consumers that need the actual path (sidecar
355    /// derivation) use this, the same canonical value the identity was
356    /// minted from, via [`Self::canonical_path`].
357    identity_path: Option<PathBuf>,
358    /// The physical file SQLite opened, checked against the canonical path.
359    /// Later reader and standalone opens must retain this identity.
360    #[cfg(any(unix, windows))]
361    opened_file_identity: Option<DatabaseFileIdentity>,
362    /// A persistent nonce read through SQLite's opened main database, rather
363    /// than through the pathname that may have been replaced during open.
364    opened_database_id: Option<uuid::Uuid>,
365    /// Registered only after every connection opens successfully. RAII removes
366    /// the path when the last pool for it drops, including failed construction.
367    identity_registration: Option<PoolIdentityRegistration>,
368    /// Test-only instrumentation: counts how many times the writer-task
369    /// init closure actually ran. Must never exceed 1 per pool no matter how
370    /// many stores are constructed over it — that is the invariant
371    /// `OnceLock::get_or_init` exists to guarantee, and what
372    /// `pool.rs`'s and `entity_tests.rs`'s one-writer-per-pool tests assert.
373    #[cfg(test)]
374    writer_task_spawn_count: std::sync::atomic::AtomicUsize,
375}
376
377impl Drop for ConnectionPool {
378    /// Close every read-only reader before the fields below it drop in
379    /// declaration order (`writer` first, `readers` well before
380    /// `writer_task`). A read-only connection cannot take the EXCLUSIVE lock
381    /// SQLite needs to checkpoint on close, so if a reader were left to close
382    /// last, WAL mode would leave `-wal`/`-shm` behind. Draining `readers`
383    /// here, before that field-order drop runs, makes the writable `writer`
384    /// connection close after every reader instead of before it.
385    fn drop(&mut self) {
386        while let Some(conn) = self.readers.pop() {
387            drop(conn);
388        }
389    }
390}
391
392#[cfg(test)]
393#[path = "runtime_write_routing_tests.rs"]
394mod runtime_write_routing_tests;
395
396#[cfg(test)]
397#[path = "database_owner_identity_pool_tests.rs"]
398mod database_owner_identity_pool_tests;
399
400#[cfg(test)]
401#[path = "pool_tests.rs"]
402mod tests;
403
404#[cfg(test)]
405#[path = "pool/recycling_tests.rs"]
406mod recycling_tests;
407
408#[cfg(all(test, any(unix, windows)))]
409#[path = "pool_identity_admission_tests.rs"]
410mod identity_admission_tests;