Skip to main content

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}