Skip to content

Add an opt-in prepared-statement cache for repeated SqlStatement executions - #576

Open
Yaraslaut wants to merge 11 commits into
masterfrom
feature/552-prepared-statement-cache
Open

Add an opt-in prepared-statement cache for repeated SqlStatement executions#576
Yaraslaut wants to merge 11 commits into
masterfrom
feature/552-prepared-statement-cache

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 19, 2026

Copy link
Copy Markdown
Member

Closes #552

What

SqlConnection can now keep already-prepared SQLHSTMT handles alive in a bounded LRU pool, so re-preparing a query text the connection has seen before skips SQLPrepare — a parse/plan round-trip to the server on MS SQL Server and PostgreSQL.

Before this, every Prepare() paid that round-trip: DataMapper and the SqlQuery DSL build a fresh SqlStatement per call site, so the same handful of query texts were re-prepared continuously.

auto conn = SqlConnection {};
conn.SetPreparedStatementCacheCapacity(Lightweight::PreparedStatementCacheCapacitySuggested); // 64
// every DataMapper / query-builder / raw Prepare() call on this connection now benefits

Pooled applications configure it on the pool instead of per connection:

constexpr auto MyPoolConfig = Lightweight::PoolConfig {
    .initialSize = 4,
    .maxSize = 16,
    .growthStrategy = Lightweight::GrowthStrategy::BoundedOverflow,
    .preparedStatementCacheCapacity = Lightweight::PreparedStatementCacheCapacitySuggested,
};
auto pool = Lightweight::Pool<MyPoolConfig> {};

Design

  • SqlPreparedStatementCache (new, owned by SqlConnection) — bounded LRU pool keyed by exact SQL text. A handle is checked out while a statement uses it (Acquire removes it, Release puts it back), so two statements preparing the same text concurrently each get their own handle. Eviction is least-recently-released first, which matters because several backends cap live prepared statements per session. std::list + unordered_multimap of string_view keys into the stable list nodes, so lookup and eviction are both O(1) average.
  • Prepare() parks before it looks up. The handle currently held goes back to the pool first, so a repeat of the same query re-acquires exactly that handle (zero churn), and an interleaved query set keeps both hot. This replaced a special-cased "same query text" fast path — one mechanism instead of two.
  • ExecuteDirect() / ExecuteBatchFetch() park too, since executing directly discards whatever the handle was prepared for. Without this the DataMapper's INSERT handle was thrown away by the SELECT LAST_INSERT_ROWID() that immediately follows it — the DataMapper test caught exactly that (5 misses, 0 hits) before the fix.
  • Configured on the pool, not at every Acquire(). Pool creates its mappers with DataMapper's default constructor, which goes through Connect(SqlConnectionString) and so never reads SqlConnectionDataSource::preparedStatementCacheCapacity — without a pool-level knob the cache was simply unreachable for pooled code except by repeating a setter at each call site, and mappers created on overflow still started cold. PoolConfig therefore gains preparedStatementCacheCapacity as a compile-time policy field alongside the three already there, with LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY (default 0) wiring it to GlobalDataMapperPool(). The four sites that constructed a pooled DataMapper now go through a single Pool::CreateDataMapper() factory — the place any future pool-wide connection setting belongs too — and the capacity is applied under if constexpr, so a pool left at the default emits exactly the code it did before the field existed.
  • The cache is per connection and cannot be otherwise. A pooled handle is a child of one connection's SQLHDBC; it can never be shared with another connection. So each pooled connection warms up independently, a fully warmed pool holds up to maxSize * capacity prepared statements on the server, and GrowthStrategy::BoundedOverflow discards the warmed cache of any connection it destroys on return. All three points are called out in the docs, since they drive how the capacity should be sized.
  • Opt-in, then transparent. Capacity defaults to 0 because a pooled handle carries the plan the driver derived from the schema at preparation time. Once enabled, every layer on top of SqlStatement benefits with no call-site change; a single statement opts out via SetPreparedStatementCaching(SqlPreparedStatementCaching::Disabled).
  • Invalidation. MigrateDirect() and the MigrationManager executor clear the cache after applying a script; connect / reconnect / Close() clear it as well (statement handles are children of the DBC handle). Raw DDL via ExecuteDirect() remains the caller's responsibility, documented.
  • Per-DBMS via a connection capability, not a switch in business logic: SqlConnection::SupportsPreparedStatementReuse() mirrors SupportsNativeRowBatch(). On a backend that is not MICROSOFT_SQL / POSTGRESQL / SQLITE, a requested capacity is retained but stays inactive, so the same setup code is safe to run anywhere.

Tests

src/tests/SqlPreparedStatementCacheTests.cpp — 15 cases, 130 assertions. Cache hits are proven both by the cache's own hit/miss/eviction counters and by a SqlLogger spy counting logical Prepare() calls: five prepares of one query text reach the driver once. Beyond the headline, it covers pooling across statement lifetimes, two concurrent statements each getting their own handle, LRU eviction at capacity 1, capacity shrink, the per-statement opt-out, invalidation by MigrateDirect(), DataMapper::Create() in a loop (1 miss / 4 hits, no call-site change), and a differential test that reads the same rows with and without the cache and compares results — so a reused handle returning stale or wrong data would fail.

Three of those cases cover the pool path: a default-configured pool leaves the cache disabled; a configured pool applies the capacity on both creation paths (pre-created in the pool's constructor, and created on demand by Acquire() past initialSize); and a pooled connection keeps its warmed handles across a return/re-acquire cycle, so five DataMapper::Create() calls split over two acquires still reach the driver exactly once.

Performance impact

The point of the change. With the cache enabled, N prepares of one query text cost 1 SQLPrepare instead of N; on TCP-backed drivers that is N-1 fewer round-trips. Off by default, so no change to existing behaviour or performance unless a capacity is set. Costs when enabled: one hash lookup per Prepare(), and a client-side SQLAllocHandle/SQLFreeHandle pair per ExecuteDirect() that follows a prepared statement on the same SqlStatement (no server round-trip). Under a pool the win arrives after a warm-up of one SQLPrepare per connection per query text, not one per process. No benchmark numbers — the win is round-trip elimination and the local test DB is SQLite (in-process), where it would not show.

Risk assessment

Low–medium, mitigated by the feature being off by default: with capacity 0 the new code paths are inert and the diff is behaviour-neutral.

  • Stale plans after DDL — the real hazard. Mitigated by clearing on migrations and on connect/close, and documented for raw ExecuteDirect() DDL.
  • Handle-state leakage between uses — a handle is returned neutral: CloseCursor() (which also tears down block-prefetch still referencing it), SQL_UNBIND, SQL_RESET_PARAMS, and the parameter-array attribute reset. None of these unprepare the statement.
  • Server-side handle count under a pool — the bound is per connection, so a pool multiplies it by maxSize. Default 0 keeps this inert; the sizing guidance is in docs/best-practices.md.
  • Per-DBMS — gated by SupportsPreparedStatementReuse(); unverified backends never pool.
  • Threading — the cache is per-connection and not thread-safe, matching SqlConnection itself; the async layer already serializes ODBC work per connection on a strand.
  • ABI — new member on SqlStatement, new fields in SqlConnection::Data (opaque, heap-allocated), and a new member on PoolConfig, which changes the NTTP mangling of every Pool<Config> instantiation. Source-compatible throughout (the new PoolConfig field is defaulted, so existing designated-initializer call sites are untouched); not ABI-compatible, consistent with how this repo evolves.

Databases tested

Full suite, all green, clang-debug build:

  • sqlite3 (3.46.1) — 1417 cases, 13759 assertions, 1 skipped (pre-existing AlterTable AlterColumn SQLite skip).
  • mssql2022 (Docker) — 1417 cases, 13731 assertions, 3 skipped (pre-existing per-DBMS skips).
  • postgres (Docker 16.4) — 1417 cases, 13684 assertions, 2 skipped (pre-existing per-DBMS skips).

Compilers tested

  • clang-debug (PEDANTIC + ASan/UBSan + clang-tidy) — clean build, no warnings, no clang-tidy findings; the three database runs above were made with it.
  • GCC 15 (Homebrew), -std=c++23 -O2 -Wall -Wextra — clean, including a translation unit that explicitly instantiates Pool<Config> in both the configured and the default (if constexpr false) branch of CreateDataMapper(). This is a compile/instantiate check only: a full GCC link is not possible on this macOS host, since SqlLogger.cpp fails on std::stacktrace, missing from Homebrew libstdc++ on macOS — pre-existing and unrelated to this change. gcc-release itself is gated to Linux by CMakePresets.json, so CI's GCC leg remains the real coverage there.
  • C++20 modules / C++26 reflection — not buildable on this host (gcc-release is Linux-only). The new namespace-scope constants are inline constexpr, per the module-linkage rule in AGENT.md; new types are exported from Lightweight.cppm.

Docs

docs/usage.md gains a "Prepared-statement cache" section next to the block-prefetch one it mirrors, including the pool configuration and the CMake option; docs/best-practices.md gains a sizing/opt-out entry plus the maxSize * capacity budgeting note for pools. scripts/check-doc-snippets.py passes (41 snippets).

🤖 Generated with Claude Code

Preparing a statement is a parse/plan round-trip to the server on MS SQL Server
and PostgreSQL, and Lightweight paid it on every Prepare(): the DataMapper and
the SqlQuery DSL build a fresh SqlStatement per call site, so the same handful
of query texts were re-prepared continuously.

SqlConnection can now keep the already-prepared SQLHSTMT handles alive in a
bounded LRU pool (SqlPreparedStatementCache), so re-preparing a query text the
connection has seen before skips SQLPrepare entirely. A handle is checked out
while a statement uses it and returned when that statement is re-prepared or
destroyed; the least recently returned handle is evicted once the capacity bound
is exceeded, which matters because several backends cap the number of live
prepared statements per session.

Prepare() parks the handle it holds before looking one up, so a repeat of the
same query finds exactly the handle just parked — and interleaved query texts
keep both hot. ExecuteDirect() and ExecuteBatchFetch() park too, since executing
directly discards whatever the handle was prepared for; without that the
DataMapper's INSERT handle would be thrown away by the last-insert-id query that
immediately follows it.

The cache is opt-in (capacity defaults to 0) because a pooled handle carries the
query plan the driver derived from the schema at preparation time. Once enabled
it is transparent: every layer built on SqlStatement benefits without call-site
changes, and a single statement opts out via SetPreparedStatementCaching(). DDL
that Lightweight owns — MigrateDirect() and the MigrationManager executor — drops
the cached plans, as does connecting, reconnecting or closing a connection.

Per-DBMS behaviour goes through a connection capability,
SqlConnection::SupportsPreparedStatementReuse(), rather than a switch in the
caller: a backend not known to keep prepared handles re-executable keeps the
requested capacity inactive, so the same setup code is safe everywhere.

Closes #552

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Proves the acceptance criterion that a cache hit avoids re-preparation, using
the cache's own hit/miss/eviction counters alongside a SqlLogger spy that counts
logical Prepare() calls: five prepares of one query text reach the driver once.

Also covers the pieces that make reuse safe rather than merely fast — pooling
across statement lifetimes, two concurrent statements each getting their own
handle, LRU eviction under a capacity of one, shrinking the capacity, the
per-statement opt-out, cache invalidation by MigrateDirect(), and that a
DataMapper::Create() loop benefits without any call-site change. One test reads
the same rows with and without the cache and compares the results, so a reused
handle that silently returned stale or wrong data would fail.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
Adds a usage.md section next to the block-prefetch one it mirrors — how to
enable the cache, what it does, the statistics, and the schema-change caveat with
the invalidation Lightweight performs for you — plus a best-practices entry on
sizing it and when to opt out.

Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
@Yaraslaut
Yaraslaut requested a review from a team as a code owner August 19, 2026 06:56
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests Core API labels Aug 19, 2026
Applying /code-review findings on this branch.

BindInputParameter() deliberately overwrites m_expectedParameterCount with
the SQLSMALLINT max sentinel, but ReleasePreparedHandle() pooled that member
verbatim. A later cache hit then adopted 32767 as the real parameter count
and skipped SQLNumParams, so the next Execute(args...) threw
std::invalid_argument{"Invalid argument count"} for a perfectly valid call,
and every such Prepare() sized the indicator vector to 32768 entries
(256 KB). DataMapper::Create/Update bind by hand, so this is the mainline
path, not a corner. Re-derive the count via SQLNumParams when the member
holds the sentinel, and decline to pool the handle if that fails.
Regression test added; it fails with exactly that exception without the fix.

ExecuteScriptRespectingSqliteGuards() cleared the plan cache before running
the DDL, so any handle released during the script -- including
ExecuteDirect's own parked handle and the SQLite table-rebuild helpers --
survived into the pool holding a pre-migration plan. docs/usage.md already
documents the clear as happening after the script. Move it into a
detail::Finally guard so it also runs on the exception path.

Prepare() called AcquirePreparedHandle(query), which releases the previous
handle and clears m_preparedQuery, and then went on to use `query` -- which
may be a view onto the string just cleared. stmt.Prepare(stmt.PreparedQuery())
was safe before this branch. Copy the text first and prepare from the member.

Export the two documented cache-capacity constants from the module, so
consumers can name them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.07692% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Lightweight/SqlConnection.hpp 60.00% 2 Missing ⚠️
src/Lightweight/SqlStatement.cpp 98.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 3 commits August 20, 2026 17:11
The cache was only reachable per connection, but a pooled application never
touches a connection directly: Pool creates its DataMappers with the default
constructor, which goes through Connect(SqlConnectionString) and therefore
never reads SqlConnectionDataSource::preparedStatementCacheCapacity. Enabling
the cache under a pool meant repeating a setter at every Acquire() call site,
and mappers created on overflow still started cold.

PoolConfig gains preparedStatementCacheCapacity, a compile-time policy knob
like the three already there, with LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY
(default 0) wiring it to the global pool. The four places that created a
DataMapper now go through one CreateDataMapper() factory, which is where any
future pool-wide connection setting belongs as well. The capacity is applied
under `if constexpr`, so a pool left at the default emits exactly the code it
did before the setting existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three cases: a default-configured pool leaves the cache disabled; a configured
pool applies the capacity on both creation paths (pre-created in the pool's
constructor and created on demand by Acquire()); and a pooled connection keeps
its warmed handles across a return/re-acquire cycle, so five DataMapper::Create()
calls split over two acquires still reach the driver exactly once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also spells out what "per connection" costs under a pool: handles are children
of one connection's SQLHDBC and can never be shared, so every pooled connection
warms up separately, a fully warmed pool holds up to maxSize * capacity prepared
statements on the server, and BoundedOverflow discards the warmed cache of any
connection it destroys on return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov flagged both arms of SqlPreparedStatementCache::Release() that do not
pool: the null-handle guard, and the disabled-cache path that frees the handle
instead of keeping it. Neither is reachable through SqlStatement — a statement
never releases a null handle, and it does not call Release() at all when the
cache is off — so both are driven directly against the cache.

The disabled-cache case allocates a real SQLHSTMT from the connection's own DBC
rather than passing a fabricated value, because what the test asserts is that
Release() takes ownership even when it keeps nothing: leaking there would cost
one statement handle per Release() on every connection with the cache switched
off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut and others added 3 commits August 20, 2026 19:59
SQLHSTMT is a void*, so `auto nativeHandle = SQLHSTMT {}` trips
readability-qualified-auto. Naming the type spells out what the handle is
anyway, which reads better here than `auto*`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…introduced

Routing the four creation sites through one factory turned two previously
untouched lines into changed lines, and Codecov duly reported them uncovered:
BoundedWait's below-capacity branch in Acquire(), and the async awaitable's own
creation step. Neither had a test before, so consolidating them exposed a gap
rather than creating one -- but it is a gap in exactly the code this PR adds the
capacity knob to, so it is worth closing properly.

Both new cases assert the capacity really is applied on that path, not merely
that a connection comes back: initialSize = 0 is what forces each of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t gaps

Patch coverage sits at 98.1%. All three uncovered lines are unreachable from the test
suite rather than untested, so they are now commented as such instead of looking like
something someone forgot:

- SupportsPreparedStatementReuse()'s MYSQL/UNKNOWN arm reads ServerType() off a live
  connection, and every environment in the matrix reports one of the three supported
  backends. (SupportsNativeRowArrayFetch, right below it, takes the server type as a
  parameter and is unit-tested for every enumerator - reshaping this one the same way
  would make the arm reachable, which is a deliberate API change rather than a test.)
- the return after that exhaustive switch exists only to satisfy -Wreturn-type.
- the SQLNumParams failure arm in the pooling path cannot be provoked: the handle is
  still prepared there, so every driver in the matrix answers the call.

No behaviour change; comments only. Full suite green on sqlite3, mssql2022 (Docker)
and postgres (Docker 16.4) under clang-debug.

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

Labels

Core API Data Mapper documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add prepared-statement caching for repeated SqlStatement executions

1 participant