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}