Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyPoolConfig> {};
```

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`
Expand Down
4 changes: 4 additions & 0 deletions src/Lightweight/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -90,6 +91,7 @@ set(HEADER_FILES
SqlLogger.hpp
SqlMigration.hpp
SqlOdbcWide.hpp
SqlPreparedStatementCache.hpp
SqlQueryFormatter.hpp
SqlSchema.hpp
SqlScopedLock.hpp
Expand Down Expand Up @@ -124,6 +126,7 @@ set(SOURCE_FILES
SqlError.cpp
SqlLogger.cpp
SqlMigration.cpp
SqlPreparedStatementCache.cpp
SqlQuery.cpp
SqlQuery/Core.cpp
SqlQuery/Migrate.cpp
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 38 additions & 4 deletions src/Lightweight/DataMapper/Pool.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "../Async/Executor.hpp"
#include "../Async/Task.hpp"
#include "../SqlConnectInfo.hpp"
#include "../SqlLogger.hpp"
#include "DataMapper.hpp"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DataMapper> CreateDataMapper()
{
auto dataMapper = std::make_unique<DataMapper>();
// 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.
Expand Down Expand Up @@ -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<DataMapper>());
_idleDataMappers.push_back(CreateDataMapper());
}

/// Destructor. The pool manages the lifecycle of the idle data mappers; be aware that any
Expand Down Expand Up @@ -270,7 +298,7 @@ class Pool
{
// below capacity: create a fresh data mapper
++_checkedOut;
return PooledDataMapper(*this, std::make_unique<DataMapper>());
return PooledDataMapper(*this, CreateDataMapper());
}

// Pool exhausted: park as a FIFO waiter (fair with AcquireAsync waiters) and block until a
Expand All @@ -291,7 +319,7 @@ class Pool
if (_idleDataMappers.empty())
{
// create a new data mapper and return it
return PooledDataMapper(*this, std::make_unique<DataMapper>());
return PooledDataMapper(*this, CreateDataMapper());
}

// get a data mapper from the pool
Expand Down Expand Up @@ -513,7 +541,7 @@ class Pool
}
++pool._checkedOut;
}
acquired = std::make_unique<DataMapper>();
acquired = Pool::CreateDataMapper();
return false;
}

Expand Down Expand Up @@ -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
Expand All @@ -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<DefaultPoolConfig>;
Expand Down
5 changes: 5 additions & 0 deletions src/Lightweight/Lightweight.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/Lightweight/Lightweight.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions src/Lightweight/SqlConnectInfo.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading