Skip to main content

khive_db/pool/
construction.rs

1//! Pool construction and initial connection admission.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::sync::atomic::{AtomicBool, AtomicU64};
6use std::sync::{Arc, OnceLock};
7
8use crossbeam_queue::ArrayQueue;
9use khive_storage::tx_registry::TxOrigin;
10use khive_storage::CapacityUnavailablePhase;
11use parking_lot::Mutex;
12use rusqlite::Connection;
13use tokio::sync::Semaphore;
14
15use crate::disk_guard_config::{resolve_disk_guard_config, DEFAULT_DISK_GUARD_DEADLINE_MS};
16use crate::error::SqliteError;
17#[cfg(any(unix, windows))]
18use crate::file_identity::database_file_identity;
19
20#[cfg(any(unix, windows))]
21use super::{claimed_file_identity, database_file_identity_if_exists, opened_sqlite_file_identity};
22use super::{
23    code_map, configure_writer_connection, deny_retired_writer, effective_reader_count,
24    initialize_database_id_with_admission, mint_db_identity, open_writer_connection,
25    read_database_id, read_only_open_target, refuse_home_data_store_in_tests,
26    validate_wal_ceiling_at_open, validate_write_admission_deadline, CheckpointOwnershipGate,
27    ConnectionPool, PoolConfig, PoolIdentityRegistration, ReaderAcquisitionCounters,
28    WriteAdmission, WriterAcquisitionCounters,
29};
30#[cfg(test)]
31use super::{run_identity_open_hook, IdentityOpenStage};
32
33impl ConnectionPool {
34    /// Create a new connection pool.
35    ///
36    /// Opens 1 writer + N reader connections to the same database when pooling
37    /// is enabled. All connections are configured consistently (busy timeout,
38    /// foreign keys, cache, mmap, temp store). Writable in-memory databases and
39    /// writable non-WAL files fall back to single-connection mode. Read-only
40    /// files retain a dedicated reader regardless of journal mode.
41    pub fn new(config: PoolConfig) -> Result<Self, SqliteError> {
42        refuse_home_data_store_in_tests(&config)?;
43        validate_write_admission_deadline(config.write_admission_deadline_ms)?;
44        if let Some(policy) = config.disk_guard_config {
45            policy.validate()?;
46        }
47        config.wal_ceiling.validate_static(
48            config.path.is_some(),
49            config.wal_mode,
50            config.read_only,
51        )?;
52        code_map::validate_pool(&config)?;
53        if config.path.is_some() && !config.read_only {
54            let lock_dir =
55                crate::disk_guard_config::require_volume_lock_dir(config.volume_lock_dir.clone())?;
56            if !lock_dir.is_absolute() {
57                return Err(SqliteError::CapacityUnavailable {
58                    phase: CapacityUnavailablePhase::Lock,
59                    message: "configured volume-lock directory is not absolute".to_string(),
60                });
61            }
62        }
63
64        // Resolve "no preference" (`None`) now that `path` is known: on for
65        // file-backed pools, off for in-memory ones. An explicit `Some(_)`
66        // preference is left untouched and always wins.
67        let mut config = config;
68        let inert_memory_queue_request =
69            config.path.is_none() && config.write_queue_enabled == Some(true);
70        config.write_queue_enabled =
71            Some(config.write_queue_enabled.unwrap_or(config.path.is_some()));
72        if inert_memory_queue_request {
73            tracing::warn!(target: "khive_db::pool",
74                "write queue explicitly requested for an in-memory pool; it is inert because \
75                 in-memory pools cannot host a writer task"
76            );
77        }
78
79        // Mint the physical identity before WAL classification or SQLite open.
80        // Every read-only connection below uses this same canonical path (or
81        // an immutable URI derived from it), so a symlink cannot split main-file
82        // resolution from sidecar resolution.
83        let (origin, identity_path) = match config.path.as_ref() {
84            Some(path) if config.code_map_vfs.is_some() => code_map::guarded_identity(path),
85            Some(path) => {
86                let (identity, canonical) = mint_db_identity(path)?;
87                (TxOrigin::Database(identity), Some(canonical))
88            }
89            None => (TxOrigin::Memory, None),
90        };
91        let (write_admission, disk_guard_config) = if !config.read_only {
92            if identity_path.as_deref().and_then(Path::parent).is_some() {
93                let disk_guard = match config.disk_guard_config {
94                    Some(policy) => policy,
95                    None => resolve_disk_guard_config(None, None)?,
96                };
97                disk_guard.validate()?;
98                if disk_guard.legacy_environment_present {
99                    tracing::warn!(target: "khive_db::pool",
100                        "legacy SQLite reserve setting is deprecated; \
101                         use KHIVE_SQLITE_DISK_RESERVE_BYTES"
102                    );
103                }
104                if disk_guard.reserve_bytes == 0 {
105                    tracing::warn!(target: "khive_db::pool",
106                        "SQLite disk reserve is explicitly zero; \
107                         new logical writes will not be floor-refused"
108                    );
109                }
110                (
111                    Arc::new(WriteAdmission::new(
112                        identity_path.clone(),
113                        disk_guard.reserve_bytes,
114                        disk_guard.guard_deadline_ms,
115                        config.volume_lock_dir.clone(),
116                    )?),
117                    Some(disk_guard),
118                )
119            } else {
120                (
121                    Arc::new(WriteAdmission::new(
122                        None,
123                        0,
124                        DEFAULT_DISK_GUARD_DEADLINE_MS,
125                        None,
126                    )?),
127                    None,
128                )
129            }
130        } else {
131            (
132                Arc::new(WriteAdmission::new(
133                    None,
134                    0,
135                    DEFAULT_DISK_GUARD_DEADLINE_MS,
136                    None,
137                )?),
138                None,
139            )
140        };
141        let read_only_open_target = read_only_open_target(&config, identity_path.as_deref())?;
142        #[cfg(any(unix, windows))]
143        let identity_before_open = identity_path
144            .as_deref()
145            .map(database_file_identity_if_exists)
146            .transpose()?
147            .flatten();
148        #[cfg(any(unix, windows))]
149        claimed_file_identity::verify_before_open(&config, identity_before_open)?;
150        let retirement_connection = Connection::open_in_memory()?;
151        retirement_connection.authorizer(Some(deny_retired_writer))?;
152        // Declared first so a failed bootstrap closes SQLite before releasing its lease.
153        let mut initialization_lease = None;
154        let mut writer = open_writer_connection(
155            &config,
156            read_only_open_target.as_deref(),
157            identity_path.as_deref(),
158        )?;
159        validate_wal_ceiling_at_open(&writer, &config)?;
160        // The identity bootstrap can take a write lock before the remaining
161        // connection pragmas are configured. Honor the caller's wait bound.
162        writer.busy_timeout(config.busy_timeout)?;
163        // A read of main.sqlite_master forces SQLite's main file open without
164        // changing either database. Reject an already-swapped target before
165        // installing a nonce into a legacy or initially empty database.
166        let initial_database_id = if identity_path.is_some() {
167            read_database_id(&writer)?
168        } else {
169            None
170        };
171        #[cfg(test)]
172        if let Some(path) = identity_path.as_deref() {
173            run_identity_open_hook(
174                path,
175                IdentityOpenStage::AfterMainOpenBeforeFirstStat,
176                Some(&writer),
177            );
178        }
179        #[cfg(any(unix, windows))]
180        let identity_before_write = identity_path
181            .as_deref()
182            .map(database_file_identity)
183            .transpose()?;
184        #[cfg(any(unix, windows))]
185        if identity_before_open.is_some() && identity_before_open != identity_before_write {
186            return Err(SqliteError::InvalidData(
187                "database file identity changed while opening the pool".to_string(),
188            ));
189        }
190        #[cfg(any(unix, windows))]
191        if let Some(path) = identity_path.as_deref() {
192            let opened = opened_sqlite_file_identity(&writer, path)?;
193            if identity_before_write != Some(opened) {
194                return Err(SqliteError::InvalidData(
195                    "database file identity changed while opening the pool".to_string(),
196                ));
197            }
198        }
199        // A newly-created file must still belong to the volume captured from
200        // its parent before open, and an existing file must retain that volume.
201        write_admission.verify_current_volume()?;
202        let opened_database_id = if identity_path.is_some()
203            && !config.read_only
204            && initial_database_id.is_none()
205        {
206            match write_admission.check() {
207                Ok(()) => {
208                    initialization_lease = write_admission.acquire()?;
209                    match initialize_database_id_with_admission(&mut writer, Some(&write_admission))
210                    {
211                        Ok(id) => Some(id),
212                        Err(SqliteError::CapacityFloor { .. }) => None,
213                        Err(error) => return Err(error),
214                    }
215                }
216                // Recovery must be able to open this pool and its checkpoint
217                // connection below the reserve. Physical file pinning still
218                // applies; a later pool open can install the nonce.
219                Err(SqliteError::CapacityFloor { .. }) => None,
220                Err(error) => return Err(error),
221            }
222        } else {
223            initial_database_id
224        };
225        drop(initialization_lease.take());
226        #[cfg(test)]
227        if let Some(path) = identity_path.as_deref() {
228            run_identity_open_hook(
229                path,
230                IdentityOpenStage::AfterInitialIdentityWrite,
231                Some(&writer),
232            );
233        }
234        #[cfg(any(unix, windows))]
235        let opened_file_identity = identity_path
236            .as_deref()
237            .map(|path| opened_sqlite_file_identity(&writer, path))
238            .transpose()?;
239        #[cfg(any(unix, windows))]
240        if identity_before_write != opened_file_identity {
241            return Err(SqliteError::InvalidData(
242                "database file identity changed while opening the pool".to_string(),
243            ));
244        }
245        let wal_enabled = configure_writer_connection(&writer, &config)?;
246        let max_readers = effective_reader_count(&config, wal_enabled);
247
248        let readers = ArrayQueue::new(max_readers.max(1));
249
250        #[cfg(any(test, feature = "test-support"))]
251        let statement_observer = crate::statement_observer::StatementObserverHub::new()?;
252        #[cfg(any(test, feature = "test-support"))]
253        crate::statement_observer::install(&writer, &statement_observer)?;
254
255        let mut pool = Self {
256            writer: Arc::new(Mutex::new(writer)),
257            retirement_connection: Mutex::new(Some(retirement_connection)),
258            #[cfg(any(test, feature = "test-support"))]
259            statement_observer,
260            main_pool_generation: OnceLock::new(),
261            checkpoint_ownership: CheckpointOwnershipGate::new(),
262            pooled_writer_retired: AtomicBool::new(false),
263            writer_acquisition_counters: Arc::new(WriterAcquisitionCounters::default()),
264            write_admission,
265            disk_guard_config,
266            reader_acquisition_counters: ReaderAcquisitionCounters::default(),
267            reader_checkouts: Mutex::new(BTreeMap::new()),
268            search_dispatches: Mutex::new(BTreeMap::new()),
269            note_candidate_hydration_rows: AtomicU64::new(0),
270            readers,
271            max_readers,
272            config,
273            read_only_open_target,
274            sql_bridge_reader_slots: Arc::new(Semaphore::new(max_readers.max(1))),
275            sql_bridge_writer_slots: Arc::new(Semaphore::new(1)),
276            writer_task: OnceLock::new(),
277            writer_task_join: Mutex::new(None),
278            writer_task_join_stored: AtomicBool::new(false),
279            origin,
280            identity_path,
281            #[cfg(any(unix, windows))]
282            opened_file_identity,
283            opened_database_id,
284            identity_registration: None,
285            #[cfg(test)]
286            writer_task_spawn_count: std::sync::atomic::AtomicUsize::new(0),
287        };
288
289        for _ in 0..pool.max_readers {
290            let conn = pool.open_pooled_reader()?;
291            pool.readers
292                .push(conn)
293                .expect("reader queue must have capacity during pool initialization");
294        }
295
296        // Best-effort, process-global diagnostics belong only to pools that
297        // can acquire a writer. A read-only inspection pool has no writer
298        // timeout to report and must neither mutate `<db_parent>/.khive-logs`
299        // nor consume the global sink claim before a later writable pool.
300        if !pool.config.read_only {
301            crate::timeout_sink::init(
302                pool.canonical_path().and_then(Path::parent),
303                &crate::timeout_sink::db_label(&pool),
304            );
305        }
306
307        pool.identity_registration = pool.canonical_path().map(PoolIdentityRegistration::new);
308        Ok(pool)
309    }
310}