Skip to main content

khive_db/pool/
acquisition.rs

1//! Reader and writer acquisition with bounded admission.
2
3use std::cell::Cell;
4use std::sync::atomic::Ordering;
5use std::sync::Arc;
6use std::thread;
7use std::time::{Duration, Instant};
8
9use khive_storage::error::StorageError;
10use khive_storage::StorageCapability;
11use rusqlite::Connection;
12
13use crate::error::SqliteError;
14
15use super::{
16    deny_retired_writer, pool_exhausted_error, CheckpointGuard, ConnectionPool, ReaderAdmission,
17    ReaderGuard, ReaderLease, WriterGuard,
18};
19
20impl ConnectionPool {
21    /// Check out a reader connection.
22    ///
23    /// Tries to pop from the lock-free queue. If empty, spins briefly then
24    /// waits with exponential backoff up to `checkout_timeout`.
25    ///
26    /// In degraded mode (WAL unavailable, `max_readers == 0`), this method
27    /// checks the shared writer mutex in bounded slices and returns pool
28    /// exhaustion after `checkout_timeout`; it never blocks indefinitely on
29    /// the non-reentrant mutex.
30    pub fn reader(&self) -> Result<ReaderGuard<'_>, SqliteError> {
31        self.reader_until(|| false)?.ok_or_else(|| {
32            SqliteError::InvalidData("uncancelled reader checkout stopped unexpectedly".into())
33        })
34    }
35
36    /// Check out a reader while cooperatively polling a request cancellation
37    /// predicate. The predicate is evaluated before connection acquisition and
38    /// between backoff slices, so an abandoned request does not sit through the
39    /// full pool checkout timeout or execute a statement when a reader later
40    /// becomes available.
41    pub(crate) fn reader_until<C>(
42        &self,
43        should_stop: C,
44    ) -> Result<Option<ReaderGuard<'_>>, SqliteError>
45    where
46        C: Fn() -> bool,
47    {
48        let started = Instant::now();
49        let mut admission_attempt = 0u32;
50        let admission_slot = loop {
51            if should_stop() {
52                return Ok(None);
53            }
54            match Arc::clone(&self.sql_bridge_reader_slots).try_acquire_owned() {
55                Ok(slot) => break slot,
56                Err(tokio::sync::TryAcquireError::Closed) => {
57                    return Err(SqliteError::InvalidData(
58                        "reader admission semaphore is closed".to_string(),
59                    ));
60                }
61                Err(tokio::sync::TryAcquireError::NoPermits) => {}
62            }
63            if started.elapsed() >= self.config.checkout_timeout {
64                self.reader_acquisition_counters.record_checkout_timeout();
65                return Err(pool_exhausted_error(
66                    self.config.checkout_timeout,
67                    self.max_readers,
68                ));
69            }
70            match admission_attempt {
71                0..=7 => {
72                    let spins = 1usize << admission_attempt;
73                    for _ in 0..spins {
74                        std::hint::spin_loop();
75                    }
76                }
77                8..=15 => thread::yield_now(),
78                _ => {
79                    let remaining = self
80                        .config
81                        .checkout_timeout
82                        .saturating_sub(started.elapsed());
83                    let sleep =
84                        Duration::from_micros(50 * (1u64 << (admission_attempt - 16).min(6)));
85                    thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
86                }
87            }
88            admission_attempt = admission_attempt.saturating_add(1);
89        };
90
91        self.reader_with_admission(
92            ReaderAdmission {
93                slot: admission_slot,
94                started,
95            },
96            should_stop,
97        )
98    }
99
100    /// Wait for one pool-wide reader admission permit on the async side, so a
101    /// read that has to queue costs a task and not a blocking-pool thread.
102    ///
103    /// The wait is bounded by `checkout_timeout` and by the current request's
104    /// cancellation and deadline, and it yields the same `Ok(Some)` / `Ok(None)`
105    /// / `Err` outcomes the permit loop in [`Self::reader_until`] does, resolved
106    /// through the same refusal mapping as [`Self::resolve_reader_checkout`].
107    /// Dropping the future abandons the wait without taking a permit.
108    pub(crate) async fn acquire_reader_admission(
109        &self,
110        capability: StorageCapability,
111        operation: &'static str,
112    ) -> Result<ReaderAdmission, StorageError> {
113        let context = khive_storage::capture_request_read_context();
114        let started = Instant::now();
115        let stopped = context.stop_reason().is_some();
116        let outcome: Result<Option<ReaderAdmission>, SqliteError> = if stopped {
117            Ok(None)
118        } else {
119            tokio::select! {
120                biased;
121                _ = context.wait_for_stop() => Ok(None),
122                waited = tokio::time::timeout(
123                    self.config.checkout_timeout,
124                    Arc::clone(&self.sql_bridge_reader_slots).acquire_owned(),
125                ) => match waited {
126                    Ok(Ok(slot)) => Ok(Some(ReaderAdmission { slot, started })),
127                    Ok(Err(_closed)) => Err(SqliteError::InvalidData(
128                        "reader admission semaphore is closed".to_string(),
129                    )),
130                    Err(_elapsed) => {
131                        self.reader_acquisition_counters.record_checkout_timeout();
132                        Err(pool_exhausted_error(
133                            self.config.checkout_timeout,
134                            self.max_readers,
135                        ))
136                    }
137                },
138            }
139        };
140        match outcome {
141            Ok(Some(admission)) => Ok(admission),
142            Ok(None) => Err(self.reader_checkout_refusal(capability, operation, None)),
143            Err(error) => Err(self.reader_checkout_refusal(capability, operation, Some(error))),
144        }
145    }
146
147    /// Select the reader connection for an admission permit already held.
148    ///
149    /// This is [`Self::reader_until`] after its permit loop: the same
150    /// `should_stop` polling, the same `Ok(Some)` / `Ok(None)` / `Err`
151    /// outcomes, and the remainder of the one `checkout_timeout` that began
152    /// when the permit wait did.
153    pub(crate) fn reader_with_admission<C>(
154        &self,
155        admission: ReaderAdmission,
156        should_stop: C,
157    ) -> Result<Option<ReaderGuard<'_>>, SqliteError>
158    where
159        C: Fn() -> bool,
160    {
161        let ReaderAdmission {
162            slot: admission_slot,
163            started,
164        } = admission;
165
166        if self.max_readers == 0 {
167            self.ensure_pooled_writer_active()?;
168            loop {
169                if should_stop() {
170                    return Ok(None);
171                }
172                let remaining = self
173                    .config
174                    .checkout_timeout
175                    .saturating_sub(started.elapsed());
176                if remaining.is_zero() {
177                    self.reader_acquisition_counters.record_checkout_timeout();
178                    return Err(pool_exhausted_error(
179                        self.config.checkout_timeout,
180                        self.max_readers,
181                    ));
182                }
183                if let Some(guard) = self
184                    .writer
185                    .try_lock_for(remaining.min(Duration::from_millis(2)))
186                {
187                    self.ensure_pooled_writer_active()?;
188                    self.reader_acquisition_counters.record_pooled_checkout();
189                    return Ok(Some(ReaderGuard {
190                        lease: Some(ReaderLease::Shared(guard)),
191                        admission_slot: Some(admission_slot),
192                        pool: self,
193                        reusable: Cell::new(true),
194                        query_in_progress: Cell::new(false),
195                        checked_out_at: self.begin_reader_checkout(),
196                        dirty: Cell::new(false),
197                        operation: None,
198                    }));
199                }
200            }
201        }
202
203        let mut attempt = 0u32;
204
205        loop {
206            if should_stop() {
207                return Ok(None);
208            }
209            if let Some(mut conn) = self.readers.pop() {
210                conn.checkouts = conn.checkouts.saturating_add(1);
211                self.reader_acquisition_counters.record_pooled_checkout();
212                return Ok(Some(ReaderGuard {
213                    lease: Some(ReaderLease::Pooled(conn)),
214                    admission_slot: Some(admission_slot),
215                    pool: self,
216                    reusable: Cell::new(true),
217                    query_in_progress: Cell::new(false),
218                    checked_out_at: self.begin_reader_checkout(),
219                    dirty: Cell::new(false),
220                    operation: None,
221                }));
222            }
223
224            if started.elapsed() >= self.config.checkout_timeout {
225                self.reader_acquisition_counters.record_checkout_timeout();
226                return Err(pool_exhausted_error(
227                    self.config.checkout_timeout,
228                    self.max_readers,
229                ));
230            }
231
232            match attempt {
233                0..=7 => {
234                    let spins = 1usize << attempt;
235                    for _ in 0..spins {
236                        std::hint::spin_loop();
237                    }
238                }
239                8..=15 => thread::yield_now(),
240                _ => {
241                    let remaining = self
242                        .config
243                        .checkout_timeout
244                        .saturating_sub(started.elapsed());
245                    let sleep = Duration::from_micros(50 * (1u64 << (attempt - 16).min(6)));
246                    thread::sleep(sleep.min(remaining).min(Duration::from_millis(2)));
247                }
248            }
249
250            attempt = attempt.saturating_add(1);
251        }
252    }
253
254    /// Check out the writer connection.
255    ///
256    /// Waits up to `checkout_timeout` for the writer Mutex and returns
257    /// `Err(SqliteError::WriterPoolCheckoutTimeout)` if the timeout is
258    /// exceeded.
259    #[track_caller]
260    pub fn writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
261        self.writer_with_checkout_probe(true, false)
262    }
263
264    /// Callers that own an explicit §4 logical-write boundary probe there,
265    /// so checkout must not refuse ahead of their BEGIN/bootstrap statement.
266    #[track_caller]
267    pub(crate) fn writer_for_admitted_operation(&self) -> Result<WriterGuard<'_>, SqliteError> {
268        self.writer_with_checkout_probe(false, false)
269    }
270
271    /// Checkpoint ownership setup changes only SQLite's checkpoint policy.
272    /// Its bounded checkout must remain available below the write reserve.
273    pub(super) fn writer_for_checkpoint_operation(&self) -> Result<WriterGuard<'_>, SqliteError> {
274        self.writer_with_checkout_probe(false, true)
275    }
276
277    #[track_caller]
278    fn writer_with_checkout_probe(
279        &self,
280        compatibility_probe: bool,
281        checkpoint_bypass: bool,
282    ) -> Result<WriterGuard<'_>, SqliteError> {
283        if !checkpoint_bypass {
284            self.write_admission.ensure_settled()?;
285        }
286        self.ensure_pooled_writer_active()?;
287        let volume_lease = if checkpoint_bypass {
288            None
289        } else {
290            self.write_admission.acquire()?
291        };
292        let Some(guard) = self.writer.try_lock_for(self.config.checkout_timeout) else {
293            self.writer_acquisition_counters
294                .pooled_timeouts
295                .fetch_add(1, Ordering::Relaxed);
296            let message = format!(
297                "timed out after {:?} waiting for sqlite writer connection",
298                self.config.checkout_timeout
299            );
300            crate::timeout_sink::emit_timeout(
301                &crate::timeout_sink::db_label(self),
302                crate::timeout_sink::Site::PoolAdmission,
303                &message,
304                Some(
305                    self.config
306                        .checkout_timeout
307                        .as_millis()
308                        .min(u128::from(u64::MAX)) as u64,
309                ),
310            );
311            return Err(SqliteError::WriterPoolCheckoutTimeout {
312                timeout: self.config.checkout_timeout,
313            });
314        };
315        self.ensure_pooled_writer_active()?;
316        #[cfg(any(unix, windows))]
317        if let Some(path) = self.identity_path.as_deref() {
318            self.verify_opened_file_identity(path)?;
319            self.verify_connection_file_identity(&guard, path)?;
320        }
321        // Public raw-Connection/Deref compatibility callers may issue an
322        // autocommit write without another seam. Keep the old coarse sample
323        // until those sites migrate; explicit admitted operations skip it.
324        if compatibility_probe && !checkpoint_bypass {
325            self.write_admission.check()?;
326        }
327        self.writer_acquisition_counters
328            .pooled_acquisitions
329            .fetch_add(1, Ordering::Relaxed);
330        Ok(WriterGuard {
331            guard,
332            origin: self.origin(),
333            pool: self,
334            admission: self.write_admission.as_ref(),
335            _volume_lease: volume_lease,
336        })
337    }
338
339    /// Non-panicking writer checkout.
340    ///
341    /// Returns `Err` on timeout instead of panicking. Use this in request
342    /// handlers where a 500 is preferable to crashing the process.
343    #[track_caller]
344    pub fn try_writer(&self) -> Result<WriterGuard<'_>, SqliteError> {
345        self.writer()
346    }
347
348    // Retain the compatibility-probe path only for its focused regression
349    // fixtures; production callers use the execution-boundary admission seam.
350    #[cfg(test)]
351    #[track_caller]
352    pub(super) fn writer_until<C>(
353        &self,
354        should_stop: C,
355    ) -> Result<Option<WriterGuard<'_>>, SqliteError>
356    where
357        C: Fn() -> bool,
358    {
359        self.writer_until_with_checkout_probe(should_stop, true)
360    }
361
362    #[track_caller]
363    pub(crate) fn writer_until_for_admitted_operation<C>(
364        &self,
365        should_stop: C,
366    ) -> Result<Option<WriterGuard<'_>>, SqliteError>
367    where
368        C: Fn() -> bool,
369    {
370        self.writer_until_with_checkout_probe(should_stop, false)
371    }
372
373    #[track_caller]
374    fn writer_until_with_checkout_probe<C>(
375        &self,
376        should_stop: C,
377        compatibility_probe: bool,
378    ) -> Result<Option<WriterGuard<'_>>, SqliteError>
379    where
380        C: Fn() -> bool,
381    {
382        self.ensure_pooled_writer_active()?;
383        if should_stop() {
384            return Ok(None);
385        }
386        let volume_lease = self.write_admission.acquire()?;
387        let started = Instant::now();
388        loop {
389            if should_stop() {
390                return Ok(None);
391            }
392            let remaining = self
393                .config
394                .checkout_timeout
395                .saturating_sub(started.elapsed());
396            if let Some(guard) = self
397                .writer
398                .try_lock_for(remaining.min(Duration::from_millis(2)))
399            {
400                // Cancellation may have arrived during the final wait slice.
401                // Once this guard is returned, constructor DDL is not interrupted.
402                if should_stop() {
403                    return Ok(None);
404                }
405                self.ensure_pooled_writer_active()?;
406                #[cfg(any(unix, windows))]
407                if let Some(path) = self.identity_path.as_deref() {
408                    self.verify_opened_file_identity(path)?;
409                    self.verify_connection_file_identity(&guard, path)?;
410                }
411                if compatibility_probe {
412                    self.write_admission.check()?;
413                }
414                self.writer_acquisition_counters
415                    .pooled_acquisitions
416                    .fetch_add(1, Ordering::Relaxed);
417                return Ok(Some(WriterGuard {
418                    guard,
419                    origin: self.origin(),
420                    pool: self,
421                    admission: self.write_admission.as_ref(),
422                    _volume_lease: volume_lease,
423                }));
424            }
425            if started.elapsed() >= self.config.checkout_timeout {
426                self.writer_acquisition_counters
427                    .pooled_timeouts
428                    .fetch_add(1, Ordering::Relaxed);
429                let message = format!(
430                    "timed out after {:?} waiting for sqlite writer connection",
431                    self.config.checkout_timeout
432                );
433                crate::timeout_sink::emit_timeout(
434                    &crate::timeout_sink::db_label(self),
435                    crate::timeout_sink::Site::PoolAdmission,
436                    &message,
437                    Some(
438                        self.config
439                            .checkout_timeout
440                            .as_millis()
441                            .min(u128::from(u64::MAX)) as u64,
442                    ),
443                );
444                return Err(SqliteError::WriterPoolCheckoutTimeout {
445                    timeout: self.config.checkout_timeout,
446                });
447            }
448        }
449    }
450
451    /// Zero-wait checkpoint checkout for recovery maintenance.
452    ///
453    /// Uses `try_lock()` (no timeout, no spin) — returns `Err` immediately when
454    /// any other caller holds the writer Mutex. The scheduled checkpoint task
455    /// uses its own dedicated connection (ADR-091 Amendment 5); this optional
456    /// pooled capability remains available for zero-wait recovery callers.
457    ///
458    /// It bypasses the disk-reserve floor because checkpoints can recover WAL
459    /// space at or below that floor (ADR-154 §5). The returned guard exposes
460    /// only fixed PASSIVE and TRUNCATE checkpoint operations.
461    ///
462    /// The former unrestricted checkout must not return:
463    /// ```compile_fail
464    /// use khive_db::{ConnectionPool, PoolConfig};
465    /// let pool = ConnectionPool::new(PoolConfig::default()).unwrap();
466    /// pool.try_writer_nowait().unwrap().execute_batch("CREATE TABLE bypass (id INTEGER)");
467    /// ```
468    pub fn try_checkpoint_nowait(&self) -> Result<CheckpointGuard<'_>, SqliteError> {
469        self.ensure_pooled_writer_active()?;
470        let guard = self.writer.try_lock().ok_or_else(|| {
471            SqliteError::InvalidData(
472                "writer connection busy (checkpoint skipped this tick)".to_string(),
473            )
474        })?;
475        self.ensure_pooled_writer_active()?;
476        Ok(CheckpointGuard { guard })
477    }
478
479    pub(crate) fn retire_pooled_writer(&self, conn: &Connection) {
480        self.pooled_writer_retired.store(true, Ordering::Release);
481        if let Err(error) = conn.authorizer(Some(deny_retired_writer)) {
482            tracing::error!(target: "khive_db::pool",
483                %error,
484                "failed to install the retired pooled-writer quarantine authorizer"
485            );
486        }
487    }
488
489    /// Exercise the retired connection's authorizer without exporting a raw
490    /// handle from the pool. This is intentionally unavailable in production.
491    #[cfg(test)]
492    pub(crate) fn probe_retired_pooled_writer_for_test(&self) -> rusqlite::Result<i64> {
493        self.writer
494            .lock()
495            .query_row("SELECT 1", [], |row| row.get(0))
496    }
497
498    /// Leave an inherited transaction on the bare pooled connection to test
499    /// the typed write-unit refusal. A normal WriterGuard rolls it back on
500    /// drop, so it cannot create this poisoned state.
501    #[cfg(test)]
502    pub(crate) fn leave_pooled_writer_transaction_open_for_test(&self) -> rusqlite::Result<()> {
503        self.writer.lock().execute_batch("BEGIN IMMEDIATE")
504    }
505
506    pub(super) fn ensure_pooled_writer_active(&self) -> Result<(), SqliteError> {
507        if self.pooled_writer_retired.load(Ordering::Acquire) {
508            return Err(SqliteError::InvalidData(
509                "pooled writer connection retired after a terminal transaction fault".to_string(),
510            ));
511        }
512        Ok(())
513    }
514}