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}