Add an opt-in prepared-statement cache for repeated SqlStatement executions - #576
Open
Yaraslaut wants to merge 11 commits into
Open
Add an opt-in prepared-statement cache for repeated SqlStatement executions#576Yaraslaut wants to merge 11 commits into
Yaraslaut wants to merge 11 commits into
Conversation
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>
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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #552
What
SqlConnectioncan now keep already-preparedSQLHSTMThandles alive in a bounded LRU pool, so re-preparing a query text the connection has seen before skipsSQLPrepare— a parse/plan round-trip to the server on MS SQL Server and PostgreSQL.Before this, every
Prepare()paid that round-trip:DataMapperand theSqlQueryDSL build a freshSqlStatementper call site, so the same handful of query texts were re-prepared continuously.Pooled applications configure it on the pool instead of per connection:
Design
SqlPreparedStatementCache(new, owned bySqlConnection) — bounded LRU pool keyed by exact SQL text. A handle is checked out while a statement uses it (Acquireremoves it,Releaseputs 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_multimapofstring_viewkeys 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 theDataMapper'sINSERThandle was thrown away by theSELECT LAST_INSERT_ROWID()that immediately follows it — the DataMapper test caught exactly that (5 misses, 0 hits) before the fix.Acquire().Poolcreates its mappers withDataMapper's default constructor, which goes throughConnect(SqlConnectionString)and so never readsSqlConnectionDataSource::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.PoolConfigtherefore gainspreparedStatementCacheCapacityas a compile-time policy field alongside the three already there, withLIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY(default0) wiring it toGlobalDataMapperPool(). The four sites that constructed a pooledDataMappernow go through a singlePool::CreateDataMapper()factory — the place any future pool-wide connection setting belongs too — and the capacity is applied underif constexpr, so a pool left at the default emits exactly the code it did before the field existed.SQLHDBC; it can never be shared with another connection. So each pooled connection warms up independently, a fully warmed pool holds up tomaxSize * capacityprepared statements on the server, andGrowthStrategy::BoundedOverflowdiscards 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.0because a pooled handle carries the plan the driver derived from the schema at preparation time. Once enabled, every layer on top ofSqlStatementbenefits with no call-site change; a single statement opts out viaSetPreparedStatementCaching(SqlPreparedStatementCaching::Disabled).MigrateDirect()and theMigrationManagerexecutor clear the cache after applying a script; connect / reconnect /Close()clear it as well (statement handles are children of the DBC handle). Raw DDL viaExecuteDirect()remains the caller's responsibility, documented.switchin business logic:SqlConnection::SupportsPreparedStatementReuse()mirrorsSupportsNativeRowBatch(). On a backend that is notMICROSOFT_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 aSqlLoggerspy counting logicalPrepare()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 byMigrateDirect(),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()pastinitialSize); and a pooled connection keeps its warmed handles across a return/re-acquire cycle, so fiveDataMapper::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
SQLPrepareinstead 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 perPrepare(), and a client-sideSQLAllocHandle/SQLFreeHandlepair perExecuteDirect()that follows a prepared statement on the sameSqlStatement(no server round-trip). Under a pool the win arrives after a warm-up of oneSQLPrepareper 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
0the new code paths are inert and the diff is behaviour-neutral.ExecuteDirect()DDL.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.maxSize. Default0keeps this inert; the sizing guidance is indocs/best-practices.md.SupportsPreparedStatementReuse(); unverified backends never pool.SqlConnectionitself; the async layer already serializes ODBC work per connection on a strand.SqlStatement, new fields inSqlConnection::Data(opaque, heap-allocated), and a new member onPoolConfig, which changes the NTTP mangling of everyPool<Config>instantiation. Source-compatible throughout (the newPoolConfigfield 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-debugbuild:AlterTable AlterColumnSQLite skip).Compilers tested
-std=c++23 -O2 -Wall -Wextra— clean, including a translation unit that explicitly instantiatesPool<Config>in both the configured and the default (if constexprfalse) branch ofCreateDataMapper(). This is a compile/instantiate check only: a full GCC link is not possible on this macOS host, sinceSqlLogger.cppfails onstd::stacktrace, missing from Homebrew libstdc++ on macOS — pre-existing and unrelated to this change.gcc-releaseitself is gated to Linux byCMakePresets.json, so CI's GCC leg remains the real coverage there.gcc-releaseis Linux-only). The new namespace-scope constants areinline constexpr, per the module-linkage rule inAGENT.md; new types are exported fromLightweight.cppm.Docs
docs/usage.mdgains a "Prepared-statement cache" section next to the block-prefetch one it mirrors, including the pool configuration and the CMake option;docs/best-practices.mdgains a sizing/opt-out entry plus themaxSize * capacitybudgeting note for pools.scripts/check-doc-snippets.pypasses (41 snippets).🤖 Generated with Claude Code