Skip to main content

khive_db/pool/
config.rs

1use super::*;
2
3pub(crate) const TEST_HARNESS_ENV: &str = "KHIVE_TEST_HARNESS";
4
5/// Where the effective WAL ceiling byte value was configured.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum WalCeilingSource {
9    BackendField,
10    Environment,
11    #[default]
12    Default,
13}
14
15/// Resolved WAL-extent policy for one SQLite backend. A zero-byte policy is
16/// explicitly disabled; it remains visible in diagnostics and config identity.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub struct WalCeilingPolicy {
19    pub bytes: u64,
20    pub source: WalCeilingSource,
21}
22
23impl WalCeilingPolicy {
24    /// Validate checks that do not need SQLite's page-size observation.
25    /// Read-only backends retain configured metadata but enforce no writer
26    /// policy. Invalid offset arithmetic is rejected in either mode.
27    pub fn validate_static(
28        self,
29        file_backed: bool,
30        wal_mode: bool,
31        read_only: bool,
32    ) -> Result<(), SqliteError> {
33        if self.bytes == 0 {
34            return Ok(());
35        }
36        if i64::try_from(self.bytes).is_err() {
37            return Err(SqliteError::WalCeilingOffsetOverflow { bytes: self.bytes });
38        }
39        if read_only {
40            return Ok(());
41        }
42        if !file_backed {
43            return Err(SqliteError::WalCeilingUnsupported {
44                bytes: self.bytes,
45                backend_kind: "in-memory backend",
46            });
47        }
48        if !wal_mode {
49            return Err(SqliteError::WalCeilingUnsupported {
50                bytes: self.bytes,
51                backend_kind: "non-WAL backend",
52            });
53        }
54        Ok(())
55    }
56
57    /// Bytes of writer policy that could be enforced on this backend.
58    pub fn effective_bytes(self, read_only: bool) -> u64 {
59        if read_only {
60            0
61        } else {
62            self.bytes
63        }
64    }
65}
66
67/// Configuration for the connection pool.
68#[derive(Clone, Debug)]
69pub struct PoolConfig {
70    /// Database path. None = in-memory (pool degrades to single connection).
71    pub path: Option<PathBuf>,
72    /// Registered native code-map VFS name; set only by the code-map constructor.
73    pub code_map_vfs: Option<String>,
74    /// File identity pinned by the caller before this pool opens SQLite.
75    /// A mismatch is refused before identity initialization or WAL setup.
76    #[cfg(any(unix, windows))]
77    pub expected_file_identity: Option<DatabaseFileIdentity>,
78    /// Number of reader connections (default: min(num_cpus, 8)).
79    pub max_readers: usize,
80    /// Retire a dedicated reader on return when its connection age exceeds
81    /// this duration. `KHIVE_READER_MAX_AGE_SECS`, default 300 seconds.
82    /// Does not expire an outstanding lease or recycle the shared writer.
83    pub reader_max_age: Duration,
84    /// Retire a dedicated reader on return after more than this many
85    /// successful checkouts. `KHIVE_READER_MAX_OPS`, default 5000.
86    /// Counts leases, not SQL statements; zero retires every returned lease.
87    pub reader_max_ops: u64,
88    /// WAL mode (must be true for pooling to work; default: true).
89    pub wal_mode: bool,
90    /// Busy timeout per connection (default: 30s).
91    ///
92    /// Overridable via `KHIVE_BUSY_TIMEOUT_SECS`.
93    pub busy_timeout: Duration,
94    /// Time to wait for a reader connection before returning an error (default: 5s).
95    ///
96    /// For a writer it bounds only the pool-mutex wait. A writable file-backed
97    /// pool takes the volume lease first, bounded by the guard deadline
98    /// (`disk_guard_deadline_ms`, default 2000 ms), so the effective writer
99    /// wait under contention is the guard deadline, then `checkout_timeout`:
100    /// a 50 ms value here can still wait about 2 s, and a wait that ends at
101    /// the lease returns `CapacityUnavailable` with phase `lock_contended` rather than a
102    /// checkout timeout. Diagnostics report both bounds and their sum.
103    ///
104    /// Overridable via `KHIVE_CHECKOUT_TIMEOUT_SECS`.
105    pub checkout_timeout: Duration,
106    /// Warn once per observed episode while an outstanding pooled checkout
107    /// exceeds this age. Captured from `KHIVE_READER_CHECKOUT_WARN_SECS`
108    /// (default: 10 seconds); this never cancels or closes a reader.
109    pub reader_checkout_warn_after: Duration,
110    /// Maximum WAL journal size in bytes before SQLite resets the WAL.
111    ///
112    /// Maps to `PRAGMA journal_size_limit`. Default: 64 MiB.
113    ///
114    /// Overridable via `KHIVE_JOURNAL_SIZE_LIMIT_BYTES`.
115    pub journal_size_limit_bytes: i64,
116    /// Open the database read-only (default: false).
117    ///
118    /// When true, the pool's writer connection is opened with
119    /// `SQLITE_OPEN_READ_ONLY` (no `SQLITE_OPEN_CREATE`, so a missing path is
120    /// rejected instead of created) and `PRAGMA query_only = ON` is set on
121    /// every connection that can execute SQL. Reader connections are already
122    /// opened read-only regardless of this flag.
123    pub read_only: bool,
124    /// ADR-194 WAL active-extent ceiling and its resolved configuration source.
125    /// Zero explicitly disables this independent policy.
126    pub wal_ceiling: WalCeilingPolicy,
127    /// Route migrated store write paths through the single-writer
128    /// `WriterTask` channel (ADR-067 Component A) instead of the legacy
129    /// per-call pool-mutex/standalone-connection path. Enabled by default
130    /// for file-backed pools when unset; explicit override always wins.
131    /// That default is a compatibility-routing posture subordinate to
132    /// ADR-135 Amendment 1 and ADR-136 D1/D2 — the strict-routing default
133    /// flip has NOT happened.
134    ///
135    /// The store layer resolves all of its routed write paths at write time;
136    /// the classification table in `writer_task.rs` remains the authoritative
137    /// inventory. This tranche does not claim the repository-wide
138    /// single-writer guarantee, and the strict default is still evidence-gated.
139    ///
140    /// `None` means the caller expressed no preference: [`ConnectionPool::new`]
141    /// resolves it once `path` is known, defaulting to `true` for file-backed
142    /// pools and `false` for in-memory ones. `Some(_)` is an explicit
143    /// preference and always wins, in both directions, over that default.
144    /// An explicit `Some(true)` on an in-memory pool is accepted DELIBERATELY
145    /// and emits a warning before degrading to the legacy path — an in-memory
146    /// pool cannot host a writer task (`writer_task::spawn`'s
147    /// standalone-connection open fails); see
148    /// `ConnectionPool::writer_task_handle` and the
149    /// `explicit_true_stays_on_for_memory_backed_pool` test.
150    ///
151    /// Overridable via `KHIVE_WRITE_QUEUE` (`"1"` or `"true"`,
152    /// case-insensitive, sets `Some(true)`; any other value sets `Some(false)`;
153    /// unset leaves it `None`).
154    pub write_queue_enabled: Option<bool>,
155    /// Bounded channel capacity for the `WriterTask` write queue.
156    ///
157    /// Overridable via `KHIVE_WRITE_QUEUE_CAPACITY`. Default: 256 pending
158    /// operations (ADR-067 Component A recommended default).
159    pub write_queue_capacity: usize,
160    /// ADR-136 D1: when `true`, every covered store write path that would
161    /// otherwise silently degrade to the legacy pool-mutex/standalone-
162    /// connection path on a missing or failed `WriterTask` handle instead
163    /// returns an error.
164    /// Exercises the store-layer routing tranche toward ADR-135 F2's
165    /// strict-routing precondition without changing behavior for callers that
166    /// never set the env var.
167    ///
168    /// Overridable via `KHIVE_WRITE_ROUTING` (value `"strict"`,
169    /// case-insensitive; anything else, or unset, leaves this `false`).
170    pub write_routing_strict: bool,
171    /// Dedicated admission deadline (ADR-131 Decision 2) bounding ONLY the
172    /// wait for capacity on the `WriterTask` write queue —
173    /// [`WriterTaskHandle::send_bounded`]/`send_top_level_bounded`'s default
174    /// timeout. Distinct from `checkout_timeout`, which bounds reader/pool
175    /// checkout instead; the two authorities used to be conflated (#1382,
176    /// #1643) before this field existed.
177    ///
178    /// Default: 2000 ms. Validated at [`ConnectionPool::new`] to fall in
179    /// `[100, 10000]` ms; a value outside that range is a configuration
180    /// error (`SqliteError::InvalidConfig`), never silently clamped into
181    /// range.
182    ///
183    /// Overridable via `KHIVE_WRITE_ADMISSION_DEADLINE_MS`.
184    pub write_admission_deadline_ms: u64,
185    /// SQLite disk-reserve and guard-deadline policy for this pool. `None`
186    /// resolves the process environment when the pool opens.
187    pub disk_guard_config: Option<EffectiveDiskGuardConfig>,
188    /// Shared per-user directory for the volume advisory lock files.
189    /// [`PoolConfig::default`] resolves it with [`crate::default_volume_lock_dir`]
190    /// and leaves `None` when no directory can be resolved.
191    pub volume_lock_dir: Option<PathBuf>,
192    /// Maximum age an explicit cached-reader read transaction
193    /// (`sql_bridge`'s `BEGIN`-then-reuse path) may reach before its next use
194    /// is refused and it is rolled back instead of extending its WAL
195    /// snapshot further (#1846). Shares `KHIVE_TX_MAX_AGE_SECS` with the
196    /// ADR-091 Plank 1 visibility sweep in `checkpoint.rs` so one knob
197    /// governs both when an operator is warned about a stale reader and when
198    /// that reader's snapshot is actually released.
199    ///
200    /// Overridable via `KHIVE_TX_MAX_AGE_SECS`. Default: 120 seconds.
201    pub read_tx_max_age: Duration,
202}
203
204/// ADR-131 Decision 2's validated range for `write_admission_deadline_ms`.
205const WRITE_ADMISSION_DEADLINE_MS_RANGE: std::ops::RangeInclusive<u64> = 100..=10_000;
206const DEFAULT_WRITE_ADMISSION_DEADLINE_MS: u64 = 2000;
207
208impl Default for PoolConfig {
209    fn default() -> Self {
210        Self {
211            path: None,
212            code_map_vfs: None,
213            #[cfg(any(unix, windows))]
214            expected_file_identity: None,
215            max_readers: std::thread::available_parallelism()
216                .map(|n| n.get())
217                .unwrap_or(1)
218                .clamp(1, DEFAULT_READER_CAP),
219            reader_max_age: Duration::from_secs(crate::env::env_parse_or(
220                "KHIVE_READER_MAX_AGE_SECS",
221                300,
222            )),
223            reader_max_ops: crate::env::env_parse_or("KHIVE_READER_MAX_OPS", 5000),
224            wal_mode: true,
225            busy_timeout: Duration::from_secs(crate::env::env_parse_or(
226                "KHIVE_BUSY_TIMEOUT_SECS",
227                30,
228            )),
229            checkout_timeout: Duration::from_secs(crate::env::env_parse_or(
230                "KHIVE_CHECKOUT_TIMEOUT_SECS",
231                5,
232            )),
233            reader_checkout_warn_after: Duration::from_secs(crate::env::env_parse_or(
234                "KHIVE_READER_CHECKOUT_WARN_SECS",
235                10,
236            )),
237            journal_size_limit_bytes: crate::env::env_parse_or(
238                "KHIVE_JOURNAL_SIZE_LIMIT_BYTES",
239                DEFAULT_JOURNAL_SIZE_LIMIT_BYTES,
240            ),
241            read_only: false,
242            wal_ceiling: WalCeilingPolicy::default(),
243            // `var_os`, not `var`: the documented contract is "any SET value
244            // other than 1/true means Some(false)" — a set-but-non-Unicode
245            // value must count as set (var() would return Err and silently
246            // fall through to the file-backed default of enabled).
247            write_queue_enabled: std::env::var_os("KHIVE_WRITE_QUEUE").map(|v| {
248                v.to_str()
249                    .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
250            }),
251            write_queue_capacity: {
252                let capacity = crate::env::env_parse_or(
253                    "KHIVE_WRITE_QUEUE_CAPACITY",
254                    DEFAULT_WRITE_QUEUE_CAPACITY,
255                );
256                if capacity > 0 {
257                    capacity
258                } else {
259                    DEFAULT_WRITE_QUEUE_CAPACITY
260                }
261            },
262            write_routing_strict: std::env::var("KHIVE_WRITE_ROUTING")
263                .map(|v| v.eq_ignore_ascii_case("strict"))
264                .unwrap_or(false),
265            write_admission_deadline_ms: crate::env::env_parse_or(
266                "KHIVE_WRITE_ADMISSION_DEADLINE_MS",
267                DEFAULT_WRITE_ADMISSION_DEADLINE_MS,
268            ),
269            disk_guard_config: None,
270            #[cfg(test)]
271            volume_lock_dir: Some(test_volume_lock_dir()),
272            #[cfg(not(test))]
273            volume_lock_dir: crate::default_volume_lock_dir().ok(),
274            read_tx_max_age: crate::checkpoint::tx_age_thresholds_from_env(
275                Duration::from_secs(30),
276                Duration::from_secs(120),
277            )
278            .1,
279        }
280    }
281}
282
283#[cfg(any(test, feature = "test-support"))]
284impl PoolConfig {
285    /// A small concurrent pool for private test databases.
286    ///
287    /// Tests of reader admission or production sizing should set their required
288    /// count explicitly. Ordinary fixtures need not reserve a CPU-sized pool.
289    pub fn for_test() -> Self {
290        Self {
291            max_readers: 2,
292            volume_lock_dir: Some(test_volume_lock_dir()),
293            ..Self::default()
294        }
295    }
296}
297
298/// Prevent Cargo-launched tests and test subprocesses from opening the
299/// operator's default data tree in every build profile. Activation is solely
300/// the runtime `KHIVE_TEST_HARNESS=1` marker; production/installed binaries do
301/// not receive that workspace Cargo environment.
302///
303/// There is deliberately no environment override: any inheritable escape
304/// hatch set for one Cargo invocation leaks into the next `cargo test` in the
305/// same shell and re-opens the store the guard exists to protect. A deliberate
306/// session against the real store runs the built binary directly (for example
307/// `target/release/...` or an installed binary), which never receives the
308/// workspace Cargo environment and therefore never trips this guard.
309/// Existing path ancestors are canonicalized before comparison, resolving
310/// traversal, symlinks, and filesystem-provided case (including APFS case
311/// folding). Missing trailing components remain lexical because they have no
312/// filesystem identity yet. SQLite URI paths are rejected rather than trying
313/// to reproduce SQLite's URI normalization rules.
314pub(super) fn refuse_home_data_store_in_tests(config: &PoolConfig) -> Result<(), SqliteError> {
315    if std::env::var(TEST_HARNESS_ENV).as_deref() != Ok("1") {
316        return Ok(());
317    }
318
319    let Some(path) = config.path.as_deref() else {
320        return Ok(());
321    };
322    if path
323        .as_os_str()
324        .as_encoded_bytes()
325        .get(..5)
326        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file:"))
327    {
328        return Err(SqliteError::InvalidData(format!(
329            "test harness refused SQLite URI database path {}; use a filesystem path outside \
330             HOME/.khive (deliberate sessions against a real store run the built binary \
331             directly, outside the Cargo test environment)",
332            path.display()
333        )));
334    }
335
336    let Some(home) = std::env::var_os("HOME") else {
337        return Ok(());
338    };
339    let canonical_path = canonicalize_deepest_existing(path)?;
340    let canonical_home_data_dir =
341        canonicalize_deepest_existing(&PathBuf::from(home).join(".khive"))?;
342    if canonical_path.starts_with(&canonical_home_data_dir) {
343        return Err(SqliteError::InvalidData(format!(
344            "test harness refused to open SQLite database under HOME/.khive: {} \
345             (deliberate sessions against a real store run the built binary directly, \
346             outside the Cargo test environment)",
347            canonical_path.display()
348        )));
349    }
350    Ok(())
351}
352
353fn canonicalize_deepest_existing(path: &Path) -> Result<PathBuf, SqliteError> {
354    let absolute = if path.is_absolute() {
355        path.to_path_buf()
356    } else {
357        std::env::current_dir().map_err(SqliteError::Io)?.join(path)
358    };
359
360    for ancestor in absolute.ancestors() {
361        match fs::canonicalize(ancestor) {
362            Ok(mut canonical) => {
363                let missing = absolute.strip_prefix(ancestor).map_err(|error| {
364                    SqliteError::InvalidData(format!(
365                        "failed to preserve missing path components for {}: {error}",
366                        absolute.display()
367                    ))
368                })?;
369                canonical.push(missing);
370                return Ok(canonical);
371            }
372            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
373            Err(error) => {
374                return Err(SqliteError::InvalidData(format!(
375                    "failed to canonicalize database path ancestor {}: {error}",
376                    ancestor.display()
377                )));
378            }
379        }
380    }
381
382    Err(SqliteError::InvalidData(format!(
383        "database path has no canonicalizable ancestor: {}",
384        absolute.display()
385    )))
386}
387
388/// Enforce ADR-131 Decision 2's `write_admission_deadline_ms` bound at
389/// configuration load: `[100, 10000]` ms, rejected rather than clamped when
390/// out of range so a misconfiguration is never silently reinterpreted as a
391/// different deadline than the operator asked for.
392pub(super) fn validate_write_admission_deadline(deadline_ms: u64) -> Result<(), SqliteError> {
393    if WRITE_ADMISSION_DEADLINE_MS_RANGE.contains(&deadline_ms) {
394        return Ok(());
395    }
396    Err(SqliteError::InvalidConfig(format!(
397        "write_admission_deadline_ms must be in [{}, {}] ms, got {deadline_ms}",
398        WRITE_ADMISSION_DEADLINE_MS_RANGE.start(),
399        WRITE_ADMISSION_DEADLINE_MS_RANGE.end()
400    )))
401}