diff --git a/docs/best-practices.md b/docs/best-practices.md index 588594775..3946b4b70 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -125,6 +125,33 @@ Keep in mind: on a connection used for cursors you intend to abandon early or where memory is tight. - It does not change results — values are identical to the per-row path. +### Enable the prepared-statement cache for recurring queries + +Every `Prepare()` costs a parse/plan round-trip on Microsoft SQL Server and PostgreSQL, and the +`DataMapper` / query-builder layers re-prepare the same handful of statements on every call because each +call site builds a fresh `SqlStatement`. A connection can pool the already-prepared handles so repeats +skip `SQLPrepare` — see [Prepared-statement cache](usage.md): + +```cpp +connection.SetPreparedStatementCacheCapacity(Lightweight::PreparedStatementCacheCapacitySuggested); +``` + +Keep in mind: + +- It is **opt-in** (default capacity `0`), and transparent once enabled — no call-site changes. +- Size it to your working set of distinct query texts. Too small and the LRU thrashes; too large and you + risk the server-side cap on live prepared statements per session. +- With a connection pool, set it once via `PoolConfig::preparedStatementCacheCapacity` (or the + `LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY` CMake option for `GlobalDataMapperPool()`) instead + of per acquired connection. Budget for the whole pool: handles cannot be shared between connections, so + a warmed pool holds up to `maxSize * preparedStatementCacheCapacity` of them, and every connection pays + its own warm-up. Under `GrowthStrategy::BoundedOverflow`, connections returned to an already-full idle + set are destroyed and their warmed caches with them. +- A pooled handle carries the plan derived from the schema at preparation time. Call + `ClearPreparedStatementCache()` after raw DDL; migrations and `MigrateDirect()` already do. +- Statements whose plan must be re-derived opt out via + `SqlStatement::SetPreparedStatementCaching(SqlPreparedStatementCaching::Disabled)`. + ## SQL Server Variation Challenges ### 64-bit Integer Handling in Oracle Database diff --git a/docs/usage.md b/docs/usage.md index 079b88870..463066a7a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -81,6 +81,83 @@ while (cursor.FetchRow()) std::println("{}|{}|{}", record.a, record.b, record.c); ``` +## Prepared-statement cache (fewer prepare round-trips) + +`Prepare()` sends the SQL text to the server so it can parse and plan it — one network round-trip on +Microsoft SQL Server and PostgreSQL, paid again every time the same query text is prepared. Applications +built on `DataMapper` or the query builders re-prepare the same handful of statements constantly, because +each call site creates its own short-lived `SqlStatement`. + +A connection can keep the already-prepared handles alive in a bounded LRU pool, so re-preparing a query +it has seen before skips `SQLPrepare` entirely: + +```cpp +auto conn = SqlConnection {}; +conn.SetPreparedStatementCacheCapacity(Lightweight::PreparedStatementCacheCapacitySuggested); // 64 +``` + +The cache is **opt-in** (default capacity `Lightweight::PreparedStatementCacheCapacityDefault`, i.e. `0` += disabled) but, once enabled, **transparent**: every `SqlStatement` on that connection participates, so +`DataMapper`, the `SqlQuery` DSL, and raw `Prepare()` call sites all benefit without a code change. It +can also be requested up-front via `SqlConnectionDataSource::preparedStatementCacheCapacity`. + +For pooled applications, configure it on the pool rather than on each acquired connection. +`PoolConfig::preparedStatementCacheCapacity` is applied to every connection the pool creates, so no +call site has to remember to enable it: + +```cpp +constexpr auto MyPoolConfig = Lightweight::PoolConfig { + .initialSize = 4, + .maxSize = 16, + .growthStrategy = Lightweight::GrowthStrategy::BoundedOverflow, + .preparedStatementCacheCapacity = Lightweight::PreparedStatementCacheCapacitySuggested, +}; +auto pool = Lightweight::Pool {}; +``` + +The global pool returned by `GlobalDataMapperPool()` takes the same setting from the CMake option +`LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY` (default `0`, i.e. disabled), alongside the +existing `LIGHTWEIGHT_POOL_INITIAL_SIZE`, `LIGHTWEIGHT_POOL_MAX_SIZE` and +`LIGHTWEIGHT_POOL_GROWTH_STRATEGY`. + +A pooled connection keeps its warmed handles across acquires, since the pool hands back the same live +connection rather than reconnecting it. Note that the capacity is **per connection**: the cache is a set +of ODBC statement handles owned by one connection's `SQLHDBC` and can never be shared with another +connection, so each pooled connection warms up separately and a fully warmed pool holds up to +`maxSize * preparedStatementCacheCapacity` prepared statements on the server. + +How it works: a handle is *checked out* while a statement uses it and returned to the pool when that +statement is re-prepared or destroyed. Two statements preparing the same text at the same time therefore +each get their own handle. When the pool exceeds its capacity the least recently returned handle is +freed — a bound that matters because several backends cap the number of live prepared statements per +session. Statistics are available for diagnostics: + +```cpp +auto const& stats = conn.PreparedStatementCache().Stats(); +std::println("prepare hits={} misses={} evictions={}", stats.hits, stats.misses, stats.evictions); +``` + +**Schema changes invalidate cached plans.** A pooled handle carries the plan the driver derived from the +schema as it was at preparation time, so DDL must drop it: + +```cpp +conn.ClearPreparedStatementCache(); +``` + +Lightweight does this for you where it owns the DDL — `SqlStatement::MigrateDirect()` and the +`MigrationManager` executor clear the cache after applying a script — and disconnecting or reconnecting a +connection clears it as well. Raw DDL you send through `ExecuteDirect()` is your responsibility. A single +statement that must never reuse a plan opts out: + +```cpp +auto stmt = SqlStatement { conn }; +stmt.SetPreparedStatementCaching(SqlPreparedStatementCaching::Disabled); +``` + +The cache is active on Microsoft SQL Server, PostgreSQL and SQLite. On any other backend +`SqlConnection::SupportsPreparedStatementReuse()` is false and the requested capacity stays inactive, so +the same setup code is safe to run everywhere. + ## SQL Query Builder Or construct statement using `SqlQueryBuilder` diff --git a/src/Lightweight/CMakeLists.txt b/src/Lightweight/CMakeLists.txt index ea1cf8368..76ab2cc00 100644 --- a/src/Lightweight/CMakeLists.txt +++ b/src/Lightweight/CMakeLists.txt @@ -8,6 +8,7 @@ endif() set(LIGHTWEIGHT_POOL_INITIAL_SIZE "4" CACHE STRING "Initial number of pre-created DataMappers in the global pool") set(LIGHTWEIGHT_POOL_MAX_SIZE "16" CACHE STRING "Maximum pool size (used by BoundedWait and BoundedOverflow)") set(LIGHTWEIGHT_POOL_GROWTH_STRATEGY "BoundedOverflow" CACHE STRING "Pool growth strategy: BoundedWait, BoundedOverflow, or UnboundedGrow") +set(LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY "0" CACHE STRING "Prepared-statement cache capacity of each pooled connection (0 disables the cache)") if(NOT WIN32) find_package(SQLite3 REQUIRED) @@ -90,6 +91,7 @@ set(HEADER_FILES SqlLogger.hpp SqlMigration.hpp SqlOdbcWide.hpp + SqlPreparedStatementCache.hpp SqlQueryFormatter.hpp SqlSchema.hpp SqlScopedLock.hpp @@ -124,6 +126,7 @@ set(SOURCE_FILES SqlError.cpp SqlLogger.cpp SqlMigration.cpp + SqlPreparedStatementCache.cpp SqlQuery.cpp SqlQuery/Core.cpp SqlQuery/Migrate.cpp @@ -189,6 +192,7 @@ target_compile_definitions(Lightweight PUBLIC LIGHTWEIGHT_POOL_INITIAL_SIZE=${LIGHTWEIGHT_POOL_INITIAL_SIZE} LIGHTWEIGHT_POOL_MAX_SIZE=${LIGHTWEIGHT_POOL_MAX_SIZE} LIGHTWEIGHT_POOL_GROWTH_STRATEGY=${LIGHTWEIGHT_POOL_GROWTH_STRATEGY} + LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY=${LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY} ) add_library(LightweightTools STATIC) diff --git a/src/Lightweight/DataMapper/Pool.hpp b/src/Lightweight/DataMapper/Pool.hpp index 23c8d3a11..e41f839e4 100644 --- a/src/Lightweight/DataMapper/Pool.hpp +++ b/src/Lightweight/DataMapper/Pool.hpp @@ -3,6 +3,7 @@ #include "../Async/Executor.hpp" #include "../Async/Task.hpp" +#include "../SqlConnectInfo.hpp" #include "../SqlLogger.hpp" #include "DataMapper.hpp" @@ -60,6 +61,17 @@ struct PoolConfig /// Strategy to determine how the pool should grow when there are no idle data mappers available, default is BoundedWait /// which blocks until a data mapper is returned to the pool GrowthStrategy growthStrategy { GrowthStrategy::BoundedWait }; + /// Prepared-statement cache capacity given to the connection of every data mapper this pool creates, + /// i.e. how many already-prepared ODBC statement handles that connection keeps for reuse. Zero (the + /// default) leaves the cache disabled, exactly as an unpooled connection. + /// + /// The bound is per connection, not per pool: a pool may hold up to `maxSize` connections, each with + /// its own cache of this size, so the live prepared handles a fully warmed pool holds on the server + /// are `maxSize * preparedStatementCacheCapacity`. Size it against the backend's per-session limit + /// on prepared statements, not against the number of distinct queries alone. + /// + /// @see SqlConnection::SetPreparedStatementCacheCapacity for what enabling the cache implies. + size_t preparedStatementCacheCapacity { PreparedStatementCacheCapacityDefault }; }; /// @ingroup ConnectionPool @@ -132,6 +144,22 @@ class Pool private: struct WaiterNode; // defined below; referenced by ReturnLocked's signature. + /// Creates a data mapper owned by this pool, with every per-connection setting the pool's + /// @ref PoolConfig prescribes already applied. The single place a pooled connection comes into + /// existence, so a pool-wide connection setting is configured once rather than at each of the + /// four creation sites. + /// + /// @return An owned data mapper connected via the default connection string. + static std::unique_ptr CreateDataMapper() + { + auto dataMapper = std::make_unique(); + // Compile-time gate, so a pool left at the default capacity emits exactly the code it did + // before the setting existed. + if constexpr (Config.preparedStatementCacheCapacity != 0) + dataMapper->Connection().SetPreparedStatementCacheCapacity(Config.preparedStatementCacheCapacity); + return dataMapper; + } + /// Detaches the async backend from a returned mapper's connection before it is idled or handed /// off, so a recycled connection never carries references to executors that may since have been /// destroyed (the next @c AcquireAsync re-enables it fresh). Shared by every @c Return overload. @@ -224,7 +252,7 @@ class Pool { _idleDataMappers.reserve(Config.initialSize); for ([[maybe_unused]] auto const _: std::views::iota(0U, Config.initialSize)) - _idleDataMappers.push_back(std::make_unique()); + _idleDataMappers.push_back(CreateDataMapper()); } /// Destructor. The pool manages the lifecycle of the idle data mappers; be aware that any @@ -270,7 +298,7 @@ class Pool { // below capacity: create a fresh data mapper ++_checkedOut; - return PooledDataMapper(*this, std::make_unique()); + return PooledDataMapper(*this, CreateDataMapper()); } // Pool exhausted: park as a FIFO waiter (fair with AcquireAsync waiters) and block until a @@ -291,7 +319,7 @@ class Pool if (_idleDataMappers.empty()) { // create a new data mapper and return it - return PooledDataMapper(*this, std::make_unique()); + return PooledDataMapper(*this, CreateDataMapper()); } // get a data mapper from the pool @@ -513,7 +541,7 @@ class Pool } ++pool._checkedOut; } - acquired = std::make_unique(); + acquired = Pool::CreateDataMapper(); return false; } @@ -556,6 +584,7 @@ class Pool // LIGHTWEIGHT_POOL_MAX_SIZE (default: 16) // LIGHTWEIGHT_POOL_GROWTH_STRATEGY (default: BoundedOverflow) // Accepted values: BoundedWait, BoundedOverflow, UnboundedGrow +// LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY (default: 0, i.e. disabled) #if !defined(LIGHTWEIGHT_POOL_INITIAL_SIZE) #define LIGHTWEIGHT_POOL_INITIAL_SIZE 4 @@ -569,10 +598,15 @@ class Pool #define LIGHTWEIGHT_POOL_GROWTH_STRATEGY BoundedOverflow #endif +#if !defined(LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY) + #define LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY 0 +#endif + inline constexpr PoolConfig DefaultPoolConfig { .initialSize = LIGHTWEIGHT_POOL_INITIAL_SIZE, .maxSize = LIGHTWEIGHT_POOL_MAX_SIZE, .growthStrategy = GrowthStrategy::LIGHTWEIGHT_POOL_GROWTH_STRATEGY, + .preparedStatementCacheCapacity = LIGHTWEIGHT_POOL_PREPARED_STATEMENT_CACHE_CAPACITY, }; using DataMapperPool = Pool; diff --git a/src/Lightweight/Lightweight.cppm b/src/Lightweight/Lightweight.cppm index 5a2180eb7..acca692b6 100644 --- a/src/Lightweight/Lightweight.cppm +++ b/src/Lightweight/Lightweight.cppm @@ -24,6 +24,7 @@ module; #include "SqlBackup/SqlBackup.hpp" #include "SqlBackup/TableFilter.hpp" #include "SqlErrorDetection.hpp" +#include "SqlPreparedStatementCache.hpp" #include "SqlScopedLock.hpp" #include "ThreadSafeQueue.hpp" #include "Tools/CxxModelPrinter.hpp" @@ -99,6 +100,8 @@ using Lightweight::MemberIndexOf; using Lightweight::NotSqlElements; using Lightweight::ParseConnectionString; using Lightweight::PostgreSqlFormatter; +using Lightweight::PreparedStatementCacheCapacityDefault; +using Lightweight::PreparedStatementCacheCapacitySuggested; using Lightweight::PrimaryKey; using Lightweight::QualifiedColumnName; using Lightweight::RecordColumnCount; @@ -194,6 +197,8 @@ using Lightweight::SqlNumeric; using Lightweight::SqlNumericType; using Lightweight::SqlOptimalMaxColumnSize; using Lightweight::SqlOutputColumnBinder; +using Lightweight::SqlPreparedStatementCache; +using Lightweight::SqlPreparedStatementCaching; using Lightweight::SqlPrimaryKeyType; using Lightweight::SqlQualifiedTableColumnName; using Lightweight::SqlQueryBuilder; diff --git a/src/Lightweight/Lightweight.hpp b/src/Lightweight/Lightweight.hpp index 34662005a..e69bbb053 100644 --- a/src/Lightweight/Lightweight.hpp +++ b/src/Lightweight/Lightweight.hpp @@ -9,6 +9,7 @@ #include "SqlError.hpp" #include "SqlLogger.hpp" #include "SqlMigration.hpp" +#include "SqlPreparedStatementCache.hpp" #include "SqlQuery.hpp" #include "SqlQueryFormatter.hpp" #include "SqlRealName.hpp" diff --git a/src/Lightweight/SqlConnectInfo.hpp b/src/Lightweight/SqlConnectInfo.hpp index d23f5c10f..e2a2572f4 100644 --- a/src/Lightweight/SqlConnectInfo.hpp +++ b/src/Lightweight/SqlConnectInfo.hpp @@ -23,6 +23,19 @@ namespace Lightweight /// a value <= 1 disables prefetch. constexpr std::size_t PrefetchDepthDefault = 1000; +/// @brief Default capacity of a connection's prepared-statement cache: the number of already-prepared +/// ODBC statement handles kept alive for reuse. +/// +/// Zero — the cache is opt-in. Reusing a prepared handle also reuses the query plan the driver derived +/// from the schema at preparation time, so enabling it is a deliberate per-connection decision. See +/// @c SqlConnection::SetPreparedStatementCacheCapacity and +/// @ref SqlConnectionDataSource::preparedStatementCacheCapacity. +inline constexpr std::size_t PreparedStatementCacheCapacityDefault = 0; + +/// @brief A sensible capacity for enabling the prepared-statement cache on a connection serving a +/// bounded set of recurring queries (the typical DataMapper workload). +inline constexpr std::size_t PreparedStatementCacheCapacitySuggested = 64; + /// @ingroup CoreApi /// Represents an ODBC connection string. struct SqlConnectionString @@ -82,6 +95,14 @@ struct [[nodiscard]] SqlConnectionDataSource /// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch). std::size_t defaultPrefetchDepth = PrefetchDepthDefault; + /// @brief Capacity of the prepared-statement cache on the resulting connection: how many + /// already-prepared ODBC statement handles are kept alive so that re-preparing the same SQL text + /// skips the driver's @c SQLPrepare round-trip. + /// + /// Defaults to @c PreparedStatementCacheCapacityDefault (zero, i.e. disabled). See + /// @c SqlConnection::SetPreparedStatementCacheCapacity for the implications of enabling it. + std::size_t preparedStatementCacheCapacity = PreparedStatementCacheCapacityDefault; + /// Constructs a SqlConnectionDataSource from the given connection string. LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value); diff --git a/src/Lightweight/SqlConnection.cpp b/src/Lightweight/SqlConnection.cpp index f4d5a3277..c5bd00442 100644 --- a/src/Lightweight/SqlConnection.cpp +++ b/src/Lightweight/SqlConnection.cpp @@ -4,6 +4,7 @@ #include "DataBinder/UnicodeConverter.hpp" #include "SqlConnection.hpp" #include "SqlOdbcWide.hpp" +#include "SqlPreparedStatementCache.hpp" #include "SqlQuery.hpp" #include "SqlQueryFormatter.hpp" #include "SqlStatement.hpp" @@ -57,6 +58,13 @@ struct SqlConnection::Data std::unique_ptr asyncBackend; // Async execution backend (null until EnableAsync()). std::size_t defaultPrefetchDepth = PrefetchDepthDefault; // Rows requested per SQLFetchScroll on the // transparent per-row prefetch path (<= 1 disables). + std::size_t requestedPreparedStatementCacheCapacity = + PreparedStatementCacheCapacityDefault; // Capacity the user asked for; only handed to the cache + // below once the connected backend is known to support + // prepared-handle reuse. + SqlPreparedStatementCache preparedStatementCache { + PreparedStatementCacheCapacityDefault + }; // Pool of already-prepared statement handles (inactive while its capacity is zero). }; SqlConnection::SqlConnection(): @@ -185,6 +193,35 @@ void SqlConnection::SetDefaultPrefetchDepth(std::size_t depth) noexcept m_data->defaultPrefetchDepth = depth; } +std::size_t SqlConnection::PreparedStatementCacheCapacity() const noexcept +{ + return m_data->preparedStatementCache.Capacity(); +} + +void SqlConnection::SetPreparedStatementCacheCapacity(std::size_t capacity) noexcept +{ + m_data->requestedPreparedStatementCacheCapacity = capacity; + ApplyPreparedStatementCacheCapacity(); +} + +void SqlConnection::ApplyPreparedStatementCacheCapacity() noexcept +{ + // A backend whose driver is not known to keep prepared handles re-executable never pools them, so + // the request is honoured as "inactive" rather than silently risking a stale handle. + m_data->preparedStatementCache.SetCapacity( + SupportsPreparedStatementReuse() ? m_data->requestedPreparedStatementCacheCapacity : 0); +} + +void SqlConnection::ClearPreparedStatementCache() noexcept +{ + m_data->preparedStatementCache.Clear(); +} + +SqlPreparedStatementCache& SqlConnection::PreparedStatementCache() noexcept +{ + return m_data->preparedStatementCache; +} + void SqlConnection::EnableAsync(Async::IExecutor& dbWorkers, Async::IResumeScheduler& resume) { // TODO(async): once the native event backend lands, select it here via a per-connection @@ -232,6 +269,10 @@ bool SqlConnection::Connect(SqlConnectionDataSource const& info) noexcept EnsureHandlesAllocated(); m_data->defaultPrefetchDepth = info.defaultPrefetchDepth; + m_data->requestedPreparedStatementCacheCapacity = info.preparedStatementCacheCapacity; + + // Handles prepared against the previous session die with the disconnect below. + m_data->preparedStatementCache.Clear(); if (m_hDbc) SQLDisconnect(m_hDbc); @@ -307,6 +348,9 @@ bool SqlConnection::Connect(SqlConnectionString sqlConnectionString) noexcept ZoneScopedN("SqlConnection::Connect(ConnectionString)"); EnsureHandlesAllocated(); + // Handles prepared against the previous session die with the disconnect below. + m_data->preparedStatementCache.Clear(); + if (m_hDbc) SQLDisconnect(m_hDbc); @@ -385,6 +429,10 @@ void SqlConnection::PostConnect() // Get the driver name from the connection handle. m_driverName = GetInfoStringW(m_hDbc, SQL_DRIVER_NAME); + // The server type is only known now, so this is the earliest point at which the backend capability + // gate on the prepared-statement cache can be evaluated. + ApplyPreparedStatementCacheCapacity(); + if (m_serverType == SqlServerType::SQLITE) { // Set a busy timeout to prevent "database is locked" errors during concurrent access. @@ -420,6 +468,10 @@ void SqlConnection::Close() noexcept SqlLogger::GetLogger().OnConnectionClosed(*this); + // Statement handles are children of the DBC handle: free the pooled ones before it goes away. + if (m_data) + m_data->preparedStatementCache.Clear(); + SQLDisconnect(m_hDbc); SQLFreeHandle(SQL_HANDLE_DBC, m_hDbc); SQLFreeHandle(SQL_HANDLE_ENV, m_hEnv); diff --git a/src/Lightweight/SqlConnection.hpp b/src/Lightweight/SqlConnection.hpp index 96a5959f4..57e8a6ebc 100644 --- a/src/Lightweight/SqlConnection.hpp +++ b/src/Lightweight/SqlConnection.hpp @@ -40,6 +40,7 @@ namespace Lightweight class SqlQueryBuilder; class SqlMigrationQueryBuilder; class SqlQueryFormatter; +class SqlPreparedStatementCache; /// @ingroup CoreApi /// @brief Represents a connection to a SQL database. @@ -219,6 +220,52 @@ class SqlConnection final /// a value <= 1 disables prefetch (restoring one @c SQLFetch per row). LIGHTWEIGHT_API void SetDefaultPrefetchDepth(std::size_t depth) noexcept; + /// @brief Whether this connection's backend supports reusing an already-prepared statement handle + /// for a later execution of the same SQL text. + /// + /// Gates the prepared-statement cache: a backend not known to keep a prepared statement + /// re-executable across an intervening cursor close never pools handles, no matter what capacity + /// was configured. Like @ref SupportsNativeRowBatch this is a driver/backend capability rather than + /// a SQL-dialect concern, so it lives on the connection instead of a `switch` in the caller. + /// + /// @return `true` if prepared handles may be pooled and re-executed on this backend. + [[nodiscard]] bool SupportsPreparedStatementReuse() const noexcept; + + /// @brief The capacity of this connection's prepared-statement cache. + /// + /// @return The number of already-prepared statement handles kept for reuse; `0` when the cache is + /// disabled (the default). + [[nodiscard]] LIGHTWEIGHT_API std::size_t PreparedStatementCacheCapacity() const noexcept; + + /// @brief Enables (or resizes) this connection's prepared-statement cache. + /// + /// With a non-zero capacity, `SqlStatement::Prepare()` on this connection first looks for an idle + /// handle already prepared for the same SQL text and reuses it, skipping the driver's `SQLPrepare` + /// round-trip. Handles return to the cache when the statement is re-prepared or destroyed, and are + /// evicted least-recently-used first once the bound is exceeded. Every layer built on + /// `SqlStatement` — `DataMapper`, the query builders — benefits without call-site changes; a single + /// statement can opt out via `SqlStatement::SetPreparedStatementCaching`. + /// + /// A capacity request on a backend without @ref SupportsPreparedStatementReuse is kept but stays + /// inactive, so the same setup code is safe to run against any DBMS. + /// + /// @warning A pooled handle carries the query plan the driver derived when it was prepared. Call + /// @ref ClearPreparedStatementCache after DDL that a cached query touches; Lightweight's + /// own migration executor and `SqlStatement::MigrateDirect` already do so. + /// + /// @param capacity Maximum number of idle prepared handles to keep; `0` disables and clears the + /// cache. @c PreparedStatementCacheCapacitySuggested is a reasonable starting point. + LIGHTWEIGHT_API void SetPreparedStatementCacheCapacity(std::size_t capacity) noexcept; + + /// Frees every pooled prepared statement handle, e.g. after DDL invalidated the cached query plans. + LIGHTWEIGHT_API void ClearPreparedStatementCache() noexcept; + + /// @brief Retrieves this connection's prepared-statement cache. + /// + /// Non-const because acquiring and releasing pooled handles mutates the cache. Mostly of interest + /// for its @c SqlPreparedStatementCache::Stats counters. + [[nodiscard]] LIGHTWEIGHT_API SqlPreparedStatementCache& PreparedStatementCache() noexcept; + /// Creates a new query builder for the given table, compatible with the current connection. /// /// @param table The table to query. @@ -322,6 +369,9 @@ class SqlConnection final void PostConnect(); + /// Re-evaluates the backend capability gate on the requested prepared-statement cache capacity. + void ApplyPreparedStatementCacheCapacity() noexcept; + // Private data members // Note: move/move assignment operators implemented manually // if adding new data members, make sure to update them accordingly. @@ -369,6 +419,31 @@ inline bool SqlConnection::SupportsNativeRowBatch() const noexcept return false; } +inline bool SqlConnection::SupportsPreparedStatementReuse() const noexcept +{ + // Re-executing a prepared statement after its cursor was closed is core ODBC, but pooling handles + // across call sites is only claimed for the backends Lightweight tests against. An unverified + // backend keeps the always-correct prepare-per-statement path. + switch (ServerType()) + { + case SqlServerType::MICROSOFT_SQL: + case SqlServerType::POSTGRESQL: + case SqlServerType::SQLITE: + return true; + // Not covered, and not coverable from the suite: this reads ServerType() off a live + // connection, and every environment in the test matrix reports one of the three above. + // (SupportsNativeRowArrayFetch below 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.) + case SqlServerType::MYSQL: + case SqlServerType::UNKNOWN: + return false; + } + // Unreachable: the switch above is exhaustive and every arm returns. Kept because a switch over a + // scoped enum without a default label still leaves the function without a return as far as + // -Wreturn-type is concerned. + return false; +} + inline bool SqlConnection::SupportsNativeRowArrayFetch(SqlServerType serverType) noexcept { // Native ODBC row-array fetching (SQL_ATTR_ROW_ARRAY_SIZE > 1 + SQLFetchScroll) is a per-driver diff --git a/src/Lightweight/SqlMigration.cpp b/src/Lightweight/SqlMigration.cpp index 4a4f6066a..c7d71b8b1 100644 --- a/src/Lightweight/SqlMigration.cpp +++ b/src/Lightweight/SqlMigration.cpp @@ -11,6 +11,7 @@ #include "SqlMigration.hpp" #include "SqlSchema.hpp" #include "SqlTransaction.hpp" +#include "Utils.hpp" #include #include @@ -1687,6 +1688,13 @@ namespace /// Otherwise execute the script directly. void ExecuteScriptRespectingSqliteGuards(SqlStatement& stmt, SqlConnection& connection, std::string_view script) { + // A migration changes the schema the pooled query plans were derived from, so drop them — on + // the way *out*, including the exception path. Clearing up front would miss every handle the + // script's own statements (the SQLite table-rebuild helpers below, in particular) hand back to + // the pool while the DDL runs, all of which carry a plan for the pre-migration schema. + auto const dropCachedQueryPlans = + ::Lightweight::detail::Finally([&connection] { connection.ClearPreparedStatementCache(); }); + auto const parsed = TryParseSqliteGuard(script); if (!parsed || !connection.RequiresTableRebuildForSchemaChange()) { diff --git a/src/Lightweight/SqlPreparedStatementCache.cpp b/src/Lightweight/SqlPreparedStatementCache.cpp new file mode 100644 index 000000000..7186e2ee8 --- /dev/null +++ b/src/Lightweight/SqlPreparedStatementCache.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "SqlPreparedStatementCache.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace Lightweight +{ + +namespace +{ + void FreeHandle(SqlPreparedStatementCache::PreparedHandle const& handle) noexcept + { + if (handle.nativeHandle != SQL_NULL_HSTMT) + SQLFreeHandle(SQL_HANDLE_STMT, handle.nativeHandle); + } +} // namespace + +SqlPreparedStatementCache::SqlPreparedStatementCache(std::size_t capacity) noexcept: + m_capacity { capacity } +{ +} + +SqlPreparedStatementCache::~SqlPreparedStatementCache() noexcept +{ + Clear(); +} + +void SqlPreparedStatementCache::SetCapacity(std::size_t capacity) noexcept +{ + m_capacity = capacity; + EvictSurplus(); +} + +void SqlPreparedStatementCache::ResetStatistics() noexcept +{ + m_stats = {}; +} + +std::optional SqlPreparedStatementCache::Acquire(std::string_view query) noexcept +{ + auto const indexed = m_index.find(query); + if (indexed == m_index.end()) + { + ++m_stats.misses; + return std::nullopt; + } + + auto const entry = indexed->second; + auto const handle = entry->handle; + m_index.erase(indexed); + m_entries.erase(entry); + ++m_stats.hits; + return handle; +} + +void SqlPreparedStatementCache::Release(std::string_view query, PreparedHandle handle) noexcept +{ + if (handle.nativeHandle == SQL_NULL_HSTMT) + return; + + if (!IsEnabled()) + { + FreeHandle(handle); + return; + } + + m_entries.emplace_front(Entry { .query = std::string(query), .handle = handle }); + m_index.emplace(std::string_view { m_entries.front().query }, m_entries.begin()); + EvictSurplus(); +} + +void SqlPreparedStatementCache::Clear() noexcept +{ + for (auto const& entry: m_entries) + FreeHandle(entry.handle); + m_entries.clear(); + m_index.clear(); +} + +void SqlPreparedStatementCache::EraseFromIndex(EntryList::const_iterator entry) noexcept +{ + auto const [first, last] = m_index.equal_range(std::string_view { entry->query }); + auto const indexed = + std::ranges::find(first, last, entry, [](auto const& pair) { return EntryList::const_iterator { pair.second }; }); + if (indexed != last) + m_index.erase(indexed); +} + +void SqlPreparedStatementCache::EvictSurplus() noexcept +{ + while (m_entries.size() > m_capacity) + { + auto const victim = std::prev(m_entries.end()); + EraseFromIndex(victim); + FreeHandle(victim->handle); + m_entries.erase(victim); + ++m_stats.evictions; + } +} + +} // namespace Lightweight diff --git a/src/Lightweight/SqlPreparedStatementCache.hpp b/src/Lightweight/SqlPreparedStatementCache.hpp new file mode 100644 index 000000000..1b5243843 --- /dev/null +++ b/src/Lightweight/SqlPreparedStatementCache.hpp @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// See SqlOdbcPrelude.hpp's header comment for why this replaces a direct include. +#include "Api.hpp" +#include "SqlOdbcPrelude.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace Lightweight +{ + +/// @ingroup CoreApi +/// @brief Whether a single @c SqlStatement takes part in its connection's prepared-statement cache. +/// +/// Statements opt in by default, which only has an effect once the owning connection was given a +/// non-zero cache capacity (see @c SqlConnection::SetPreparedStatementCacheCapacity). Individual +/// call sites that must not reuse a plan — for instance a statement that straddles a schema change — +/// opt out via @c SqlStatement::SetPreparedStatementCaching. +enum class SqlPreparedStatementCaching : uint8_t +{ + /// Reuse a pooled handle when one matches, and hand the handle back to the pool afterwards. + Enabled, + + /// Never take a handle from, nor give one to, the connection's cache. + Disabled, +}; + +/// @ingroup CoreApi +/// @brief A bounded LRU pool of already-prepared ODBC statement handles, owned by a @c SqlConnection. +/// +/// Preparing a statement is a server round-trip on most drivers (MS SQL Server, PostgreSQL). This +/// cache keeps the @c SQLHSTMT handles of recently prepared queries alive, so re-preparing the same +/// SQL text on the same connection skips @c SQLPrepare entirely. +/// +/// A handle is *checked out* while a statement uses it: @ref Acquire removes it from the pool and +/// @ref Release puts it back. Two statements preparing the same query at the same time therefore each +/// get their own handle, and both are pooled afterwards (subject to the capacity bound). Eviction is +/// least-recently-released first, which matters because several backends cap the number of live +/// prepared statements per session. +/// +/// @note Not thread-safe, mirroring @c SqlConnection: one connection is used by one thread at a time. +/// @note A pooled handle holds a query plan derived from the schema as it was at preparation time. A +/// connection that runs DDL must drop those plans via +/// @c SqlConnection::ClearPreparedStatementCache — Lightweight's own migration paths do it for you. +class SqlPreparedStatementCache final +{ + public: + /// @brief A pooled statement handle together with the parameter count the driver reported for it. + struct PreparedHandle + { + /// The native ODBC statement handle, prepared for the associated query text. + SQLHSTMT nativeHandle {}; + + /// The number of input parameters @c SQLNumParams reported for that query. + SQLSMALLINT parameterCount {}; + }; + + /// @brief Cumulative counters, primarily for tests and diagnostics. + struct Statistics + { + /// Prepare requests served from the pool, i.e. without a @c SQLPrepare round-trip. + uint64_t hits {}; + + /// Prepare requests that had to issue @c SQLPrepare. + uint64_t misses {}; + + /// Pooled handles freed because the capacity bound was exceeded. + uint64_t evictions {}; + }; + + /// @brief Constructs a cache with the given capacity. + /// @param capacity Maximum number of idle prepared handles to keep; @c 0 disables the cache. + LIGHTWEIGHT_API explicit SqlPreparedStatementCache(std::size_t capacity = 0) noexcept; + + /// Frees every pooled statement handle. + LIGHTWEIGHT_API ~SqlPreparedStatementCache() noexcept; + + SqlPreparedStatementCache(SqlPreparedStatementCache const&) = delete; + SqlPreparedStatementCache& operator=(SqlPreparedStatementCache const&) = delete; + SqlPreparedStatementCache(SqlPreparedStatementCache&&) = delete; + SqlPreparedStatementCache& operator=(SqlPreparedStatementCache&&) = delete; + + /// @return The maximum number of idle prepared handles kept (@c 0 when disabled). + [[nodiscard]] std::size_t Capacity() const noexcept + { + return m_capacity; + } + + /// @brief Sets the capacity, evicting the least recently released handles when shrinking. + /// @param capacity Maximum number of idle prepared handles to keep; @c 0 disables and clears. + LIGHTWEIGHT_API void SetCapacity(std::size_t capacity) noexcept; + + /// @return Whether the cache is enabled, i.e. whether its capacity is non-zero. + [[nodiscard]] bool IsEnabled() const noexcept + { + return m_capacity != 0; + } + + /// @return The number of idle prepared handles currently pooled. + [[nodiscard]] std::size_t Size() const noexcept + { + return m_entries.size(); + } + + /// @return The cumulative hit/miss/eviction counters. + [[nodiscard]] Statistics const& Stats() const noexcept + { + return m_stats; + } + + /// Resets the cumulative counters to zero, leaving the pooled handles untouched. + LIGHTWEIGHT_API void ResetStatistics() noexcept; + + /// @brief Takes an idle handle prepared for @p query out of the pool. + /// + /// The caller owns the returned handle until it hands it back via @ref Release (or frees it). + /// + /// @param query The exact SQL text the handle must have been prepared with. + /// @return The pooled handle, or @c std::nullopt when no idle handle matches. + [[nodiscard]] LIGHTWEIGHT_API std::optional Acquire(std::string_view query) noexcept; + + /// @brief Hands a prepared handle back to the pool as the most recently used entry. + /// + /// The caller must have closed the handle's cursor and unbound its columns beforehand. Ownership + /// of @p handle transfers to the cache; when the capacity bound is exceeded — or the cache is + /// disabled — the surplus handle is freed right away. + /// + /// @param query The SQL text @p handle is prepared for. + /// @param handle The prepared handle to pool. + LIGHTWEIGHT_API void Release(std::string_view query, PreparedHandle handle) noexcept; + + /// Frees every pooled handle, e.g. after DDL invalidated the cached query plans. + LIGHTWEIGHT_API void Clear() noexcept; + + private: + /// One pooled handle plus the query text it is keyed by. Held in a list so node addresses — and + /// therefore the @c string_view keys of @c m_index, which point into @c query — stay stable. + struct Entry + { + std::string query; + PreparedHandle handle; + }; + + using EntryList = std::list; + + /// Drops the index entry referring to @p entry (there may be several entries per query text). + void EraseFromIndex(EntryList::const_iterator entry) noexcept; + + /// Frees the least recently released handles until at most @c m_capacity remain. + void EvictSurplus() noexcept; + + std::size_t m_capacity; + EntryList m_entries; // front = most recently used + std::unordered_multimap m_index; // query text -> entry + Statistics m_stats {}; +}; + +} // namespace Lightweight diff --git a/src/Lightweight/SqlStatement.cpp b/src/Lightweight/SqlStatement.cpp index a3e8a79ac..52a467d09 100644 --- a/src/Lightweight/SqlStatement.cpp +++ b/src/Lightweight/SqlStatement.cpp @@ -230,7 +230,8 @@ SqlStatement::SqlStatement(SqlStatement&& other) noexcept: m_connection { other.m_connection }, m_hStmt { other.m_hStmt }, m_preparedQuery { std::move(other.m_preparedQuery) }, - m_expectedParameterCount { other.m_expectedParameterCount } + m_expectedParameterCount { other.m_expectedParameterCount }, + m_preparedStatementCaching { other.m_preparedStatementCaching } { other.m_data.reset(); other.m_connection = nullptr; @@ -247,6 +248,7 @@ SqlStatement& SqlStatement::operator=(SqlStatement&& other) noexcept m_hStmt = other.m_hStmt; m_preparedQuery = std::move(other.m_preparedQuery); m_expectedParameterCount = other.m_expectedParameterCount; + m_preparedStatementCaching = other.m_preparedStatementCaching; other.m_data.reset(); other.m_connection = nullptr; @@ -275,9 +277,108 @@ SqlStatement::SqlStatement(std::nullopt_t /*nullopt*/): SqlStatement::~SqlStatement() noexcept { SqlLogger::GetLogger().OnFetchEnd(); + + // Hand the prepared handle back to the connection's pool, so the next statement preparing the same + // query text can skip SQLPrepare. Falls through to freeing it when the pool declines to take it. + if (ReleasePreparedHandle()) + return; + SQLFreeHandle(SQL_HANDLE_STMT, m_hStmt); } +void SqlStatement::SetPreparedStatementCaching(SqlPreparedStatementCaching caching) noexcept +{ + m_preparedStatementCaching = caching; +} + +SqlPreparedStatementCache* SqlStatement::UsablePreparedStatementCache() const noexcept +{ + if (m_preparedStatementCaching == SqlPreparedStatementCaching::Disabled || m_connection == nullptr) + return nullptr; + + auto& cache = m_connection->PreparedStatementCache(); + return cache.IsEnabled() ? &cache : nullptr; +} + +bool SqlStatement::ReleasePreparedHandle() noexcept +{ + auto* const cache = UsablePreparedStatementCache(); + if (cache == nullptr || m_hStmt == SQL_NULL_HSTMT || m_preparedQuery.empty()) + return false; + + // BindInputParameter() replaces m_expectedParameterCount with the "count unknown" sentinel, so the + // member no longer describes the query once a caller bound parameters by hand. A later cache hit + // adopts the pooled count verbatim and skips SQLNumParams, and the sentinel would then make every + // Execute(args...) on that query text reject its arguments — so re-derive the real count from the + // (still prepared) handle, and decline to pool it when even that fails. + // The SQLNumParams failure arm is not reachable from the suite: the handle is still prepared at + // this point, so every driver in the matrix answers it. Declining to pool the handle is the safe + // response for a driver that does not. + auto parameterCount = m_expectedParameterCount; + if (parameterCount == (std::numeric_limits::max)() + && !SQL_SUCCEEDED(SQLNumParams(m_hStmt, ¶meterCount))) + return false; + + // The pooled handle must come back neutral: cursor closed (CloseCursor also tears down any block + // prefetch still referencing the handle), columns unbound, parameter buffers and the parameter-array + // attributes reset. None of these unprepare the statement. + CloseCursor(); + SQLFreeStmt(m_hStmt, SQL_UNBIND); + SQLFreeStmt(m_hStmt, SQL_RESET_PARAMS); + ResetParameterArrayBinding(); + + cache->Release(m_preparedQuery, + SqlPreparedStatementCache::PreparedHandle { .nativeHandle = m_hStmt, .parameterCount = parameterCount }); + + m_hStmt = SQL_NULL_HSTMT; + m_preparedQuery.clear(); + m_expectedParameterCount = 0; + m_numColumns.reset(); + return true; +} + +void SqlStatement::EnsureStatementHandle() +{ + if (m_hStmt == SQL_NULL_HSTMT && m_connection != nullptr) + m_connection->RequireSuccess(SQLAllocHandle(SQL_HANDLE_STMT, m_connection->NativeHandle(), &m_hStmt)); +} + +void SqlStatement::ReleasePreparedHandleForDirectExecution() +{ + // SQLExecDirect discards whatever this handle was prepared for, so park it in the connection's pool + // first: a later Prepare() of that query text then still finds it. This matters for the DataMapper, + // which drives its INSERT through one statement and immediately reuses it for the direct + // last-insert-id query. + if (ReleasePreparedHandle()) + EnsureStatementHandle(); +} + +bool SqlStatement::AcquirePreparedHandle(std::string_view query) +{ + auto* const cache = UsablePreparedStatementCache(); + if (cache == nullptr) + return false; + + // Park the handle we hold before looking one up: re-preparing the same query then finds exactly the + // handle just parked, which is what makes a repeated Prepare() of one query text free. + ReleasePreparedHandle(); + + auto const pooled = cache->Acquire(query); + if (!pooled) + { + EnsureStatementHandle(); + return false; + } + + // Whatever we still hold was not worth pooling (it carries no prepared query), so it is surplus now. + if (m_hStmt != SQL_NULL_HSTMT) + SQLFreeHandle(SQL_HANDLE_STMT, m_hStmt); + + m_hStmt = pooled->nativeHandle; + m_expectedParameterCount = pooled->parameterCount; + return true; +} + SqlStatement SqlStatement::Prepare(std::string_view query) && { auto resultStatement = SqlStatement { std::move(*this) }; @@ -291,7 +392,16 @@ void SqlStatement::Prepare(std::string_view query) & ZoneTextObject(query); SqlLogger::GetLogger().OnPrepare(query); - m_preparedQuery = std::string(query); + // Copy the query text up front: AcquirePreparedHandle() clears m_preparedQuery when it parks the + // handle we currently hold, which would dangle a `query` that views this statement's own text + // (e.g. stmt.Prepare(stmt.PreparedQuery())). + auto queryText = std::string(query); + + // Reuses a handle the connection already prepared for this query text when the prepared-statement + // cache is enabled; otherwise a no-op, and SQLPrepareW below does the work. + auto const alreadyPrepared = AcquirePreparedHandle(queryText); + + m_preparedQuery = std::move(queryText); const_cast(this)->m_numColumns.reset(); m_data->postExecuteCallbacks.clear(); @@ -315,9 +425,12 @@ void SqlStatement::Prepare(std::string_view query) & // but psqlODBC has historically treated SQL_C_CHAR parameter binds differently // depending on the variant of the most recent statement-text call — so we keep // the path uniformly W to side-step that. - auto wQuery = detail::OdbcWideArg { query }; - RequireSuccess(SQLPrepareW(m_hStmt, wQuery.data(), static_cast(wQuery.buffer.size()))); - RequireSuccess(SQLNumParams(m_hStmt, &m_expectedParameterCount)); + if (!alreadyPrepared) + { + auto wQuery = detail::OdbcWideArg { std::string_view { m_preparedQuery } }; + RequireSuccess(SQLPrepareW(m_hStmt, wQuery.data(), static_cast(wQuery.buffer.size()))); + RequireSuccess(SQLNumParams(m_hStmt, &m_expectedParameterCount)); + } m_data->indicators.resize(static_cast(m_expectedParameterCount) + 1); } @@ -328,6 +441,8 @@ SqlResultCursor SqlStatement::ExecuteDirect(std::string_view const& query, std:: if (query.empty()) return SqlResultCursor { *this }; + ReleasePreparedHandleForDirectExecution(); + m_preparedQuery.clear(); m_numColumns.reset(); @@ -419,6 +534,8 @@ RowArrayCursor SqlStatement::ExecuteBatchFetch(std::string_view query, std::size if (arrayDepth == 0) throw std::invalid_argument { "arrayDepth must be greater than zero" }; + ReleasePreparedHandleForDirectExecution(); + m_preparedQuery.clear(); m_numColumns.reset(); diff --git a/src/Lightweight/SqlStatement.hpp b/src/Lightweight/SqlStatement.hpp index 9da74479f..cb86315d9 100644 --- a/src/Lightweight/SqlStatement.hpp +++ b/src/Lightweight/SqlStatement.hpp @@ -15,6 +15,7 @@ #include "DataMapper/Record.hpp" #include "SqlConnection.hpp" #include "SqlOdbcPrelude.hpp" +#include "SqlPreparedStatementCache.hpp" #include "SqlQuery.hpp" #include "SqlQueryFormatter.hpp" #include "SqlServerType.hpp" @@ -132,6 +133,22 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback /// Retrieves the last prepared query string. [[nodiscard]] std::string const& PreparedQuery() const noexcept; + /// @brief Whether this statement takes part in its connection's prepared-statement cache. + /// @return The configured participation mode (@c SqlPreparedStatementCaching::Enabled by default). + [[nodiscard]] SqlPreparedStatementCaching PreparedStatementCaching() const noexcept; + + /// @brief Opts this statement in or out of its connection's prepared-statement cache. + /// + /// Only has an effect while the connection has a non-zero cache capacity (see + /// @c SqlConnection::SetPreparedStatementCacheCapacity). Opt out for a statement whose query plan + /// must be re-derived — for instance because it runs across a schema change. + /// + /// The handle this statement currently holds is unaffected; from the next @c Prepare() on it is + /// simply neither taken from nor given back to the pool. + /// + /// @param caching Whether pooled handles may be reused by, and published from, this statement. + LIGHTWEIGHT_API void SetPreparedStatementCaching(SqlPreparedStatementCaching caching) noexcept; + /// Binds an input parameter to the prepared statement at the given column index. template void BindInputParameter(SQLSMALLINT columnIndex, Arg const& arg); @@ -452,6 +469,33 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback template void RecordPrefetchOutputColumn(SQLUSMALLINT column, T* arg); + // --- Prepared-statement cache: takes the statement handle from (and hands it back to) the pool + // owned by the connection, so re-preparing a query text that is already prepared skips SQLPrepare. + + /// @return The connection's prepared-statement cache if this statement may use it, else nullptr. + [[nodiscard]] SqlPreparedStatementCache* UsablePreparedStatementCache() const noexcept; + + /// @brief Parks the currently prepared handle in the pool and takes back one already prepared for + /// @p query, allocating a fresh handle when the pool has none. + /// + /// Parking first is what makes a repeated @c Prepare() of the same text free: the handle just + /// released is the one the immediately following lookup finds. + /// + /// @param query The SQL text about to be prepared. + /// @return true if @c m_hStmt is already prepared for @p query, so @c SQLPrepare can be skipped. + [[nodiscard]] bool AcquirePreparedHandle(std::string_view query); + + /// @brief Hands @c m_hStmt to the pool if it carries a prepared query and caching is in effect. + /// @return true if the handle was pooled (and must therefore not be freed by the caller). + bool ReleasePreparedHandle() noexcept; + + /// @brief Parks the prepared handle before a direct execution, which would discard what the handle + /// was prepared for, and gives this statement a fresh handle to execute on. + void ReleasePreparedHandleForDirectExecution(); + + /// Allocates a statement handle if this statement currently has none. + void EnsureStatementHandle(); + // private data members struct Data; std::unique_ptr m_data; // The private data of the statement @@ -460,8 +504,16 @@ class [[nodiscard]] SqlStatement final: public SqlDataBinderCallback std::string m_preparedQuery; // The last prepared query std::optional m_numColumns; // The number of columns in the result set, if known SQLSMALLINT m_expectedParameterCount {}; // The number of parameters expected by the query + SqlPreparedStatementCaching m_preparedStatementCaching { + SqlPreparedStatementCaching::Enabled + }; // Whether this statement may use the connection's prepared-statement cache }; +inline SqlPreparedStatementCaching SqlStatement::PreparedStatementCaching() const noexcept +{ + return m_preparedStatementCaching; +} + /// @ingroup CoreApi /// API for reading an SQL query result set. class [[nodiscard]] SqlResultCursor @@ -2036,6 +2088,9 @@ void SqlStatement::MigrateDirect(Callable const& callable, std::source_location query)); [[maybe_unused]] auto cursor = ExecuteDirect(query, location); } + + // The plans of any pooled handle were derived from the schema we just changed. + Connection().ClearPreparedStatementCache(); } template diff --git a/src/tests/Async/AsyncPoolTests.cpp b/src/tests/Async/AsyncPoolTests.cpp index 1c4a8495c..0780b1f8e 100644 --- a/src/tests/Async/AsyncPoolTests.cpp +++ b/src/tests/Async/AsyncPoolTests.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -92,6 +93,35 @@ TEST_CASE_METHOD(SqlTestFixture, "Async.Pool: AcquireAsync acquires, queries and CHECK(pool.IdleCount() == 2); // the acquired mapper was returned to the pool } +TEST_CASE_METHOD(SqlTestFixture, + "Async.Pool: AcquireAsync applies the pool's prepared-statement cache capacity", + "[Async][Pool][SqlPreparedStatementCache]") +{ + ThreadPoolExecutor dbWorkers { 2 }; + ManualExecutor appLoop; + + // initialSize = 0, so the awaitable has no idle entry to hand out and must create the connection + // itself — a creation path of its own, which has to configure the connection like the others do. + constexpr auto CachingPoolConfig = PoolConfig { + .initialSize = 0, + .maxSize = 4, + .growthStrategy = GrowthStrategy::BoundedOverflow, + .preparedStatementCacheCapacity = PreparedStatementCacheCapacitySuggested, + }; + auto pool = Pool(); + REQUIRE(pool.IdleCount() == 0); + + auto const capacity = RunPumped( + [&]() -> Task { + auto dm = co_await pool.AcquireAsync(dbWorkers, appLoop); + co_return dm->Connection().PreparedStatementCacheCapacity(); + }, + appLoop); + + CHECK(capacity == PreparedStatementCacheCapacitySuggested); + CHECK(pool.IdleCount() == 1); +} + TEST_CASE_METHOD(SqlTestFixture, "Async.Pool: SetAsyncExecutors lets the no-argument AcquireAsync wire mappers", "[Async][Pool]") diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 752f10cf8..6db39b971 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -69,6 +69,7 @@ set(SOURCE_FILES SqlFaultSeamTests.cpp SqlGuidTests.cpp SqlLoggerTests.cpp + SqlPreparedStatementCacheTests.cpp MigrationLockTests.cpp SqlConnectionDbTests.cpp SqlBinaryAndTextDbTests.cpp diff --git a/src/tests/SqlPreparedStatementCacheTests.cpp b/src/tests/SqlPreparedStatementCacheTests.cpp new file mode 100644 index 000000000..fdbf0a7ec --- /dev/null +++ b/src/tests/SqlPreparedStatementCacheTests.cpp @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "Utils.hpp" + +#include + +#include + +#include +#include +#include +#include +#include + +using namespace Lightweight; +using namespace std::string_view_literals; + +namespace +{ + +// Counts SqlStatement::Prepare() requests, so a test can relate the number of logical prepares to the +// number of driver round-trips the cache statistics report. +struct PrepareProbe: SqlLogger::Null +{ + std::size_t prepares = 0; + + void OnPrepare(std::string_view const& /*query*/) override + { + ++prepares; + } +}; + +// RAII swap of the active SqlLogger (restores the previous one on scope exit). +struct LoggerSwap +{ + SqlLogger* previous; + explicit LoggerSwap(SqlLogger& replacement): + previous { &SqlLogger::GetLogger() } + { + SqlLogger::SetLogger(replacement); + } + ~LoggerSwap() + { + SqlLogger::SetLogger(*previous); + } + LoggerSwap(LoggerSwap const&) = delete; + LoggerSwap& operator=(LoggerSwap const&) = delete; + LoggerSwap(LoggerSwap&&) = delete; + LoggerSwap& operator=(LoggerSwap&&) = delete; +}; + +// Creates the table the query-executing tests below read from, and returns a connection whose +// prepared-statement cache is enabled with freshly zeroed statistics. Table creation runs *before* the +// cache is enabled, so the DDL-triggered cache invalidation does not perturb the counters. +SqlConnection MakeSeededConnection(std::size_t capacity = 8) +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + + auto connection = SqlConnection {}; + { + auto stmt = SqlStatement { connection }; + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.DropTableIfExists("stmt_cache"); + migration.CreateTable("stmt_cache").PrimaryKey("id", Integer {}).RequiredColumn("value", Integer {}); + }); + + stmt.Prepare("INSERT INTO stmt_cache (id, value) VALUES (?, ?)"); + for (auto const id: { 1, 2, 3 }) + std::ignore = stmt.Execute(id, id * 10); + } + + connection.SetPreparedStatementCacheCapacity(capacity); + connection.PreparedStatementCache().ResetStatistics(); + return connection; +} + +// Reads the single `value` of the row with the given id through a prepared (and thus cacheable) query. +int SelectValue(SqlStatement& stmt, int id) +{ + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = ?"); + auto cursor = stmt.Execute(id); + REQUIRE(cursor.FetchRow()); + return cursor.GetColumn(1); +} + +} // namespace + +// A minimal record whose Create() re-prepares one and the same INSERT statement. +struct CachedThing +{ + Field id; + Field> name; +}; + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: disabled by default", "[SqlPreparedStatementCache]") +{ + auto connection = SqlConnection {}; + CHECK(connection.PreparedStatementCacheCapacity() == PreparedStatementCacheCapacityDefault); + CHECK(connection.PreparedStatementCacheCapacity() == 0); + + auto stmt = SqlStatement { connection }; + for (auto i = 0; i < 3; ++i) + stmt.Prepare("SELECT 1"); + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(stats.hits == 0); + CHECK(stats.misses == 0); + CHECK(connection.PreparedStatementCache().Size() == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: repeated prepare hits the cache", "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + auto probe = PrepareProbe {}; + auto const loggerSwap = LoggerSwap { probe }; + + auto stmt = SqlStatement { connection }; + for (auto i = 0; i < 5; ++i) + CHECK(SelectValue(stmt, 2) == 20); + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(probe.prepares == 5); // five logical prepares ... + CHECK(stats.misses == 1); // ... but only the first one reached the driver + CHECK(stats.hits == 4); + CHECK(stats.evictions == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a second statement reuses a pooled handle", + "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + + { + auto first = SqlStatement { connection }; + CHECK(SelectValue(first, 1) == 10); + } + + // The destroyed statement handed its prepared handle to the pool rather than freeing it. + CHECK(connection.PreparedStatementCache().Size() == 1); + CHECK(connection.PreparedStatementCache().Stats().misses == 1); + + { + auto second = SqlStatement { connection }; + CHECK(SelectValue(second, 3) == 30); + CHECK(connection.PreparedStatementCache().Stats().hits == 1); + CHECK(connection.PreparedStatementCache().Size() == 0); // checked out while in use + } + + CHECK(connection.PreparedStatementCache().Size() == 1); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: concurrent statements each get their own handle", + "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + + auto first = SqlStatement { connection }; + auto second = SqlStatement { connection }; + + CHECK(SelectValue(first, 1) == 10); + CHECK(SelectValue(second, 2) == 20); // same query text, but `first` still holds its handle + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(stats.misses == 2); + CHECK(stats.hits == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: bounded LRU evicts", "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(1); + REQUIRE(connection.PreparedStatementCacheCapacity() == 1); + + auto stmt = SqlStatement { connection }; + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = 1"); + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = 2"); + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = 3"); + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(stats.misses == 3); + CHECK(stats.hits == 0); + CHECK(stats.evictions == 1); + CHECK(connection.PreparedStatementCache().Size() <= 1); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: a statement can opt out", "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + + auto stmt = SqlStatement { connection }; + stmt.SetPreparedStatementCaching(SqlPreparedStatementCaching::Disabled); + CHECK(stmt.PreparedStatementCaching() == SqlPreparedStatementCaching::Disabled); + + for (auto i = 0; i < 3; ++i) + CHECK(SelectValue(stmt, 1) == 10); + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(stats.hits == 0); + CHECK(stats.misses == 0); + CHECK(connection.PreparedStatementCache().Size() == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a hand-bound statement pools the real parameter count", + "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + + { + // BindInputParameter() replaces the expected parameter count with the "unknown" sentinel. That + // sentinel must not reach the pool: a later cache hit adopts the pooled count and skips + // SQLNumParams, and Execute() would then reject a perfectly valid argument list. + auto stmt = SqlStatement { connection }; + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = ?"); + auto const id = 1; + stmt.BindInputParameter(1, id); + auto cursor = stmt.Execute(); + REQUIRE(cursor.FetchRow()); + CHECK(cursor.GetColumn(1) == 10); + } + + REQUIRE(connection.PreparedStatementCache().Size() == 1); + + auto stmt = SqlStatement { connection }; + CHECK(SelectValue(stmt, 2) == 20); // reuses the pooled handle, passing its argument through Execute() + CHECK(connection.PreparedStatementCache().Stats().hits == 1); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: reused handles keep returning correct results", + "[SqlPreparedStatementCache]") +{ + auto cached = std::vector {}; + auto uncached = std::vector {}; + + { + auto connection = MakeSeededConnection(); + auto stmt = SqlStatement { connection }; + for (auto i = 0; i < 4; ++i) + for (auto const id: { 1, 2, 3 }) + cached.push_back(SelectValue(stmt, id)); + CHECK(connection.PreparedStatementCache().Stats().hits > 0); + } + + { + auto connection = SqlConnection {}; + auto stmt = SqlStatement { connection }; + for (auto i = 0; i < 4; ++i) + for (auto const id: { 1, 2, 3 }) + uncached.push_back(SelectValue(stmt, id)); + CHECK(connection.PreparedStatementCache().Stats().hits == 0); + } + + CHECK(cached == uncached); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: pooled handles survive interleaved query texts", + "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + auto stmt = SqlStatement { connection }; + + for (auto i = 0; i < 3; ++i) + { + CHECK(SelectValue(stmt, 1) == 10); + + stmt.Prepare("SELECT COUNT(*) FROM stmt_cache"); + auto cursor = stmt.Execute(); + REQUIRE(cursor.FetchRow()); + CHECK(cursor.GetColumn(1) == 3); + } + + auto const& stats = connection.PreparedStatementCache().Stats(); + CHECK(stats.misses == 2); // one per distinct query text + CHECK(stats.hits == 4); + CHECK(stats.evictions == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: MigrateDirect drops cached plans", "[SqlPreparedStatementCache]") +{ + using namespace Lightweight::SqlColumnTypeDefinitions; + + auto connection = MakeSeededConnection(); + { + auto stmt = SqlStatement { connection }; + CHECK(SelectValue(stmt, 1) == 10); + } + REQUIRE(connection.PreparedStatementCache().Size() == 1); + + auto stmt = SqlStatement { connection }; + stmt.MigrateDirect([](SqlMigrationQueryBuilder& migration) { + migration.DropTableIfExists("stmt_cache_other"); + migration.CreateTable("stmt_cache_other").PrimaryKey("id", Integer {}); + }); + + CHECK(connection.PreparedStatementCache().Size() == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: shrinking the capacity evicts", "[SqlPreparedStatementCache]") +{ + auto connection = MakeSeededConnection(); + + { + auto stmt = SqlStatement { connection }; + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = 1"); + stmt.Prepare("SELECT value FROM stmt_cache WHERE id = 2"); + } + REQUIRE(connection.PreparedStatementCache().Size() == 2); + + connection.SetPreparedStatementCacheCapacity(0); + CHECK(connection.PreparedStatementCache().Size() == 0); + CHECK(connection.PreparedStatementCacheCapacity() == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: DataMapper benefits without call-site changes", + "[SqlPreparedStatementCache][DataMapper]") +{ + auto dm = DataMapper {}; + dm.CreateTable(); + + dm.Connection().SetPreparedStatementCacheCapacity(PreparedStatementCacheCapacitySuggested); + dm.Connection().PreparedStatementCache().ResetStatistics(); + + for (auto i = 0; i < 5; ++i) + { + auto thing = CachedThing {}; + thing.name = std::format("Thing {}", i); + dm.Create(thing); + } + + // Five DataMapper::Create() calls prepare the identical INSERT: only the first reached the driver. + auto const& stats = dm.Connection().PreparedStatementCache().Stats(); + CHECK(stats.misses == 1); + CHECK(stats.hits == 4); + + CHECK(dm.Query().All().size() == 5); +} + +// Pool configurations used by the tests below. `preparedStatementCacheCapacity` is a compile-time +// policy like the other three fields, so each variant is its own pool type. +namespace +{ +constexpr auto UnconfiguredPoolConfig = PoolConfig { + .initialSize = 1, + .maxSize = 2, + .growthStrategy = GrowthStrategy::BoundedOverflow, +}; + +constexpr auto CachingPoolConfig = PoolConfig { + .initialSize = 1, + .maxSize = 2, + .growthStrategy = GrowthStrategy::BoundedOverflow, + .preparedStatementCacheCapacity = PreparedStatementCacheCapacitySuggested, +}; + +// BoundedWait has its own below-capacity creation path, separate from the non-blocking strategies'. +// initialSize = 0 forces the very first Acquire() through it. +constexpr auto CachingWaitPoolConfig = PoolConfig { + .initialSize = 0, + .maxSize = 2, + .growthStrategy = GrowthStrategy::BoundedWait, + .preparedStatementCacheCapacity = PreparedStatementCacheCapacitySuggested, +}; +} // namespace + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a pool leaves the cache disabled by default", + "[SqlPreparedStatementCache][ConnectionPool]") +{ + auto pool = Pool {}; + + auto const pooled = pool.Acquire(); + CHECK(pooled->Connection().PreparedStatementCacheCapacity() == 0); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a pool configures every connection it creates", + "[SqlPreparedStatementCache][ConnectionPool]") +{ + auto pool = Pool {}; + + // The first acquire hands out a mapper the pool pre-created in its constructor, the second one + // exceeds `initialSize` and is therefore created on demand by Acquire() — both creation paths must + // apply the configured capacity. + auto const preCreated = pool.Acquire(); + auto const createdOnDemand = pool.Acquire(); + + CHECK(preCreated->Connection().PreparedStatementCacheCapacity() == PreparedStatementCacheCapacitySuggested); + CHECK(createdOnDemand->Connection().PreparedStatementCacheCapacity() == PreparedStatementCacheCapacitySuggested); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a pooled connection stays warm across acquires", + "[SqlPreparedStatementCache][ConnectionPool]") +{ + // The table must exist before the pool opens its connections, so that no pooled connection has to + // survive DDL for this test to be meaningful. + DataMapper {}.CreateTable(); + + auto pool = Pool {}; + + auto const CreateOne = [](DataMapper& dm, int index) { + auto thing = CachedThing {}; + thing.name = std::format("Thing {}", index); + dm.Create(thing); + }; + + { + auto pooled = pool.Acquire(); + pooled->Connection().PreparedStatementCache().ResetStatistics(); + for (auto const index: { 0, 1, 2 }) + CreateOne(pooled.Get(), index); + + auto const& stats = pooled->Connection().PreparedStatementCache().Stats(); + CHECK(stats.misses == 1); + CHECK(stats.hits == 2); + } // returned to the pool, keeping its prepared handles alive + + { + // BoundedOverflow keeps the returned mapper idle (the idle set is below maxSize), so this hands + // back the very same connection — with its cache still warm: no further driver round-trip. + auto pooled = pool.Acquire(); + for (auto const index: { 3, 4 }) + CreateOne(pooled.Get(), index); + + auto const& stats = pooled->Connection().PreparedStatementCache().Stats(); + CHECK(stats.misses == 1); + CHECK(stats.hits == 4); + } + + CHECK(DataMapper {}.Query().All().size() == 5); +} + +TEST_CASE_METHOD(SqlTestFixture, "PreparedStatementCache: releasing a null handle is a no-op", "[SqlPreparedStatementCache]") +{ + auto cache = SqlPreparedStatementCache { 4 }; + + // A statement that never reached SQLPrepare (or was moved from) parks a null handle. Pooling it + // would hand the next Acquire() of that query text an unusable handle instead of a prepared one. + cache.Release("SELECT 1", SqlPreparedStatementCache::PreparedHandle {}); + + CHECK(cache.Size() == 0); + CHECK_FALSE(cache.Acquire("SELECT 1").has_value()); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a disabled cache frees a released handle instead of pooling it", + "[SqlPreparedStatementCache]") +{ + auto connection = SqlConnection {}; + + // Allocated from the connection's own DBC so the handle the disabled cache frees is a real one: + // the point of the test is that Release() takes ownership even when it keeps nothing. + SQLHSTMT nativeHandle = SQL_NULL_HSTMT; + REQUIRE(SQL_SUCCEEDED(SQLAllocHandle(SQL_HANDLE_STMT, connection.NativeHandle(), &nativeHandle))); + + auto cache = SqlPreparedStatementCache { 0 }; + REQUIRE_FALSE(cache.IsEnabled()); + + cache.Release("SELECT 1", SqlPreparedStatementCache::PreparedHandle { .nativeHandle = nativeHandle }); + + // Nothing pooled, and the handle is gone rather than leaked — a leak would otherwise accumulate + // one statement handle per Release() on every connection whose cache is switched off. + CHECK(cache.Size() == 0); + CHECK_FALSE(cache.Acquire("SELECT 1").has_value()); +} + +TEST_CASE_METHOD(SqlTestFixture, + "PreparedStatementCache: a BoundedWait pool configures the connection it creates on demand", + "[SqlPreparedStatementCache][ConnectionPool]") +{ + auto pool = Pool {}; + + // Nothing pre-created and the pool is below capacity, so this takes BoundedWait's own creation + // path rather than handing out an idle entry — and that path must apply the capacity too. + auto const pooled = pool.Acquire(); + + CHECK(pooled->Connection().PreparedStatementCacheCapacity() == PreparedStatementCacheCapacitySuggested); + CHECK(pooled->Connection().IsAlive()); +}