Skip to main content

khive_db/pool/
guards.rs

1//! Reader lease capabilities, cleanup, and the owned shared-reader transaction.
2
3use std::cell::Cell;
4use std::sync::Arc;
5use std::thread;
6use std::time::{Duration, Instant};
7
8use khive_storage::{error::StorageError, StorageCapability};
9use rusqlite::Connection;
10
11use crate::error::SqliteError;
12
13use super::{
14    close_connection_quietly, pool_exhausted_error, restore_shared_reader_state, ConnectionPool,
15    PoolConfig,
16};
17
18#[derive(Debug)]
19pub(super) struct PooledReader {
20    pub(super) conn: Connection,
21    pub(super) opened_at: Instant,
22    pub(super) checkouts: u64,
23}
24
25impl PooledReader {
26    pub(super) fn past_limit(&self, config: &PoolConfig, now: Instant) -> bool {
27        now.saturating_duration_since(self.opened_at) > config.reader_max_age
28            || self.checkouts > config.reader_max_ops
29    }
30}
31
32pub(super) enum ReaderLease<'pool> {
33    Pooled(Box<PooledReader>),
34    Shared(parking_lot::MutexGuard<'pool, Connection>),
35}
36
37/// A value-extraction view of one row from a reader lease.
38///
39/// It deliberately exposes neither the prepared statement nor its connection.
40///
41/// ```compile_fail
42/// fn statement(row: &khive_db::ReaderRow<'_, '_>) {
43///     let _: &rusqlite::Statement<'_> = row.as_ref();
44/// }
45/// ```
46pub struct ReaderRow<'row, 'statement> {
47    row: &'row rusqlite::Row<'statement>,
48}
49
50impl ReaderRow<'_, '_> {
51    /// Extract a value by zero-based column index or column name.
52    pub fn get<I: rusqlite::RowIndex, T: rusqlite::types::FromSql>(
53        &self,
54        index: I,
55    ) -> rusqlite::Result<T> {
56        self.row.get(index)
57    }
58
59    /// Borrow a SQLite value without exposing statement metadata or execution.
60    pub fn get_ref<I: rusqlite::RowIndex>(
61        &self,
62        index: I,
63    ) -> rusqlite::Result<rusqlite::types::ValueRef<'_>> {
64        self.row.get_ref(index)
65    }
66}
67
68/// One public query owns this lease's connection-global progress handler.
69struct ReaderQueryInProgress<'a>(&'a Cell<bool>);
70
71impl Drop for ReaderQueryInProgress<'_> {
72    fn drop(&mut self) {
73        self.0.set(false);
74    }
75}
76
77/// One pool-wide reader admission permit acquired before any connection is
78/// selected, with the instant its checkout wait began so a single
79/// `checkout_timeout` bounds both the permit wait and the connection pick.
80/// Hand it to [`ConnectionPool::reader_with_admission`], which moves the permit
81/// into the resulting [`ReaderGuard`].
82pub(crate) struct ReaderAdmission {
83    pub(super) slot: tokio::sync::OwnedSemaphorePermit,
84    pub(super) started: Instant,
85}
86
87/// A reader connection checked out from the pool.
88/// Returns the connection to the pool on drop.
89pub struct ReaderGuard<'pool> {
90    pub(super) lease: Option<ReaderLease<'pool>>,
91    /// One permit from the pool-wide reader budget, shared with the explicit
92    /// raw-SQL transaction exception. Returned only after the connection has
93    /// been reset/replaced and made reusable.
94    pub(super) admission_slot: Option<tokio::sync::OwnedSemaphorePermit>,
95    pub(super) pool: &'pool ConnectionPool,
96    pub(super) reusable: Cell<bool>,
97    pub(super) query_in_progress: Cell<bool>,
98    pub(super) checked_out_at: Instant,
99    /// Set by [`Self::mark_dirty`] whenever this checkout ran a `SqlReader`
100    /// raw-SQL statement (`sql_bridge`'s `run_pool_reader_query`), never by a
101    /// typed store read. Gates whether `Drop` pays the TEMP-object,
102    /// attachment, and connection-setting pristine scan on return —
103    /// `reader_connection_state_is_pristine` and
104    /// `reader_connection_settings_match_baseline` never touch the hot path
105    /// of an ordinary typed checkout.
106    pub(super) dirty: Cell<bool>,
107    /// Names the typed-store operation this checkout was resolved for, set
108    /// once by [`ConnectionPool::resolve_reader_checkout`]. `None` means the
109    /// checkout never passed that route (the pool-internal and raw-SQL
110    /// callers), and the diagnostics maximum reports it as unattributed
111    /// rather than guessing.
112    pub(super) operation: Option<&'static str>,
113}
114
115impl<'pool> ReaderGuard<'pool> {
116    /// Access the connection from within `khive-db`. Every internal caller
117    /// is either a typed store (proven read-only by construction) or a
118    /// raw-SQL route that already calls [`Self::mark_dirty`] itself, so this
119    /// stays crate-private: it is the untracked half of the pristine-return
120    /// contract, and a caller outside the crate has no way to pay into that
121    /// contract by calling `mark_dirty` (also crate-private). A raw
122    /// `&Connection` is never handed to a caller outside `khive-db` — use
123    /// [`Self::query_row`] instead, which admits only read-shaped SQL.
124    pub(crate) fn conn(&self) -> &Connection {
125        match self
126            .lease
127            .as_ref()
128            .expect("reader guard missing connection")
129        {
130            ReaderLease::Pooled(reader) => &reader.conn,
131            ReaderLease::Shared(guard) => guard,
132        }
133    }
134
135    /// Run one read-only statement against this reader lease and map the
136    /// first resulting row, for callers outside `khive-db`.
137    ///
138    /// Unlike a raw `Connection`, this never hands out a capability that can
139    /// change connection-local or database state: `sql` is checked against
140    /// the same allow-listed read-shape classifier
141    /// (`SELECT`/`WITH ... SELECT`/`VALUES`/`EXPLAIN`/a fixed read-only
142    /// `PRAGMA` set) the pooled `SqlReader` surface admits raw SQL through,
143    /// and anything else — `BEGIN`, DML, DDL, a setting `PRAGMA`, `ATTACH`
144    /// — is refused before it ever reaches SQLite. An admitted statement
145    /// still marks the checkout dirty unconditionally, so `Drop` always pays
146    /// the pristine-state scan (or, in degraded shared-lease mode, the
147    /// settings/rollback verification) on return.
148    ///
149    /// SQL stepping cooperatively observes the current request's cancellation
150    /// and original absolute deadline. Synchronous mapper/native callback code
151    /// cannot be forcibly preempted; a post-check refuses a successful result
152    /// if the request stopped while that code ran.
153    pub fn query_row<T, P, F>(&self, sql: &str, params: P, f: F) -> Result<T, SqliteError>
154    where
155        P: rusqlite::Params,
156        F: FnOnce(&ReaderRow<'_, '_>) -> rusqlite::Result<T>,
157    {
158        crate::sql_bridge::reader_capability_admits(sql).map_err(SqliteError::InvalidData)?;
159        if !self.reusable.get() {
160            return Err(SqliteError::InvalidData(
161                "reader lease is quarantined after failed read cleanup".into(),
162            ));
163        }
164        // Params may call user-provided ToSql before stepping, and the mapper
165        // also runs user code. Neither may replace this query's progress handler
166        // by recursively querying the same lease.
167        if self.query_in_progress.replace(true) {
168            return Err(SqliteError::InvalidData(
169                "reader lease is already executing a query".into(),
170            ));
171        }
172        let _in_progress = ReaderQueryInProgress(&self.query_in_progress);
173        self.mark_dirty();
174        crate::read_cancellation::run_borrowed_reader(self, |conn, admission| {
175            conn.query_row(sql, params, |row| {
176                // Parameter conversion runs before any stepping, and a query
177                // cheap enough not to trip the progress handler never polls at
178                // all, so this is the only point that can keep the documented
179                // guarantee: a cancelled request does not enter the mapper.
180                // SQLITE_INTERRUPT is the code the scope already recognises, so
181                // the refusal converts to the same timeout error as a
182                // cancellation observed during stepping.
183                if !admission.admits() {
184                    return Err(rusqlite::Error::SqliteFailure(
185                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
186                        Some("request stopped before the mapper".into()),
187                    ));
188                }
189                f(&ReaderRow { row })
190            })
191            .map_err(|error| {
192                StorageError::driver(StorageCapability::Sql, "reader_guard.query_row", error)
193            })
194        })
195        .map_err(|error| {
196            self.pool.record_reader_query_error(&error);
197            match error {
198                StorageError::Driver {
199                    capability,
200                    operation,
201                    source,
202                } => match source.downcast::<rusqlite::Error>() {
203                    Ok(error) => SqliteError::Rusqlite(*error),
204                    Err(source) => SqliteError::RequestReadStopped(StorageError::Driver {
205                        capability,
206                        operation,
207                        source,
208                    }),
209                },
210                other => SqliteError::RequestReadStopped(other),
211            }
212        })
213    }
214
215    /// Fail closed when connection-global state could not be restored after
216    /// a read. A pooled reader is closed and replaced on drop; a degraded
217    /// shared-writer reader is quarantined for the lifetime of the pool.
218    pub(crate) fn discard(&self) {
219        self.reusable.set(false);
220    }
221
222    /// Mark this checkout as having run a raw-SQL statement, so `Drop` pays
223    /// the pristine-state scan on return instead of skipping it.
224    pub(crate) fn mark_dirty(&self) {
225        self.dirty.set(true);
226    }
227
228    /// Name the typed-store operation this checkout serves, so a long hold
229    /// can be attributed in diagnostics instead of arriving as a bare
230    /// maximum with no next step (#2793).
231    pub(crate) fn label_operation(&mut self, operation: &'static str) {
232        self.operation = Some(operation);
233    }
234}
235
236impl<'pool> Drop for ReaderGuard<'pool> {
237    fn drop(&mut self) {
238        let Some(lease) = self.lease.take() else {
239            return;
240        };
241
242        match lease {
243            ReaderLease::Pooled(conn) if self.reusable.get() => {
244                self.pool.return_reader(conn, self.dirty.get())
245            }
246            ReaderLease::Pooled(conn) => {
247                close_connection_quietly(conn.conn);
248                self.pool.replace_discarded_reader_slot();
249            }
250            ReaderLease::Shared(guard) if !self.reusable.get() => {
251                self.pool.retire_pooled_writer(&guard);
252            }
253            ReaderLease::Shared(guard) => {
254                // The shared lease IS the pool's writer connection (degraded
255                // `max_readers == 0` mode) — there is no separate reader
256                // connection to close and replace. A dirty return that
257                // cannot be verifiably restored poisons the whole pool via
258                // the same terminal-fault path a writer transaction fault
259                // uses, since reuse and replacement are equally unavailable
260                // here.
261                if self.dirty.get() && !restore_shared_reader_state(&guard, &self.pool.config) {
262                    self.pool.retire_pooled_writer(&guard);
263                }
264            }
265        }
266
267        // A checkout remains active until reset/replacement returned the
268        // connection to service (or the degraded writer guard was released),
269        // not merely until the caller's query closure returned.
270        drop(self.admission_slot.take());
271        self.pool
272            .reader_acquisition_counters
273            .record_checkout_completed(self.checked_out_at.elapsed(), self.operation);
274        self.pool.finish_reader_checkout(self.checked_out_at);
275    }
276}
277
278/// Owned, `'static` counterpart to [`ReaderGuard`]'s degraded-mode
279/// (`max_readers == 0`) lease. `ReaderGuard` borrows `&'pool ConnectionPool`
280/// and therefore cannot be retained across the separate `.await` points
281/// between a caller's `SqlReader` trait-method calls — every ordinary call
282/// through it draws a fresh checkout and returns it before the next call
283/// begins. That is fine for an ordinary read, but wrong for the explicit
284/// multi-call deferred read transaction (ADR-005/ADR-091): a `BEGIN
285/// DEFERRED` checked out and returned this way releases the pool's one
286/// physical connection to any concurrent caller — including a writer —
287/// before its own matching `COMMIT`/`ROLLBACK` runs, and an abandoned span
288/// (an error or cancellation between the two) leaves that connection sitting
289/// in the pool inside an open transaction with nothing left holding it. This
290/// type owns an `Arc`-rooted mutex guard instead of borrowing one, so it can
291/// be held by the caller across those `.await` points and give the whole
292/// span real, exclusive ownership of the one connection.
293pub(crate) struct SharedReaderTransactionGuard {
294    conn: parking_lot::ArcMutexGuard<parking_lot::RawMutex, Connection>,
295    admission_slot: Option<tokio::sync::OwnedSemaphorePermit>,
296    pool: Arc<ConnectionPool>,
297    checked_out_at: Instant,
298    /// Set once a statement executed against this connection could not
299    /// prove its own cleanup ran (SQLite progress-handler removal failure).
300    /// `Drop` then treats the connection exactly like a rollback failure:
301    /// poison the pool rather than let possibly-corrupted connection state
302    /// return to service.
303    poison: Cell<bool>,
304}
305
306impl SharedReaderTransactionGuard {
307    pub(crate) fn conn(&self) -> &Connection {
308        &self.conn
309    }
310
311    /// Force poisoning on drop regardless of the connection's own
312    /// autocommit/pristine state.
313    pub(crate) fn poison(&self) {
314        self.poison.set(true);
315    }
316}
317
318impl Drop for SharedReaderTransactionGuard {
319    fn drop(&mut self) {
320        // An abandoned span must never let this connection return to
321        // service while still inside an open transaction — the next reader
322        // or writer to draw the pool's one physical connection would
323        // silently inherit it. Roll back first; only a verified return to
324        // autocommit, followed by the same pristine-state scan an ordinary
325        // dirty degraded checkout pays, allows reuse.
326        let mut restored = !self.poison.get();
327        if restored && !self.conn.is_autocommit() {
328            restored = self.conn.execute_batch("ROLLBACK").is_ok() && self.conn.is_autocommit();
329        }
330        if restored {
331            restored = restore_shared_reader_state(&self.conn, &self.pool.config);
332        }
333        if !restored {
334            self.pool.retire_pooled_writer(&self.conn);
335        }
336        drop(self.admission_slot.take());
337        self.pool
338            .reader_acquisition_counters
339            .record_checkout_completed(
340                self.checked_out_at.elapsed(),
341                Some("explicit_sql_read_transaction"),
342            );
343        self.pool.finish_reader_checkout(self.checked_out_at);
344    }
345}
346
347impl ConnectionPool {
348    /// Check out the degraded-mode (`max_readers == 0`) pool's single
349    /// physical connection as an owned [`SharedReaderTransactionGuard`]
350    /// instead of a borrowed [`ReaderGuard`]. Mirrors the admission and
351    /// mutex-acquisition loop in [`Self::reader_until`]'s degraded branch —
352    /// the only difference is the guard type returned, so a caller can
353    /// retain this one across several separate `.await` points.
354    ///
355    /// Exists only for the in-memory backend's explicit deferred-read-
356    /// transaction bypass (`sql_bridge::PoolBackedReader`); ordinary reads
357    /// use [`Self::reader_until`].
358    pub(crate) fn checkout_shared_reader_transaction(
359        self: &Arc<Self>,
360        should_stop: impl Fn() -> bool,
361    ) -> Result<Option<SharedReaderTransactionGuard>, SqliteError> {
362        debug_assert_eq!(
363            self.max_readers, 0,
364            "the owned shared-reader-transaction guard exists only for the degraded, \
365             single-connection backend"
366        );
367        self.ensure_pooled_writer_active()?;
368        let started = Instant::now();
369        let admission_slot = loop {
370            if should_stop() {
371                return Ok(None);
372            }
373            match Arc::clone(&self.sql_bridge_reader_slots).try_acquire_owned() {
374                Ok(slot) => break slot,
375                Err(tokio::sync::TryAcquireError::Closed) => {
376                    return Err(SqliteError::InvalidData(
377                        "reader admission semaphore is closed".to_string(),
378                    ));
379                }
380                Err(tokio::sync::TryAcquireError::NoPermits) => {}
381            }
382            if started.elapsed() >= self.config.checkout_timeout {
383                self.reader_acquisition_counters.record_checkout_timeout();
384                return Err(pool_exhausted_error(
385                    self.config.checkout_timeout,
386                    self.max_readers,
387                ));
388            }
389            thread::yield_now();
390        };
391
392        loop {
393            if should_stop() {
394                return Ok(None);
395            }
396            let remaining = self
397                .config
398                .checkout_timeout
399                .saturating_sub(started.elapsed());
400            if remaining.is_zero() {
401                self.reader_acquisition_counters.record_checkout_timeout();
402                return Err(pool_exhausted_error(
403                    self.config.checkout_timeout,
404                    self.max_readers,
405                ));
406            }
407            if let Some(conn) = self
408                .writer
409                .try_lock_arc_for(remaining.min(Duration::from_millis(2)))
410            {
411                self.ensure_pooled_writer_active()?;
412                self.reader_acquisition_counters.record_pooled_checkout();
413                return Ok(Some(SharedReaderTransactionGuard {
414                    conn,
415                    admission_slot: Some(admission_slot),
416                    pool: Arc::clone(self),
417                    checked_out_at: self.begin_reader_checkout(),
418                    poison: Cell::new(false),
419                }));
420            }
421        }
422    }
423}