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}