khive_db/pool/pool_impl.rs
1//! Pool diagnostics, writer-task lifecycle and connection maintenance.
2
3use std::path::Path;
4use std::sync::atomic::Ordering;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use khive_storage::error::StorageError;
9use khive_storage::tx_registry::TxOrigin;
10use khive_storage::StorageCapability;
11use rusqlite::{Connection, OpenFlags};
12use tokio::sync::Semaphore;
13
14use crate::database_owner_identity::{DatabaseOwnerIdentity, DatabaseOwnerIdentityError};
15use crate::disk_guard_config::EffectiveDiskGuardConfig;
16#[cfg(test)]
17use crate::disk_guard_config::{DiskGuardConfigSource, DEFAULT_DISK_GUARD_DEADLINE_MS};
18use crate::error::SqliteError;
19#[cfg(any(unix, windows))]
20use crate::file_identity::{database_file_identity, DatabaseFileIdentity};
21use crate::writer_task::WriterTaskHandle;
22
23#[cfg(any(unix, windows))]
24use super::opened_sqlite_file_identity;
25#[cfg(doc)]
26use super::pool_exhausted_error;
27#[cfg(feature = "namespace-trigram-proto")]
28use super::register_namespace_trigram;
29use super::{
30 claimed_file_identity, close_connection_quietly, configure_reader_connection,
31 current_journal_mode, open_reader_connection, read_database_id, reader_connection_is_healthy,
32 register_read_functions, register_writer_clock, reset_reader_connection, ConnectionPool,
33 PoolConfig, PoolIdentityRegistration, PooledReader, ReaderAcquisitionSnapshot, ReaderGuard,
34 RuntimeWriteOperation, SearchMechanismSnapshot, StandaloneReaderPurpose, WriteAdmission,
35 WriterAcquisitionCounters, WriterAcquisitionSnapshot, NEXT_MAIN_POOL_GENERATION,
36};
37#[cfg(test)]
38use super::{run_identity_open_hook, IdentityOpenStage};
39
40impl ConnectionPool {
41 /// Snapshot all instrumented writer acquisition outcomes since this pool
42 /// was constructed.
43 pub fn writer_acquisition_snapshot(&self) -> WriterAcquisitionSnapshot {
44 self.writer_acquisition_counters
45 .snapshot(self.write_admission.lease_timeouts())
46 }
47
48 /// Count a coordinator call only after served-kind filtering selects this
49 /// backend. The canonical requested kind is the granular kind when one was
50 /// supplied, or the entity/note substrate otherwise.
51 pub fn record_search_dispatch(&self, backend_id: &str, requested_kind: &str) {
52 let mut dispatches = self.search_dispatches.lock();
53 let count = dispatches
54 .entry(backend_id.to_owned())
55 .or_default()
56 .entry(requested_kind.to_owned())
57 .or_default();
58 *count = count.saturating_add(1);
59 }
60
61 /// Count one actual candidate note row returned at the post-fusion
62 /// hydration seam, including rows later filtered as deleted. Absent rows
63 /// and later result metadata are excluded.
64 pub fn record_note_candidate_hydration_row(&self) {
65 let _ = self.note_candidate_hydration_rows.fetch_update(
66 Ordering::Relaxed,
67 Ordering::Relaxed,
68 |current| Some(current.saturating_add(1)),
69 );
70 }
71
72 /// Snapshot ADR-166 G4/G5 counters. Values reset only with this pool.
73 pub fn search_mechanism_snapshot(&self) -> SearchMechanismSnapshot {
74 SearchMechanismSnapshot {
75 dispatches_by_backend_and_kind: self.search_dispatches.lock().clone(),
76 note_candidate_hydration_rows: self
77 .note_candidate_hydration_rows
78 .load(Ordering::Relaxed),
79 }
80 }
81
82 /// Snapshot reader acquisition, saturation, and hold lifecycle outcomes
83 /// since this pool was constructed. Counters reset only with pool
84 /// reconstruction.
85 pub fn reader_acquisition_snapshot(&self) -> ReaderAcquisitionSnapshot {
86 self.reader_acquisition_counters.snapshot(
87 self.max_readers.max(1),
88 self.sql_bridge_reader_slots.available_permits(),
89 )
90 }
91
92 /// Record one pool-wide reader-admission wait that exhausted the configured
93 /// checkout timeout outside [`Self::reader_until`] (currently the explicit
94 /// raw-SQL read-transaction exception and reads on a standalone writer).
95 pub(crate) fn record_reader_admission_timeout(&self) {
96 self.reader_acquisition_counters.record_checkout_timeout();
97 }
98
99 /// Count a query error from an already checked-out pooled reader when it is
100 /// SQLite's `SQLITE_BUSY` surfacing after the busy handler gave up. Every
101 /// other error, including `SQLITE_LOCKED`, is ignored. Checkout exhaustion
102 /// is counted by [`Self::reader_until`] before any query runs, so the two
103 /// classes cannot overlap.
104 pub(crate) fn record_reader_query_error(&self, error: &StorageError) {
105 if crate::read_cancellation::storage_error_sqlite_code(error)
106 == Some(rusqlite::ErrorCode::DatabaseBusy)
107 {
108 self.reader_acquisition_counters.record_busy_timeout();
109 }
110 }
111
112 /// Clone the pool-scoped counter set for the lifetime-owned writer task.
113 pub(crate) fn writer_acquisition_counters(&self) -> Arc<WriterAcquisitionCounters> {
114 Arc::clone(&self.writer_acquisition_counters)
115 }
116
117 pub(crate) fn write_admission(&self) -> Arc<WriteAdmission> {
118 Arc::clone(&self.write_admission)
119 }
120
121 pub fn effective_disk_guard_config(&self) -> Option<EffectiveDiskGuardConfig> {
122 self.disk_guard_config
123 }
124
125 #[cfg(test)]
126 pub(crate) fn set_test_write_admission(
127 &mut self,
128 floor_bytes: u64,
129 probe: impl Fn(&Path) -> std::io::Result<u64> + Send + Sync + 'static,
130 ) {
131 let database_path = if self.config.read_only {
132 None
133 } else {
134 self.canonical_path().map(Path::to_path_buf)
135 };
136 let admission = Arc::new(
137 WriteAdmission::new(
138 database_path,
139 floor_bytes,
140 DEFAULT_DISK_GUARD_DEADLINE_MS,
141 self.config.volume_lock_dir.clone(),
142 )
143 .expect("test admission volume identity"),
144 );
145 admission.set_test_space_probe(probe);
146 self.write_admission = admission;
147 if self.canonical_path().is_some() && !self.config.read_only {
148 self.disk_guard_config = Some(EffectiveDiskGuardConfig {
149 reserve_bytes: floor_bytes,
150 guard_deadline_ms: DEFAULT_DISK_GUARD_DEADLINE_MS,
151 reserve_source: DiskGuardConfigSource::Backend,
152 deadline_source: DiskGuardConfigSource::Default,
153 legacy_environment_present: false,
154 });
155 }
156 }
157
158 /// Get the current number of available reader connections.
159 pub fn available_readers(&self) -> usize {
160 self.readers.len()
161 }
162
163 /// Get the total number of reader connections in the pool.
164 pub fn max_readers(&self) -> usize {
165 self.max_readers
166 }
167
168 /// Age of the oldest outstanding pooled reader checkout, including
169 /// degraded shared-reader leases and their reset/replacement work.
170 /// Returns `None` when no checkout remains; standalone readers are
171 /// separately tracked by the transaction registry.
172 pub fn oldest_checkout_age(&self) -> Option<Duration> {
173 self.oldest_checkout_age_at(Instant::now())
174 }
175
176 pub(crate) fn oldest_checkout_age_at(&self, now: Instant) -> Option<Duration> {
177 self.reader_checkouts
178 .lock()
179 .first_key_value()
180 .map(|(started, _)| now.saturating_duration_since(*started))
181 }
182
183 pub(super) fn begin_reader_checkout(&self) -> Instant {
184 let started = Instant::now();
185 // Instant resolution can give simultaneous leases the same key.
186 *self.reader_checkouts.lock().entry(started).or_default() += 1;
187 started
188 }
189
190 pub(super) fn finish_reader_checkout(&self, started: Instant) {
191 let mut checkouts = self.reader_checkouts.lock();
192 if let Some(count) = checkouts.get_mut(&started) {
193 *count -= 1;
194 if *count == 0 {
195 checkouts.remove(&started);
196 }
197 }
198 }
199
200 /// Return the pool configuration.
201 pub fn config(&self) -> &PoolConfig {
202 &self.config
203 }
204
205 /// Observe actual SQLite statement starts on this private test pool.
206 ///
207 /// The limit bounds retained SQL records. Failed steps count as attempts;
208 /// preparation alone does not count. The guard observes every pool-owned
209 /// connection, including the queued writer. Do not run unrelated background
210 /// work on the fixture pool; see the guard documentation for limitations.
211 /// Connection setup runs before observation begins on each connection and
212 /// is never recorded, including for opens during an active observation.
213 /// Reader connection opens use the existing reader acquisition counters instead.
214 #[cfg(any(test, feature = "test-support"))]
215 pub fn observe_test_statement_starts(
216 &self,
217 limit: usize,
218 ) -> Result<crate::statement_observer::StatementStartObservation, SqliteError> {
219 self.statement_observer.observe(limit)
220 }
221
222 /// Identify this pool's counter window when it is designated as main.
223 /// Repeated runtime handles and diagnostics reads reuse the same generation;
224 /// constructing secondary pools does not consume main-pool generations.
225 pub fn main_pool_generation(&self) -> u64 {
226 *self.main_pool_generation.get_or_init(|| {
227 NEXT_MAIN_POOL_GENERATION
228 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| {
229 next.checked_add(1)
230 })
231 .expect("main pool generation exhausted")
232 })
233 }
234
235 /// The typed admission failure for a pooled reader checkout that
236 /// exhausted `checkout_timeout`: no reader was acquired, so the
237 /// operation never started and a retry cannot duplicate a side effect.
238 pub(crate) fn reader_admission_timeout(&self, operation: &'static str) -> StorageError {
239 StorageError::AdmissionTimeout {
240 operation: operation.into(),
241 timeout_ms: u64::try_from(self.config.checkout_timeout.as_millis()).unwrap_or(u64::MAX),
242 pool_identity: Some(
243 self.identity_registration
244 .as_ref()
245 .map(PoolIdentityRegistration::label)
246 .unwrap_or_else(|| ":memory:".to_string()),
247 ),
248 }
249 }
250
251 /// Resolve a [`Self::reader_until`] outcome into a checked-out guard or
252 /// the canonical refusal. This is the single home of the checkout
253 /// tri-state; call sites must not re-derive any arm of it:
254 ///
255 /// - `Ok(Some)` — a reader was checked out.
256 /// - `Ok(None)` — `should_stop()` fired: the request was cancelled or hit
257 /// its deadline before checkout. NOT an admission wait, so it maps to
258 /// the non-retryable [`StorageError::Timeout`] — emitting the retryable
259 /// `AdmissionTimeout` here would invite an immediate retry of a request
260 /// its caller already abandoned, into a possibly saturated pool.
261 /// - `Err` carrying the pool's own `SQLITE_BUSY` — `reader_until` executes
262 /// no SQL, so the only `SQLITE_BUSY` it can produce is
263 /// [`pool_exhausted_error`], raised when `checkout_timeout` elapses with
264 /// no reader available. A genuine admission wait that ended before any
265 /// work began maps to the retryable [`StorageError::AdmissionTimeout`].
266 /// - any other `Err` (e.g. [`Self::ensure_pooled_writer_active`] returning
267 /// `InvalidData` for a retired pooled writer) — an opaque driver failure
268 /// under the caller's capability, non-retryable.
269 pub(crate) fn resolve_reader_checkout<'p>(
270 &self,
271 capability: StorageCapability,
272 operation: &'static str,
273 outcome: Result<Option<ReaderGuard<'p>>, SqliteError>,
274 ) -> Result<ReaderGuard<'p>, StorageError> {
275 match outcome {
276 Ok(Some(mut guard)) => {
277 guard.label_operation(operation);
278 Ok(guard)
279 }
280 Ok(None) => Err(self.reader_checkout_refusal(capability, operation, None)),
281 Err(error) => Err(self.reader_checkout_refusal(capability, operation, Some(error))),
282 }
283 }
284
285 /// The refusal for a reader checkout that produced nothing, shared by
286 /// [`Self::resolve_reader_checkout`] and [`Self::acquire_reader_admission`]
287 /// so the arms documented on the former have one home. `None` is the
288 /// stopped-request arm; `Some` carries the failed checkout's error.
289 pub(super) fn reader_checkout_refusal(
290 &self,
291 capability: StorageCapability,
292 operation: &'static str,
293 error: Option<SqliteError>,
294 ) -> StorageError {
295 let Some(error) = error else {
296 return StorageError::Timeout {
297 operation: operation.into(),
298 };
299 };
300 let is_pool_exhausted = matches!(
301 &error,
302 SqliteError::Rusqlite(rusqlite::Error::SqliteFailure(code, _))
303 if code.code == rusqlite::ErrorCode::DatabaseBusy
304 );
305 if is_pool_exhausted {
306 self.reader_admission_timeout(operation)
307 } else {
308 StorageError::driver(capability, operation, error)
309 }
310 }
311
312 /// Pool-wide admission permits shared by pooled readers, the explicit
313 /// raw-SQL read-transaction exception, and reads on standalone writers.
314 pub(crate) fn sql_bridge_reader_slots(&self) -> Arc<Semaphore> {
315 Arc::clone(&self.sql_bridge_reader_slots)
316 }
317
318 /// Pool-wide permit for a file-backed raw-SQL writer handle.
319 pub(crate) fn sql_bridge_writer_slots(&self) -> Arc<Semaphore> {
320 Arc::clone(&self.sql_bridge_writer_slots)
321 }
322
323 /// Current writer holds that prevent voluntary daemon retirement.
324 ///
325 /// The raw-SQL writer permit remains handle-scoped even in autocommit.
326 /// This read acquires no connection and changes no admission policy.
327 pub fn retirement_writer_holds(&self) -> usize {
328 usize::from(self.writer.is_locked())
329 + usize::from(self.sql_bridge_writer_slots.available_permits() == 0)
330 }
331
332 /// This pool's ADR-091 backend-scoped attribution origin (ADR-091,
333 /// backend-scoped WAL-pin attribution design note): `Database(_)` for a
334 /// file-backed pool, `Memory` for an in-memory pool. Every
335 /// `tx_registry::register_scoped` call site threaded in this crate
336 /// passes this value as the span's origin.
337 pub fn origin(&self) -> TxOrigin {
338 self.origin.clone()
339 }
340
341 /// The canonical path this pool's `origin()` identity was minted from,
342 /// `None` for an in-memory pool. `DbIdentity` has no path accessor by
343 /// design; sidecar derivation and other filesystem consumers use this —
344 /// the same canonical value the identity was minted from — instead of
345 /// re-deriving a path from the raw configured one.
346 pub fn canonical_path(&self) -> Option<&Path> {
347 self.identity_path.as_deref()
348 }
349
350 /// Unix file identity retained for callers using the original tuple API.
351 #[cfg(unix)]
352 pub fn opened_file_identity(&self) -> Option<(u64, u64)> {
353 self.opened_file_identity
354 .map(DatabaseFileIdentity::unix_parts)
355 }
356
357 /// Physical file identity pinned to SQLite's opened main file. Construction
358 /// compares it with the path on both sides of the open before admission.
359 #[cfg(any(unix, windows))]
360 pub fn opened_file_identity_record(&self) -> Option<DatabaseFileIdentity> {
361 self.opened_file_identity
362 }
363
364 /// Ownership evidence captured through the database SQLite opened.
365 /// This does not select the topology's main backend or establish a root binding.
366 /// Legacy read-only and low-space opens without a stored UUID must reopen
367 /// after installation; this accessor never installs or re-mints an identity.
368 pub fn database_owner_identity(
369 &self,
370 ) -> Result<DatabaseOwnerIdentity, DatabaseOwnerIdentityError> {
371 if self.identity_path.is_none() {
372 return Err(DatabaseOwnerIdentityError::InMemory);
373 }
374 #[cfg(any(unix, windows))]
375 {
376 let durable_id = self
377 .opened_database_id
378 .ok_or(DatabaseOwnerIdentityError::DurableIdentityUnavailable)?;
379 let file_identity = self
380 .opened_file_identity
381 .ok_or(DatabaseOwnerIdentityError::PhysicalIdentityUnavailable)?;
382 Ok(DatabaseOwnerIdentity {
383 durable_id,
384 file_identity,
385 })
386 }
387 #[cfg(not(any(unix, windows)))]
388 {
389 Err(DatabaseOwnerIdentityError::UnsupportedPlatform)
390 }
391 }
392
393 /// Require both this opened database's UUID and physical file to match.
394 pub fn verify_database_owner(
395 &self,
396 expected: &DatabaseOwnerIdentity,
397 ) -> Result<(), DatabaseOwnerIdentityError> {
398 self.database_owner_identity()?.verify_owner(expected)
399 }
400
401 /// Whether the write queue is effectively enabled for this pool: the
402 /// resolved `write_queue_enabled` flag AND file-backed.
403 ///
404 /// `ConnectionPool::new` resolves the "no preference" (`None`) preference
405 /// to a concrete `Some(..)` once `path` is known, so every reader of
406 /// `config.write_queue_enabled` sees a resolved value; the `debug_assert`
407 /// pins that invariant and a `None` that slipped past would read as
408 /// disabled. Bypassing `ConnectionPool::new` to construct a pool is a
409 /// construction-path bug. Use this instead of repeating
410 /// `config().write_queue_enabled.unwrap_or(false) && config().path.is_some()`
411 /// at every routing/violation site.
412 pub fn write_queue_active(&self) -> bool {
413 debug_assert!(
414 self.config.write_queue_enabled.is_some(),
415 "write_queue_enabled must be resolved to Some(..) by ConnectionPool::new \
416 before any write_queue_active read"
417 );
418 self.config.write_queue_enabled.unwrap_or(false) && self.config.path.is_some()
419 }
420
421 /// Whether a writer-task JoinHandle has been stored at least once.
422 ///
423 /// Unlike [`Self::take_writer_task_join`], this remains true after the
424 /// one-shot handle slot is emptied, distinguishing a task that never
425 /// spawned from a handle another caller already consumed.
426 pub fn writer_task_join_was_stored(&self) -> bool {
427 self.writer_task_join_stored.load(Ordering::SeqCst)
428 }
429
430 /// Return the pool-wide ADR-067 Component A writer task, spawning it
431 /// lazily on first access if `PoolConfig::write_queue_enabled` is set.
432 /// Exactly one writer task exists per `ConnectionPool` (per DB file); see
433 /// crates/khive-db/docs/api/pool.md#connectionpoolwriter_task_handle--single-writer-task-rationale
434 /// for why a per-store writer task would defeat the single-writer
435 /// guarantee.
436 ///
437 /// Returns `Ok(None)` if the flag is off, or if the writer task failed to
438 /// spawn for a reason other than a missing runtime (for example, an
439 /// in-memory pool has no standalone-connection support) — callers fall
440 /// back to the legacy pool-mutex write path in either case. A spawn
441 /// failure is logged once here (at first access), not once per store.
442 ///
443 /// Returns `Err(StorageError::WriterTaskNoRuntime)` instead of panicking
444 /// when `write_queue_enabled` is set but this is the first access and no
445 /// Tokio runtime is available on the calling thread (checked via
446 /// [`tokio::runtime::Handle::try_current`]) — spawning the writer task
447 /// requires `tokio::spawn`, which panics outside a runtime. Callers that
448 /// already treat a missing writer task as best-effort (construction-time
449 /// degrade to the legacy path, matching slice 1's documented policy) can
450 /// collapse this into `None` with `.ok().flatten()`; callers that need to
451 /// fail loud on a genuine misconfiguration (write queue requested but no
452 /// runtime to run it on) can propagate the `Err` directly.
453 pub fn writer_task_handle(&self) -> Result<Option<WriterTaskHandle>, StorageError> {
454 // Same pinned invariant `write_queue_active` asserts, kept inline
455 // here because this gate keys on the flag ALONE: an explicit
456 // `Some(true)` on an in-memory pool must still attempt the spawn
457 // and degrade (documented + tested in
458 // `explicit_true_stays_on_for_memory_backed_pool`), so the
459 // file-backed half of `write_queue_active` cannot gate this early
460 // return.
461 debug_assert!(
462 self.config.write_queue_enabled.is_some(),
463 "write_queue_enabled must be resolved to Some(..) by ConnectionPool::new \
464 before any writer_task_handle read"
465 );
466 if !self.config.write_queue_enabled.unwrap_or(false) {
467 return Ok(None);
468 }
469 // Fast path: already resolved (spawned, degraded, or off) by an
470 // earlier call — no need to re-check the runtime.
471 if let Some(existing) = self.writer_task.get() {
472 return Ok(existing.clone());
473 }
474 // Not yet initialized and the flag is on: spawning requires
475 // `tokio::spawn`, which panics outside a runtime context. Check
476 // first and fail loud with a typed error instead.
477 if tokio::runtime::Handle::try_current().is_err() {
478 return Err(StorageError::WriterTaskNoRuntime);
479 }
480 Ok(self
481 .writer_task
482 .get_or_init(|| {
483 #[cfg(test)]
484 self.writer_task_spawn_count
485 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
486
487 match crate::writer_task::spawn(self, self.config.write_queue_capacity) {
488 Ok(handle) => Some(handle),
489 Err(e) => {
490 tracing::warn!(target: "khive_db::pool",
491 error = %e,
492 "KHIVE_WRITE_QUEUE=1 but the writer task failed to spawn; \
493 writes fall back to the pool-mutex path"
494 );
495 None
496 }
497 }
498 })
499 .clone())
500 }
501
502 /// Resolve the writer task for a store write at the moment the write is
503 /// issued, rather than trusting only a handle cached by a synchronous
504 /// store constructor. Construction can legitimately run before Tokio is
505 /// entered, in which case `writer_task_handle()` returns
506 /// `WriterTaskNoRuntime` without caching a terminal `None`.
507 ///
508 /// Strict routing makes every missing handle fail closed here. The
509 /// caller remains responsible for recording a non-strict direct fallback
510 /// at the exact fallback seam with [`Self::record_direct_route`].
511 pub(crate) fn writer_task_for_write(
512 &self,
513 cached: Option<&WriterTaskHandle>,
514 operation: &'static str,
515 ) -> Result<Option<WriterTaskHandle>, StorageError> {
516 let handle = match cached {
517 Some(handle) => Some(handle.clone()),
518 None => match self.writer_task_handle() {
519 Ok(handle) => handle,
520 Err(error) if self.config.write_routing_strict => return Err(error),
521 Err(_) => None,
522 },
523 };
524
525 if handle.is_none() && self.config.write_routing_strict {
526 return Err(StorageError::Pool {
527 operation: operation.into(),
528 message: "strict write routing requires a writer-task handle; no handle is \
529 available, so the direct writer fallback was refused"
530 .into(),
531 });
532 }
533 Ok(handle)
534 }
535
536 /// Record one actual compatibility fallback around the writer task. A
537 /// file-backed pool with the queue enabled should never reach this seam
538 /// in strict mode because [`Self::writer_task_for_write`] refuses first.
539 pub(crate) fn record_direct_route(&self, site: crate::timeout_sink::Site) {
540 if self.write_queue_active() {
541 crate::timeout_sink::emit_direct_route_violation(
542 &crate::timeout_sink::db_label(self),
543 site,
544 );
545 }
546 }
547
548 /// Resolve a runtime-owned transaction through the same strict/compatibility
549 /// policy as store writes. `None` permits the caller's direct transaction and
550 /// records its compatibility fallback when the file-backed queue is enabled.
551 /// A strict refusal returns before any direct-writer acquisition or telemetry.
552 pub fn writer_task_for_runtime_write(
553 &self,
554 operation: RuntimeWriteOperation,
555 ) -> Result<Option<WriterTaskHandle>, StorageError> {
556 let handle = self.writer_task_for_write(None, operation.operation())?;
557 if handle.is_none() {
558 self.record_direct_route(operation.fallback_site());
559 }
560 Ok(handle)
561 }
562
563 /// Test-only: how many times the writer-task init closure actually ran.
564 /// Must be at most 1 for the pool's whole lifetime, regardless of how
565 /// many times [`Self::writer_task_handle`] is called or how many stores
566 /// are constructed over this pool.
567 #[cfg(test)]
568 pub(crate) fn writer_task_spawn_count(&self) -> usize {
569 self.writer_task_spawn_count
570 .load(std::sync::atomic::Ordering::SeqCst)
571 }
572
573 /// Record the writer task's `tokio::spawn` JoinHandle. Called exactly
574 /// once, by [`crate::writer_task::spawn`], immediately after spawning —
575 /// the same `writer_task` OnceLock init that makes spawn at-most-once
576 /// per pool makes this write at-most-once per pool.
577 ///
578 /// First-wins: if a handle was ever stored (including one a caller has
579 /// since taken — `writer_task_join_stored` remembers), the existing
580 /// state is kept and the new handle is dropped (dropping a `JoinHandle`
581 /// detaches its task without cancelling it). A second store violates the
582 /// at-most-once contract and trips the debug_assert in debug builds;
583 /// release builds keep the first handle rather than silently swapping
584 /// the drain owner out from under whichever caller already took it.
585 pub(crate) fn set_writer_task_join(&self, join: tokio::task::JoinHandle<()>) {
586 // `swap(true)` returns the prior value: `true` means a handle was
587 // stored at least once before, so this is a second store — even when
588 // the slot itself is empty because `take_writer_task_join` already
589 // ran (the slot alone cannot tell "never stored" from "taken").
590 let first_store = !self.writer_task_join_stored.swap(true, Ordering::SeqCst);
591 debug_assert!(
592 first_store,
593 "writer task JoinHandle stored twice (even counting a taken one); \
594 the writer_task OnceLock is supposed to make spawn at-most-once per pool"
595 );
596 if first_store {
597 *self.writer_task_join.lock() = Some(join);
598 }
599 }
600
601 /// Take the writer task's JoinHandle, if a writer task was spawned and
602 /// the handle has not already been taken.
603 ///
604 /// Intended for short-lived batch callers that drop every
605 /// [`WriterTaskHandle`] clone (closing the queue) and then need to await
606 /// the task's exit before treating the database file as settled: the
607 /// task's connection close fires SQLite's close-time WAL checkpoint, so
608 /// until the task exits the file bytes can still move after the caller's
609 /// last write returned.
610 ///
611 /// One-shot: `None` means either the write queue never spawned
612 /// (disabled, or spawn degraded) or another caller already took the
613 /// handle — in both cases there is nothing further to await here.
614 /// Exactly one subsystem may own the drain: the single caller that
615 /// receives `Some(_)` is the sole owner of the task-exit await (and of
616 /// the close-time WAL checkpoint that settles the database file); every
617 /// later caller receives `None` and must not arrange its own await.
618 pub fn take_writer_task_join(&self) -> Option<tokio::task::JoinHandle<()>> {
619 self.writer_task_join.lock().take()
620 }
621
622 pub(super) fn open_pooled_reader(&self) -> Result<Box<PooledReader>, SqliteError> {
623 let opened_at = Instant::now();
624 // Allocate once per physical reader; checkouts move the same box between
625 // the queue and lease without allocating on every borrow/return.
626 Ok(Box::new(PooledReader {
627 conn: self.open_reader_connection()?,
628 opened_at,
629 checkouts: 0,
630 }))
631 }
632
633 pub(super) fn open_reader_connection(&self) -> Result<Connection, SqliteError> {
634 let path = self.read_connection_path()?;
635 #[cfg(any(unix, windows))]
636 if let Some(identity_path) = self.identity_path.as_deref() {
637 self.verify_opened_file_identity(identity_path)?;
638 }
639 let conn = open_reader_connection(path, &self.config)?;
640 #[cfg(any(unix, windows))]
641 if let Some(identity_path) = self.identity_path.as_deref() {
642 self.verify_connection_file_identity(&conn, identity_path)?;
643 }
644 self.verify_opened_database_id(&conn)?;
645 #[cfg(any(test, feature = "test-support"))]
646 crate::statement_observer::install(&conn, &self.statement_observer)?;
647 Ok(conn)
648 }
649
650 fn read_connection_path(&self) -> Result<&Path, SqliteError> {
651 self.read_only_open_target
652 .as_deref()
653 .or(self.identity_path.as_deref())
654 .ok_or_else(|| {
655 SqliteError::InvalidData(
656 "in-memory databases do not support standalone connections".to_string(),
657 )
658 })
659 }
660
661 /// Open a standalone read-write connection to the same file-backed database.
662 ///
663 /// Stores whose trait methods take `Send + 'static` closures (executed via
664 /// `spawn_blocking`) cannot hold the pooled `WriterGuard`'s `MutexGuard`
665 /// across the call — it opens an independent connection instead. This
666 /// must still honor `PoolConfig::read_only`: opening
667 /// `SQLITE_OPEN_READ_WRITE` unconditionally here would let a read-only
668 /// backend's graph/event/text stores bypass the flag that the pooled
669 /// writer enforces via `query_only`. A fully configured successful open
670 /// increments the standalone acquisition class exactly once.
671 pub fn open_standalone_writer(&self) -> Result<Connection, SqliteError> {
672 self.write_admission.check()?;
673 self.open_standalone_writer_for_admitted_operation()
674 }
675
676 /// Open for a caller that owns §4 admission at its transaction or
677 /// autocommit execution boundary. Connection open alone cannot authorize
678 /// the later write, especially when a handle is cached across requests.
679 pub(crate) fn open_standalone_writer_for_admitted_operation(
680 &self,
681 ) -> Result<Connection, SqliteError> {
682 let conn = self.open_standalone_writer_untracked()?;
683 self.writer_acquisition_counters
684 .standalone_acquisitions
685 .fetch_add(1, Ordering::Relaxed);
686 Ok(conn)
687 }
688
689 /// Open an infrastructure-owned standalone writer connection without
690 /// counting it as one write-operation acquisition.
691 ///
692 /// Restricted to the diagnostics PASSIVE probe, the writer task's
693 /// one-time lifetime connection, and the checkpoint task's dedicated
694 /// long-lived connection (opened once at startup and reused across
695 /// ticks — see `CheckpointConnection::ensure_open`). Actual file-backed
696 /// write paths must call [`Self::open_standalone_writer`] so their
697 /// acquisitions are observable.
698 pub(crate) fn open_standalone_writer_untracked(&self) -> Result<Connection, SqliteError> {
699 let path = self.identity_path.as_deref().ok_or_else(|| {
700 SqliteError::InvalidData(
701 "in-memory databases do not support standalone connections".to_string(),
702 )
703 })?;
704
705 if self.config.read_only {
706 return Err(SqliteError::InvalidData(
707 "database is read-only: standalone write connections are not permitted".to_string(),
708 ));
709 }
710
711 // The configured spelling may be a symlink that changed since this
712 // pool opened. Use its pinned target, and refuse replacement of that
713 // target before SQLite can execute the diagnostics PASSIVE checkpoint.
714 #[cfg(any(unix, windows))]
715 self.verify_opened_file_identity(path)?;
716
717 #[cfg(test)]
718 run_identity_open_hook(path, IdentityOpenStage::BeforeStandaloneOpen, None);
719
720 let conn = claimed_file_identity::open_connection(
721 &self.config,
722 path,
723 OpenFlags::SQLITE_OPEN_READ_WRITE
724 | OpenFlags::SQLITE_OPEN_NO_MUTEX
725 | OpenFlags::SQLITE_OPEN_URI,
726 self.identity_path.as_deref(),
727 )?;
728 #[cfg(test)]
729 run_identity_open_hook(path, IdentityOpenStage::AfterStandaloneOpen, Some(&conn));
730 #[cfg(any(unix, windows))]
731 self.verify_connection_file_identity(&conn, path)?;
732 self.verify_opened_database_id(&conn)?;
733 #[cfg(feature = "namespace-trigram-proto")]
734 register_namespace_trigram(&conn)?;
735 register_writer_clock(&conn)?;
736 // Expression indexes over these keys are maintained by every writer
737 // (the write-queue task and per-store standalone writers included), so
738 // each of them needs the same functions the pooled writer registers.
739 register_read_functions(&conn)?;
740 conn.busy_timeout(self.config.busy_timeout)?;
741 if self.config.code_map_vfs.is_none() {
742 self.checkpoint_ownership
743 .configure_wal_autocheckpoint(&conn)?;
744 }
745 conn.pragma_update(None, "foreign_keys", "ON")?;
746 conn.pragma_update(None, "synchronous", "NORMAL")?;
747
748 let wal_enabled =
749 self.config.wal_mode && current_journal_mode(&conn)?.eq_ignore_ascii_case("wal");
750 if wal_enabled {
751 conn.pragma_update(
752 None,
753 "journal_size_limit",
754 self.config.journal_size_limit_bytes,
755 )?;
756 }
757
758 #[cfg(any(test, feature = "test-support"))]
759 crate::statement_observer::install(&conn, &self.statement_observer)?;
760 Ok(conn)
761 }
762
763 #[cfg(any(unix, windows))]
764 pub(super) fn verify_opened_file_identity(&self, path: &Path) -> Result<(), SqliteError> {
765 let Some(expected) = self.opened_file_identity else {
766 return Err(SqliteError::InvalidData(
767 "file-backed pool has no opened database file identity".to_string(),
768 ));
769 };
770 let current = database_file_identity(path).ok();
771 if current != Some(expected) {
772 return Err(SqliteError::InvalidData(
773 "pool database file identity changed since the first open; refusing standalone connection"
774 .to_string(),
775 ));
776 }
777 Ok(())
778 }
779
780 #[cfg(any(unix, windows))]
781 pub(super) fn verify_connection_file_identity(
782 &self,
783 conn: &Connection,
784 path: &Path,
785 ) -> Result<(), SqliteError> {
786 let opened = opened_sqlite_file_identity(conn, path)?;
787 if self.opened_file_identity != Some(opened) {
788 return Err(SqliteError::InvalidData(
789 "pool database file identity changed since the first open; refusing standalone connection"
790 .to_string(),
791 ));
792 }
793 Ok(())
794 }
795
796 fn verify_opened_database_id(&self, conn: &Connection) -> Result<(), SqliteError> {
797 // A read-only pool, or a writable pool opened below the space reserve,
798 // may precede installation of the nonce by another process. Without a
799 // nonce to pin, use the existing file identity checks where available.
800 let Some(expected) = self.opened_database_id else {
801 return Ok(());
802 };
803 if self.identity_path.is_some() && read_database_id(conn)? != Some(expected) {
804 return Err(SqliteError::InvalidData(
805 "pool database identity changed since the first open; refusing standalone connection"
806 .to_string(),
807 ));
808 }
809 Ok(())
810 }
811
812 /// Effective `PRAGMA wal_autocheckpoint` for a writer-capable connection
813 /// opened right now: `0` once a dedicated checkpoint owner has claimed
814 /// the pool, the bounded fallback otherwise.
815 #[cfg(test)]
816 pub(crate) fn effective_wal_autocheckpoint_pages(&self) -> u32 {
817 self.checkpoint_ownership.wal_autocheckpoint_pages()
818 }
819
820 /// Claim routine WAL-checkpoint ownership for this pool.
821 ///
822 /// Called by the scheduled checkpoint task at startup — the one caller
823 /// that actually replaces SQLite's per-commit autocheckpoint with
824 /// dedicated PASSIVE checkpointing (ADR-091 Amendment 10). The claim
825 /// makes every subsequently opened writer-capable connection set
826 /// `PRAGMA wal_autocheckpoint = 0`, and re-applies that pragma on the
827 /// already-open pooled writer under the writer mutex. A writer task
828 /// spawned before the claim keeps its own long-lived connection;
829 /// [`Self::propagate_checkpoint_claim_to_writer_task`] reaches that one.
830 ///
831 /// Without a claim, writer-capable connections keep the bounded
832 /// `FALLBACK_WAL_AUTOCHECKPOINT_PAGES` threshold, so a writable pool
833 /// in a process that never runs the checkpoint task (embedded runtimes,
834 /// one-shot CLI executions) retains SQLite's own WAL reclamation instead
835 /// of growing its WAL without bound.
836 ///
837 /// Read-only pools record the claim but have no writer-capable
838 /// connections to reconfigure. Writable pools publish the claim only after
839 /// the pooled writer is configured successfully; a failed attempt keeps
840 /// the bounded fallback active and remains retryable.
841 pub fn claim_checkpoint_ownership(&self) -> Result<(), SqliteError> {
842 if !self.checkpoint_ownership.begin_claim() {
843 return Ok(());
844 }
845 let result = (|| {
846 if !self.config.read_only {
847 let writer = self.writer_for_checkpoint_operation()?;
848 writer.conn().pragma_update(None, "wal_autocheckpoint", 0)?;
849 }
850 Ok(())
851 })();
852 self.checkpoint_ownership.finish_claim(result.is_ok());
853 result
854 }
855
856 /// Flip an already-running writer task's long-lived connection to the
857 /// claimed-owner setting.
858 ///
859 /// Connections opened after [`Self::claim_checkpoint_ownership`] inherit
860 /// `wal_autocheckpoint = 0` at open; only a writer task spawned before
861 /// the claim still holds a connection on the bounded fallback. Returns
862 /// `Ok(())` without side effects when the pool's write queue is
863 /// disabled.
864 pub async fn propagate_checkpoint_claim_to_writer_task(&self) -> Result<(), StorageError> {
865 let Some(handle) = self.writer_task_handle()? else {
866 return Ok(());
867 };
868 handle
869 .send_top_level(|conn| {
870 conn.pragma_update(None, "wal_autocheckpoint", 0)
871 .map_err(|e| StorageError::Pool {
872 operation: "claim_checkpoint_ownership".into(),
873 message: e.to_string(),
874 })
875 })
876 .await
877 }
878
879 /// Open a standalone read-only connection for one enumerated structural
880 /// exception to pooled-reader routing.
881 ///
882 /// There is intentionally no public/generic standalone-reader fallback.
883 /// Ordinary request reads use [`Self::reader`] and surface bounded pool
884 /// exhaustion. Adding a purpose variant or call site is therefore a
885 /// deliberate, scoped architecture change (ADR-165 Slice 2), not a
886 /// routine extension.
887 pub(crate) fn open_standalone_reader(
888 &self,
889 purpose: StandaloneReaderPurpose,
890 ) -> Result<Connection, SqliteError> {
891 let path = self.read_connection_path()?;
892
893 #[cfg(any(unix, windows))]
894 if let Some(identity_path) = self.identity_path.as_deref() {
895 self.verify_opened_file_identity(identity_path)?;
896 }
897
898 let conn = claimed_file_identity::open_connection(
899 &self.config,
900 path,
901 OpenFlags::SQLITE_OPEN_READ_ONLY
902 | OpenFlags::SQLITE_OPEN_NO_MUTEX
903 | OpenFlags::SQLITE_OPEN_URI,
904 self.identity_path.as_deref(),
905 )?;
906 #[cfg(any(unix, windows))]
907 if let Some(identity_path) = self.identity_path.as_deref() {
908 self.verify_connection_file_identity(&conn, identity_path)?;
909 }
910 self.verify_opened_database_id(&conn)?;
911 configure_reader_connection(&conn, &self.config)?;
912 conn.pragma_update(None, "synchronous", "NORMAL")?;
913 self.reader_acquisition_counters
914 .record_standalone_open(purpose);
915 #[cfg(any(test, feature = "test-support"))]
916 crate::statement_observer::install(&conn, &self.statement_observer)?;
917 Ok(conn)
918 }
919
920 pub(super) fn return_reader(&self, conn: Box<PooledReader>, dirty: bool) {
921 if self.max_readers == 0 {
922 return;
923 }
924
925 if !conn.past_limit(&self.config, Instant::now())
926 && reset_reader_connection(&conn.conn, dirty, &self.config)
927 && reader_connection_is_healthy(&conn.conn)
928 {
929 self.enqueue_reader_slot(conn);
930 return;
931 }
932
933 close_connection_quietly(conn.conn);
934 self.replace_discarded_reader_slot();
935 }
936
937 /// Push a connection back onto the physical reader queue, discarding it
938 /// (rather than growing the queue past its configured capacity) if the
939 /// queue is already full.
940 fn enqueue_reader_slot(&self, conn: Box<PooledReader>) {
941 if let Err(conn) = self.readers.push(conn) {
942 eprintln!("[sqlite-pool] reader pool queue full, discarding replacement connection");
943 close_connection_quietly(conn.conn);
944 }
945 }
946
947 /// Open a fresh connection to refill one physical reader slot after its
948 /// previous occupant was closed and discarded — either disqualified on
949 /// an ordinary return ([`Self::return_reader`]) or abandoned by a
950 /// non-reusable checkout ([`ReaderGuard`]'s `Drop`). Both call sites
951 /// share this so a failed replacement is recorded and logged identically
952 /// either way, instead of one path silently shrinking the pool.
953 pub(super) fn replace_discarded_reader_slot(&self) {
954 self.reader_acquisition_counters
955 .reader_discards
956 .fetch_add(1, Ordering::Relaxed);
957 match self.open_pooled_reader() {
958 Ok(conn) => self.enqueue_reader_slot(conn),
959 Err(error) => {
960 self.reader_acquisition_counters
961 .record_reader_replacement_open_failure();
962 tracing::warn!(target: "khive_db::pool",
963 %error,
964 "sqlite-pool: reader replacement connection failed to open; the physical \
965 pool permanently shrinks by one slot below max_readers"
966 );
967 }
968 }
969 }
970}