Rindle docs and package mapSkip to main content

rindle_sqlite/
stmt_cache.rs

1//! The prepared-statement cache (spec `05` §4.3) — **a thin wrapper over
2//! rusqlite's built-in per-connection statement cache**, not a from-scratch port
3//! of `internal/statement-cache.ts`.
4//!
5//! ## Why wrap instead of port
6//!
7//! The JS `StatementCache` exists to solve one problem: a SQLite prepared
8//! statement **is not reentrant** — a single statement cannot be iterated by two
9//! callers at once (`statement-cache.ts:16-20`). The JS solves it by *removing* a
10//! statement from the cache on `get` and pushing it back on `return`; a
11//! concurrent `get` for the same SQL (the self-join / reentrant-fetch case, `05`
12//! §3.10) prepares a *fresh* statement so both callers iterate independently.
13//!
14//! rusqlite ships **exactly this discipline already** (`rusqlite::cache`):
15//! - [`Connection::prepare_cached`] does `cache.remove(sql)` — the statement is
16//!   *out of the cache while in use*, so a concurrent same-SQL `get` cannot alias
17//!   it and prepares a fresh one. This is the JS "remove on get" semantics.
18//! - The returned [`rusqlite::CachedStatement`] returns itself to the cache on
19//!   `Drop` — RAII return, **strictly better** than the JS manual
20//!   `finally → cache.return()` discipline (`table-source.ts:340-374`): an early
21//!   `break`, a `?`, or early return all return the statement for free (foundations
22//!   §7, README Primitive #2). This cache is `sqlite`-only (native server): a panic
23//!   returns the statement only in unwinding builds; the `release-server` profile
24//!   (`panic = "unwind"`) covers panic, but a plain `release` build (`panic = "abort"`)
25//!   aborts the process and no destructor runs. See WS02.
26//!
27//! Critically, the built-in cache **dissolves the spec's #1 implementation risk**
28//! (`05` §13 Q1, the `rusqlite::Statement<'static>` self-referential-struct
29//! problem): it stores a lifetime-free `RawStatement` (a raw FFI pointer) and
30//! re-attaches the connection borrow only for the checkout window. That raw
31//! mechanism is `pub(crate)` in rusqlite, so a faithful multi-slot port could
32//! *not* reuse it — it would have to reach for an `unsafe` `'static` transmute or
33//! a self-referencing dependency (`ouroboros`), i.e. take on the exact risk the
34//! spec flagged. Standing on `prepare_cached` is the lower-risk path.
35//!
36//! ## Deviations from the JS cache (noted, per `05` §13 Q7)
37//!
38//! The spec explicitly says *do not silently change the eviction policy and then
39//! assume the same eviction order in tests*. The deviations here are deliberate:
40//!
41//! 1. **Single statement per SQL key, LRU-evicted** — not the JS
42//!    `Map<sql, Statement[]>` (multiple cached copies per SQL for concurrent
43//!    reuse). Under a reentrant self-join where the outer and inner fetch emit
44//!    *identical* SQL (constraint values are bound params, `05` §4.4, so the text
45//!    is identical), the single slot re-prepares one extra statement per reentrant
46//!    cycle and finalizes the surplus on return. A real but modest hot-path cost,
47//!    measurable at the §11 benchmarking milestone; if it bites, the upgrade to a
48//!    multi-slot front is local to this module. We do **not** pre-optimize into
49//!    the §13 Q1 unsafe territory before a benchmark justifies it.
50//! 2. **LRU eviction (bounded), not manual `drop(n)`** — the JS cache is unbounded
51//!    with an externally-driven `drop(n)` (`statement-cache.ts:53-69`). rusqlite's
52//!    cache is a bounded LRU; [`StatementCache::set_capacity`] tunes the bound.
53//!    This resolves §13 Q7 (the memory-footprint risk) without a pipeline-driver
54//!    hook. Eviction *order* differs from the JS — any differential test that
55//!    asserted the JS FIFO-ish trim order must be rewritten against LRU.
56//! 3. **No internal whitespace normalization on the hot path** — the JS
57//!    `normalizeWhitespace` (`statement-cache.ts:129`) collapses internal
58//!    whitespace runs so hand-written SQL hits the same key. *All* fetch SQL here
59//!    is emitted by `build_select_query` (`05` §4.4) in canonical single-spaced
60//!    form, so normalization would be a wasted per-`get` allocation on the hot
61//!    path. The cache assumes canonical SQL; rusqlite still trims leading/trailing
62//!    whitespace for the key. (If a future caller feeds non-canonical SQL,
63//!    normalize once when *building* it, not on every cache hit.)
64
65use std::cell::Cell;
66use std::ops::{Deref, DerefMut};
67use std::rc::Rc;
68
69use rusqlite::{CachedStatement, Connection, Statement};
70
71/// rusqlite's default per-connection statement-cache capacity (LRU slots). We
72/// adopt it unless [`StatementCache::with_capacity`] overrides — a server with
73/// many distinct fetch shapes may want a larger bound (`05` §13 Q7).
74pub const DEFAULT_CAPACITY: usize = 16;
75
76/// The prepared-statement cache for a SQLite source (`05` §4.3). Owns the
77/// connection (`Rc<Connection>`, mirroring the spec's `Rc<Db>` so the cache can
78/// live alongside the per-snapshot write statements and be rebound on a
79/// Snapshotter leapfrog, `05` §5.7) and lends out [`PooledStmt`] RAII guards.
80///
81/// The *actual* statement storage is rusqlite's per-connection cache, reached via
82/// [`Connection::prepare_cached`]; this type adds (1) the `PooledStmt` checkout
83/// handle — the single place to hang the `scanStatus` capture-on-drop hook (`05`
84/// §13 Q8, deferred) — and (2) open-cursor accounting (README Primitive #2), so a
85/// test can prove the statement was released even on an early `break`.
86pub struct StatementCache {
87    conn: Rc<Connection>,
88    /// Cursors currently checked out. Incremented by [`Self::get`], decremented by
89    /// [`PooledStmt`]'s `Drop`. The Rust analogue of the JS `LoggingIterableIterator`
90    /// open-iterator tracking (`db.ts:304-308`); makes RAII release *observable*.
91    open: Cell<i64>,
92}
93
94impl StatementCache {
95    /// Wrap `conn` with the default LRU capacity ([`DEFAULT_CAPACITY`]).
96    pub fn new(conn: Rc<Connection>) -> StatementCache {
97        StatementCache {
98            conn,
99            open: Cell::new(0),
100        }
101    }
102
103    /// Wrap `conn` and set the LRU capacity (number of distinct cached SQL
104    /// statements retained). `0` disables caching (every `get` prepares fresh).
105    pub fn with_capacity(conn: Rc<Connection>, capacity: usize) -> StatementCache {
106        conn.set_prepared_statement_cache_capacity(capacity);
107        StatementCache {
108            conn,
109            open: Cell::new(0),
110        }
111    }
112
113    /// Re-bound the LRU capacity. Resolves `05` §13 Q7 (the server-side
114    /// memory-footprint bound) without the JS external `drop(n)`.
115    pub fn set_capacity(&self, capacity: usize) {
116        self.conn.set_prepared_statement_cache_capacity(capacity);
117    }
118
119    /// Finalize every cached statement (drops the LRU contents). The bounded
120    /// analogue of the JS `drop(size)`; e.g. on schema change before a re-prepare.
121    pub fn flush(&self) {
122        self.conn.flush_prepared_statement_cache();
123    }
124
125    /// The wrapped connection — needed by the source to prepare the *write*
126    /// statements (insert/delete/update/checkExists/getExisting, `05` §4.3) that
127    /// are held for a snapshot's lifetime rather than cache-checked-out per fetch.
128    pub fn conn(&self) -> &Rc<Connection> {
129        &self.conn
130    }
131
132    /// Cursors currently checked out (Primitive #2 accounting). `0` when every
133    /// `PooledStmt` has been dropped — i.e. no statement is mid-iteration and the
134    /// connection is free for a write.
135    pub fn open_cursors(&self) -> i64 {
136        self.open.get()
137    }
138
139    /// Check out a prepared statement for `sql`, **removed from the cache while in
140    /// use** (a SQLite statement is not reentrant — `05` §3.10). Returns a
141    /// [`PooledStmt`] RAII guard that returns the statement to the cache on `Drop`.
142    ///
143    /// A concurrent `get` for the same `sql` while this one is checked out finds
144    /// the slot empty and prepares a *fresh* statement (rusqlite `cache.rs:147`),
145    /// so the two iterate independently — the self-join / reentrant-fetch case.
146    ///
147    /// Fails only if `sql` is not valid SQL on this connection — a builder bug in
148    /// practice, since fetch SQL is emitted by `build_select_query` (`05` §4.4).
149    /// Surfaced as a `Result` rather than hidden (foundations §10).
150    pub fn get(&self, sql: &str) -> rusqlite::Result<PooledStmt<'_>> {
151        let stmt = self.conn.prepare_cached(sql)?;
152        self.open.set(self.open.get() + 1);
153        Ok(PooledStmt {
154            stmt: Some(stmt),
155            open: &self.open,
156        })
157    }
158
159    /// Check out a statement, run `f` against it, and return it to the cache —
160    /// the port of the JS `StatementCache.use` (`statement-cache.ts:103-110`) and
161    /// the `StatementRunner` one-liners (`zero-cache/db/statements.ts`). The
162    /// statement is released when `f` returns (or unwinds), automatically.
163    pub fn with<T>(
164        &self,
165        sql: &str,
166        f: impl FnOnce(&mut PooledStmt<'_>) -> T,
167    ) -> rusqlite::Result<T> {
168        let mut stmt = self.get(sql)?;
169        Ok(f(&mut stmt))
170    }
171}
172
173/// RAII handle owning a checked-out prepared statement (`05` §4.3, the
174/// `PooledStmt`). On `Drop` it returns the statement to the per-connection cache
175/// — which also **resets the cursor**, so the next write does not hit "database
176/// is busy" — and decrements the open-cursor count. This replaces *both* the JS
177/// `get`/`finally → cache.return()` and the `rowIterator.return?.()` cursor-reset
178/// (`table-source.ts:340-374`); early `break`, `?`, and early return all trigger
179/// it. A panic triggers it only in unwinding builds (this is `sqlite`-only server
180/// code): the `release-server` profile (`panic = "unwind"`) covers panic, but a plain
181/// `release` build (`panic = "abort"`) aborts and the `Drop` does not run. See WS02.
182///
183/// Deref/DerefMut expose the inner [`rusqlite::Statement`], so a caller does
184/// `pooled.query(params)` exactly as on a bare statement (mirrors rusqlite's own
185/// `CachedStatement`).
186pub struct PooledStmt<'c> {
187    /// `Option` so [`Self::discard`] can take the statement out *without*
188    /// returning it to the cache. `None` after discard; `Drop` then returns
189    /// nothing (the statement is finalized) but still decrements `open`.
190    stmt: Option<CachedStatement<'c>>,
191    open: &'c Cell<i64>,
192}
193
194impl<'c> PooledStmt<'c> {
195    /// Drop the statement **without** returning it to the cache (rusqlite
196    /// `CachedStatement::discard`). Use when a statement should not be reused —
197    /// e.g. it errored, or the schema changed under it. The JS analogue is simply
198    /// *not* calling `return` ("It is not an error to fail to call return",
199    /// `statement-cache.ts:30-32`). The open-cursor count is still decremented
200    /// (by `Drop`), so accounting stays balanced.
201    pub fn discard(mut self) {
202        if let Some(stmt) = self.stmt.take() {
203            stmt.discard();
204        }
205        // `self` drops here → `Drop` decrements `open`; `stmt` is already `None`.
206    }
207}
208
209impl<'c> Deref for PooledStmt<'c> {
210    type Target = Statement<'c>;
211
212    fn deref(&self) -> &Statement<'c> {
213        // Deref-coerces &CachedStatement → &Statement. `expect` cannot fire on the
214        // data path: `stmt` is `None` only after `discard`, which consumes `self`.
215        self.stmt.as_ref().expect("PooledStmt used after discard")
216    }
217}
218
219impl<'c> DerefMut for PooledStmt<'c> {
220    fn deref_mut(&mut self) -> &mut Statement<'c> {
221        self.stmt.as_mut().expect("PooledStmt used after discard")
222    }
223}
224
225impl Drop for PooledStmt<'_> {
226    fn drop(&mut self) {
227        // scanStatus capture hook point (`05` §7 / §13 Q8): when a `DebugDelegate`
228        // is active, read `sqlite3_stmt_scanstatus_v2` off the statement here,
229        // before it returns to the cache. Observability-only, so deferred — the
230        // data path does no work.
231        //
232        // Assigning `None` drops the inner `CachedStatement` *now*, which returns
233        // it to the per-connection cache (and resets the cursor). On the `discard`
234        // path `stmt` is already `None` — nothing to return.
235        //
236        // REENTRANCY SAFETY: this return goes through rusqlite's
237        // `StatementCache::cache_stmt`, which takes a `RefCell::borrow_mut` — but
238        // only *transiently* (around a single `insert`), never held across a vend.
239        // Likewise checkout (`get` → `prepare_cached`) borrows the RefCell only to
240        // `remove`, then releases. So a `PooledStmt` dropping while ANOTHER is
241        // mid-iteration (the self-join / nested-checkout case, `05` §3.10) never
242        // double-borrows: the live statement was already *removed* from the cache,
243        // so it isn't borrowing the RefCell at all. This holds only as long as the
244        // foundations §6.2 rule is upheld — never hold a cache borrow across a row
245        // vend. A future refactor that did so would turn this Drop into an
246        // `already borrowed` panic; keep checkout/return windows transient.
247        self.stmt = None;
248        self.open.set(self.open.get() - 1);
249    }
250}