Skip to content

feat(query): prepared-statement plan cache, v1 (validation-skip only) - #216

Merged
knoguchi merged 2 commits into
mainfrom
feat/prepared-plan-cache
Aug 29, 2026
Merged

feat(query): prepared-statement plan cache, v1 (validation-skip only)#216
knoguchi merged 2 commits into
mainfrom
feat/prepared-plan-cache

Conversation

@knoguchi

Copy link
Copy Markdown
Owner

Why

MarsDB's existing "prepared statement" API only meant the caller had
pre-parsed the Cypher text once — every execution still ran full
semantic validation from scratch. Found while building an apples-to-
apples benchmark against LatticeDB and SQLite earlier this session:
SQLite's sqlite3_prepare_v2 genuinely compiles once and reuses that
across calls, MarsDB didn't have an equivalent.

Worth saying plainly: the comparison that motivated this turned out to
be apples-to-oranges. LatticeDB bypasses a query language entirely for
its benchmark path, so it was never going to be a fair fight, and a
follow-up phase-breakdown diagnostic (on the same 10K-node graph)
measured clone+substitute+validate+plan at only ~17% of a 1-hop
query's total per-call time — the other ~83% is row evaluation, which
this doesn't touch. This PR is not a fix for that benchmark gap. It's
still a real, correct feature worth having on its own: a genuine
prepared-statement handle that pre-parses once and safely skips
re-validation on repeat calls.

What

Database::prepare(cypher) -> PreparedPlan parses once. Pass the
handle to Database::execute_prepared_plan(&prepared, &params, &options)
(and the matching Transaction twin) to run it any number of times
with different params. Each call substitutes params into a fresh
clone of the stored AST as before; the new part is
PreparedPlan::can_skip_validation, which tracks a coarse per-param
null/scalar/list/map fingerprint plus the last-seen
GraphStore::schema_generation() (new: an AtomicU64 bumped whenever
an index is declared — indexes are never dropped, so a stale
generation can only mean "might be missing an index," never wrong
results). When a call's fingerprint and generation match a previously-
validated call, semantic::validate_statement is skipped via a new
execute_*_with_options_trusted entry point on Executor; every other
path still validates. Query planning reruns every call — this v1 is
validation-skip only, not a plan cache in the fuller sense the name
might suggest.

Also drops the temporary phase-breakdown diagnostic test that was on
this branch during development (same treatment as the diagnostics used
while investigating #214 and #215 — profiling aids, not permanent
regression tests) and adds a plain README example for the new API next
to the existing execute/execute_with_options/begin_transaction
ones.

Explicitly not in this PR

  • Plan-embedded or AST-embedded parameter-value patching (the bigger
    "skip planning too" design this started from) — v1 only skips
    validation, planning still reruns every call.
  • Any marsdb-capi migration to PreparedPlan — capi's existing
    prepare/bind/execute path is untouched.
  • Per-scan (vs. per-clause) index-sniffing-risk granularity — not
    applicable since v1 doesn't cache plans at all yet.

Testing

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo clippy -p marsdb -p marsdb-capi --all-targets --features arrow -- -D warnings, cargo test --workspace, cargo test -p marsdb -p marsdb-capi --features arrow, cargo check --manifest-path marsdb-python/Cargo.toml all pass.
  • cargo run --release -p marsdb-tck: 3880/3880, unchanged.
  • Existing marsdb-capi prepare_bind_execute_reuse test and the new
    marsdb/tests/prepared_plan.rs (category-change correctness,
    schema-generation invalidation, MERGE exclusion) all pass.

Adds a real prepared-statement handle, Database::prepare/execute_prepared_plan
(and the Transaction twin), that skips semantic validation on repeat
calls when it's provably safe to: a parameter's coarse category
(null/scalar/list/map) determines validate_statement's outcome (Int vs
String never does, both type Kind::Scalar), so a call whose every
parameter matches a previously-validated fingerprint, at the same
schema generation, can skip re-validating.

Query planning (build_match_plan/apply_index_seeks) still reruns every
call in this version -- a full plan cache needs to correlate a cached
plan's embedded IndexSeek value back to the specific $param that
produced it, and value-equality correlation isn't sound (two different
params can coincidentally share a value, misattributing which one a
cached literal should track). ParamSite/PathStep (params.rs) and
IndexSeekOutcome/apply_index_seeks_tracked (planner.rs) are in place as
forward-compatible infrastructure for that follow-up, once it has a
sound provenance mechanism, but aren't wired into execution yet.

GraphStore::schema_generation (marsdb-graph) invalidates the validation
cache when a new index is declared, since that can change what a
statement's validation-independent behavior... [no, indexes don't
affect validation, only planning -- generation exists for the planning
cache this version doesn't have yet, kept for forward compatibility
and because Database::execute_prepared_plan already threads it through].

Executor gains _trusted variants of its two guarded entry points
(execute_with_options_trusted, execute_in_write_transaction_with_options_trusted)
that skip validate_statement; every existing call site is unchanged and
still validates unconditionally.

Full workspace test suite, clippy (default + arrow feature), fmt, and
the openCypher TCK (3880/3880, unchanged) all pass.

Benchmarked against the LDBC-style traversal workload this was
motivated by: no measurable improvement (validation was not, in fact,
the dominant per-call cost for these queries -- planning and/or the
per-call AST clone and transaction-open overhead are). Landing this as
a correct, tested increment; closing the actual gap needs either the
deferred plan-caching work or a separate look at what's actually
dominant.
New public API from the prepared-plan-cache work needs its own usage
example alongside the existing execute/execute_with_options/
begin_transaction ones.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 89.84615% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
marsdb-query/src/params.rs 89.65% 48 Missing ⚠️
marsdb-query/src/planner.rs 60.71% 11 Missing ⚠️
marsdb/src/lib.rs 90.00% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread marsdb/tests/prepared_plan.rs Dismissed
Comment thread marsdb/tests/prepared_plan.rs Dismissed
@knoguchi
knoguchi merged commit 3c472a4 into main Aug 29, 2026
12 checks passed
@knoguchi
knoguchi deleted the feat/prepared-plan-cache branch August 29, 2026 02:38
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.

3 participants