Skip to content

feat(engine): prepared-statement plan cache + parameter binding (SQLR-23)#108

Closed
joaoh82 wants to merge 1 commit into
mainfrom
feat/sqlr-23-prepare-cache-bind
Closed

feat(engine): prepared-statement plan cache + parameter binding (SQLR-23)#108
joaoh82 wants to merge 1 commit into
mainfrom
feat/sqlr-23-prepare-cache-bind

Conversation

@joaoh82
Copy link
Copy Markdown
Owner

@joaoh82 joaoh82 commented May 8, 2026

Summary

Closes SQLR-23 (highest-leverage perf task surfaced by the bench suite).

  • Connection::prepare_cached(sql) — per-connection LRU plan cache (default cap 16, matches rusqlite). set_prepared_cache_capacity(n) to tune.
  • Statement::query_with_params(&[Value]) / Statement::execute_with_params(&[Value]) — bind ? placeholders positionally. Strict arity check; clear typed errors on mismatch.
  • Statement now caches the parsed AST at prepare time. query() / run() / the bound versions all dispatch through it without re-running sqlparser.
  • Value::Vector(Vec<f32>) is a first-class bind type — a bound vector substitutes as the in-band bracket-array shape eval_expr_scope and try_hnsw_probe already recognize. HNSW shortcut still fires on prepared queries.

Approach: AST-rewrite. New module src/sql/params.rs (a) renumbers bare ??N at prepare time and (b) clones + substitutes the cached AST at execute time. Zero changes to the executor / parser narrowing — every existing arm sees concrete literals exactly as it does today on inline-params SQL. Enabled the visitor feature on sqlparser to use visit_expressions_mut.

Bench harness flip + version bump

The SQLRite driver was flipped from per-call inline_params SQL formatting to prepare_cached + the bound API. Every workload's WorkloadId.version bumped v1 → v2 in lockstep so the methodology change is captured explicitly — old v1 envelopes stay readable; the comparison script flags cross-version pairs.

Smoke results vs v1 baseline

Laptop smoke (read directionally only — pinned-host republish is SQLR-25):

Workload v1 (pinned) v2 (smoke) Reading
W1 read-by-PK 9.87 µs 4.69 µs ✅ -52% — parser tax confirmed real
W6 index lookup 10.45 µs 4.08 µs ✅ -61%
W3 bulk insert (100k/txn) 1.029 s 577 ms ✅ -44% — 100k INSERTs no longer per-row parsed
W11 BM25 top-10 1.079 ms 793 µs ✅ -26% — FTS query string no longer re-parsed
W7 SUM, W8 GROUP BY flat flat already executor-bound
W10 HNSW 127 ms 181 ms ⚠️ see SQLR-28 below

Pre-existing limitation surfaced (not introduced)

W10 HNSW didn't widen its gap vs brute-force — but not for a SQLR-23 reason. try_hnsw_probe is L2-only per its own docstring; W10's hot loop uses vec_distance_cosine, so the HNSW variant has been silently brute-forcing the entire time. SQLR-28 tracks widening the probe hook to cosine + dot, gating the proper W10 win.

Tests

  • 12 new tests in src/connection.rs: parameter count, scalar bind, vector bind via brute-force, HNSW + bound vector self-query (verifies optimizer still fires), arity mismatch, NULL three-valued logic, INSERT-loop binding, prepare_cached reuse + LRU eviction.
  • 7 unit tests in src/sql/params.rs covering placeholder rewriting, scalar / vector / null substitution, arity errors.

All 475 lib tests + 20 MCP + 8 FFI green.

Test plan

  • cargo fmt --all -- --check
  • cargo build --workspace --exclude sqlrite-{desktop,python,nodejs,benchmarks} --all-targets
  • cargo test --workspace --exclude sqlrite-{desktop,python,nodejs,benchmarks} — all green
  • cargo clippy --workspace … — no new warnings on touched code
  • cargo doc --workspace … --no-deps — clean
  • cargo build -p sqlrite-benchmarks --features duckdb
  • make bench smoke — all 12 workloads completed; v2 wins visible on parser-bound rows

Follow-ups (filed in marvinapp)

  • SQLR-25 (high) — republish v2 numbers on pinned host
  • SQLR-26 (low) — named placeholders + LIMIT ?
  • SQLR-27 (low) — vector-bind side channel (gated on W10 evidence)
  • SQLR-28 (high) — widen HNSW probe hook to cosine + dot (unblock W10)

🤖 Generated with Claude Code

…-23)

`Connection::prepare_cached` (default 16-entry per-connection LRU) +
`Statement::query_with_params` / `Statement::execute_with_params`
substitute `?` placeholders into a cached AST at execute time, skipping
the per-call sqlparser walk. `Value::Vector(Vec<f32>)` is a first-class
bind type — a bound vector substitutes into the same in-band bracket-
array shape an inline `[…]` literal would, so the HNSW probe optimizer
still recognizes it on prepared queries.

Bench harness flipped from per-call SQL formatting (`inline_params`) to
the bound + cached path; every workload's `WorkloadId.version` bumped
`v1 → v2` in lockstep so the methodology change is captured explicitly.
Old v1 envelopes stay readable; the comparison script flags cross-
version pairs.

Smoke run confirms parser-bound wins on the workloads the plan
predicted: W1 -52%, W6 -61%, W3 -44%, W11 -26%. Republishing the
official pinned-host envelope is SQLR-25.

Surfaced one pre-existing limitation: `try_hnsw_probe` is L2-only, so
W10's `vec_distance_cosine` query has been silently brute-forcing the
HNSW variant the entire time. SQLR-28 tracks widening the probe to
cosine + dot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@joaoh82 joaoh82 closed this May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant