Skip to content

Commit 5b66dc1

Browse files
author
Christian Parpart
committed
[SqlStatement] Add transparent block-prefetch for classic per-row fetch loops
Classic result iteration (while(cursor.FetchRow()){ GetColumn<T>(i) }, bound output columns, SqlRowIterator<T>, SqlVariantRowCursor) issued one SQLFetch per row -- one network round-trip per row, which dominates wall-clock for large result sets on TCP backends. The recent row-wise array fetch only sped up the materializing DataMapper paths (All/Range/ First); the lazy cursor loops were left on the per-row path and cannot be rewritten across all client code. Back the classic cursor transparently with the existing RowArrayCursor: on the first fetch of an eligible result set the statement arms a block buffer (SQL_ATTR_ROW_ARRAY_SIZE) and serves FetchRow()/GetColumn<T>() and bound-column scatters from it, cutting round-trips from N to ceil(N/depth). On by default; depth is a single connection-level knob (SqlConnection::SetDefaultPrefetchDepth, default PrefetchDepthDefault = 1000; <= 1 disables). Capability-gated by SupportsNativeRowArrayFetch(). Eligibility is restricted to fixed-width numeric, temporal and GUID columns, whose block reconstruction is byte-identical to the per-row binder on every backend. Result sets carrying character/text, NUMERIC, TIME, binary or LOB columns transparently stay on the per-row path (faithful materialization of those is not uniform across backends: MSSQL returns narrow text in the client codepage, SQLite's dynamic typing reports unreliable text sizes). A new SqlLogger::OnFetchBlock hook makes the round-trip reduction observable/testable. Performance: round-trips drop ~1000x at depth 1000 for eligible sets; no regression on the per-row path (no allocation when disabled/ineligible). Risk: an active cursor reads ahead up to one block (a few MB, budget- clamped); the connection knob is the global escape hatch. Tested: sqlite3, mssql2022 (Docker), postgres (Docker 16.4) -- full suite shows no regression vs the pre-change baseline; new [prefetch] suite green on all three. Build clean under clangcl-debug (PEDANTIC /WX). Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
1 parent b96f9ad commit 5b66dc1

11 files changed

Lines changed: 1439 additions & 4 deletions

docs/best-practices.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ single response.
106106
This will help to reduce the response time and the load on the server, and improve the performance of your
107107
application.
108108
109+
### Let block-prefetch cut network round-trips
110+
111+
Per-row fetch loops issue one `SQLFetch` (one network round-trip) per row. Lightweight transparently
112+
fetches rows in blocks (ODBC row-array binding) so a large result set costs `ceil(rows / depth)`
113+
round-trips instead of one per row — see [Transparent block-prefetch](usage.md). It is on by default
114+
(`Lightweight::PrefetchDepthDefault`, 1000 rows) and tuned per connection:
115+
116+
```cpp
117+
connection.SetDefaultPrefetchDepth(1000); // rows per SQLFetchScroll round-trip; <= 1 disables
118+
```
119+
120+
Keep in mind:
121+
122+
- It engages only for **fixed-width numeric/temporal** result sets; result sets with character,
123+
`GUID`, `NUMERIC`, `TIME`, binary or LOB columns transparently stay on the per-row path.
124+
- An active cursor reads ahead up to one block and holds a few MB of buffers, so set the depth to `1`
125+
on a connection used for cursors you intend to abandon early or where memory is tight.
126+
- It does not change results — values are identical to the per-row path.
127+
109128
## SQL Server Variation Challenges
110129

111130
### 64-bit Integer Handling in Oracle Database

docs/usage.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@ while (stmt.FetchRow())
3232
}
3333
```
3434

35+
## Transparent block-prefetch (fewer network round-trips)
36+
37+
Classic per-row fetch loops like the one above issue **one `SQLFetch` per row**, i.e. one network
38+
round-trip per row. On TCP-backed drivers (Microsoft SQL Server, PostgreSQL) that latency dominates the
39+
wall-clock time of large result sets.
40+
41+
Lightweight transparently reduces these round-trips: on the first `FetchRow()` of a result set it
42+
inspects the columns and, when eligible, fetches whole **blocks** of rows per `SQLFetchScroll`
43+
round-trip (ODBC row-array binding) and serves your `FetchRow()` / `GetColumn<T>()` calls from that
44+
buffer. **No code change is required** — the loops above, `SqlRowIterator<T>`, `SqlVariantRowCursor`
45+
and the `DataMapper` all benefit automatically.
46+
47+
The depth is a connection-level setting (default `Lightweight::PrefetchDepthDefault`, 1000 rows). A
48+
value `<= 1` disables prefetch and restores one `SQLFetch` per row:
49+
50+
```cpp
51+
auto conn = SqlConnection {};
52+
conn.SetDefaultPrefetchDepth(2000); // request up to 2000 rows per SQLFetchScroll round-trip
53+
conn.SetDefaultPrefetchDepth(1); // disable prefetch for this connection
54+
```
55+
56+
Prefetch engages only for result sets whose columns are **fixed-width numeric, temporal, or `GUID`**
57+
types (integers, floating point, `DATE`, `TIMESTAMP`/`DATETIME`, and native `GUID`/`uniqueidentifier`/
58+
`uuid`) on drivers that support native row-array fetching (Microsoft SQL Server, PostgreSQL, SQLite).
59+
Result sets that contain character/text, `NUMERIC`/`DECIMAL`, `TIME`, binary or LOB columns transparently
60+
keep the per-row path: faithful block reconstruction of those is not achievable uniformly across backends
61+
(e.g. Microsoft SQL Server returns narrow text in the client codepage rather than UTF-8, and SQLite's
62+
dynamic typing reports text/`NUMERIC` columns with an unreliable, unenforced size), so the dedicated
63+
single-row binders handle them. Memory is bounded to a few MB per active cursor (the depth is auto-clamped
64+
to that budget), and prefetch reads ahead up to one block, so a loop that stops early over-reads at most
65+
one block.
66+
3567
## Prepared Statements
3668

3769
You can also use prepared statements to execute queries, for example

src/Lightweight/SqlConnectInfo.hpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "Api.hpp"
66

77
#include <chrono>
8+
#include <cstddef>
89
#include <format>
910
#include <map>
1011
#include <string>
@@ -13,6 +14,15 @@
1314
namespace Lightweight
1415
{
1516

17+
/// @brief Default block-prefetch depth for new connections: the number of rows a classic per-row
18+
/// fetch loop requests per @c SQLFetchScroll round-trip on the transparent prefetch path.
19+
///
20+
/// Suffixed (not @c DefaultPrefetchDepth) so it does not collide with the
21+
/// @c SqlConnection::DefaultPrefetchDepth() accessor. A connection's depth can be overridden via
22+
/// @c SqlConnection::SetDefaultPrefetchDepth or @ref SqlConnectionDataSource::defaultPrefetchDepth;
23+
/// a value <= 1 disables prefetch.
24+
constexpr std::size_t PrefetchDepthDefault = 1000;
25+
1626
/// Represents an ODBC connection string.
1727
struct SqlConnectionString
1828
{
@@ -62,6 +72,13 @@ struct [[nodiscard]] SqlConnectionDataSource
6272
std::string password;
6373
/// The connection timeout duration.
6474
std::chrono::seconds timeout { 5 };
75+
/// @brief Default block-prefetch depth applied to statements created on the resulting connection
76+
/// (rows requested per @c SQLFetchScroll round-trip on the transparent per-row fetch path).
77+
///
78+
/// A value <= 1 disables prefetch (every classic loop keeps issuing one @c SQLFetch per row).
79+
/// Defaults to @ref PrefetchDepthDefault. Has effect only on backends whose driver supports
80+
/// native row-array fetching (see @c SqlConnection::SupportsNativeRowArrayFetch).
81+
std::size_t defaultPrefetchDepth = PrefetchDepthDefault;
6582

6683
/// Constructs a SqlConnectionDataSource from the given connection string.
6784
LIGHTWEIGHT_API static SqlConnectionDataSource FromConnectionString(SqlConnectionString const& value);

src/Lightweight/SqlConnection.cpp

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ struct SqlConnection::Data
5454
std::chrono::steady_clock::time_point lastUsed; // Last time the connection was used (mostly interesting for
5555
// idle connections in the connection pool).
5656
SqlConnectionString connectionString;
57-
std::unique_ptr<Async::IAsyncBackend> asyncBackend; // Async execution backend (null until EnableAsync()).
57+
std::unique_ptr<Async::IAsyncBackend> asyncBackend; // Async execution backend (null until EnableAsync()).
58+
std::size_t defaultPrefetchDepth = PrefetchDepthDefault; // Rows requested per SQLFetchScroll on the
59+
// transparent per-row prefetch path (<= 1 disables).
5860
};
5961

6062
SqlConnection::SqlConnection():
@@ -173,6 +175,16 @@ std::chrono::steady_clock::time_point SqlConnection::LastUsed() const noexcept
173175
return m_data->lastUsed;
174176
}
175177

178+
std::size_t SqlConnection::DefaultPrefetchDepth() const noexcept
179+
{
180+
return m_data->defaultPrefetchDepth;
181+
}
182+
183+
void SqlConnection::SetDefaultPrefetchDepth(std::size_t depth) noexcept
184+
{
185+
m_data->defaultPrefetchDepth = depth;
186+
}
187+
176188
void SqlConnection::EnableAsync(Async::IExecutor& dbWorkers, Async::IResumeScheduler& resume)
177189
{
178190
// TODO(async): once the native event backend lands, select it here via a per-connection
@@ -219,6 +231,8 @@ bool SqlConnection::Connect(SqlConnectionDataSource const& info) noexcept
219231
ZoneScopedN("SqlConnection::Connect(DataSource)");
220232
EnsureHandlesAllocated();
221233

234+
m_data->defaultPrefetchDepth = info.defaultPrefetchDepth;
235+
222236
if (m_hDbc)
223237
SQLDisconnect(m_hDbc);
224238

src/Lightweight/SqlConnection.hpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,22 @@ class SqlConnection final
184184
/// @return `true` if narrow character data round-trips byte-exact on that backend.
185185
[[nodiscard]] static bool RoundTripsNarrowTextByteExact(SqlServerType serverType) noexcept;
186186

187+
/// @brief The default block-prefetch depth applied to statements created on this connection.
188+
///
189+
/// Classic per-row fetch loops (`while (cursor.FetchRow()) ...`, @c SqlRowIterator,
190+
/// @c SqlVariantRowCursor) transparently request this many rows per @c SQLFetchScroll round-trip
191+
/// instead of issuing one @c SQLFetch per row. A value <= 1 disables prefetch. Effective only when
192+
/// @ref SupportsNativeRowArrayFetch is true; otherwise statements fall back to per-row fetching.
193+
///
194+
/// @return The configured default prefetch depth (defaults to @ref PrefetchDepthDefault).
195+
[[nodiscard]] LIGHTWEIGHT_API std::size_t DefaultPrefetchDepth() const noexcept;
196+
197+
/// @brief Sets the default block-prefetch depth for statements created on this connection.
198+
///
199+
/// @param depth Rows to request per @c SQLFetchScroll round-trip on the transparent prefetch path;
200+
/// a value <= 1 disables prefetch (restoring one @c SQLFetch per row).
201+
LIGHTWEIGHT_API void SetDefaultPrefetchDepth(std::size_t depth) noexcept;
202+
187203
/// Creates a new query builder for the given table, compatible with the current connection.
188204
///
189205
/// @param table The table to query.

src/Lightweight/SqlLogger.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ class SqlLogger
113113
/// Invoked when a row is fetched.
114114
virtual void OnFetchRow() = 0;
115115

116+
/// @brief Invoked once per block-prefetch round-trip (one @c SQLFetchScroll that materialized a
117+
/// whole row block on the transparent prefetch path), with the number of rows the block yielded.
118+
///
119+
/// Non-pure with an empty default so existing loggers need no change; override to observe how many
120+
/// network round-trips a query actually cost (N rows at depth D collapse to @c ceil(N/D) blocks).
121+
///
122+
/// @param rowsInBlock Number of rows materialized by this block fetch (0 at end of result set).
123+
virtual void OnFetchBlock(std::size_t /*rowsInBlock*/) {}
124+
116125
/// Invoked when fetching is done.
117126
virtual void OnFetchEnd() = 0;
118127

0 commit comments

Comments
 (0)