Skip to content

odbc_copy: bind once per stable single-row shape (re: #163 item 2) - #168

Merged
staticlibs merged 5 commits into
duckdb:mainfrom
fdcastel:bind-once-execute-many
Apr 28, 2026
Merged

odbc_copy: bind once per stable single-row shape (re: #163 item 2)#168
staticlibs merged 5 commits into
duckdb:mainfrom
fdcastel:bind-once-execute-many

Conversation

@fdcastel

Copy link
Copy Markdown
Contributor

Follow-up to #164. This is item 2 from #163bind once / execute many — which I held out of #164 because my first attempt broke test/sql/duckdb/duckdb_copy.test:46 with a SQLSTATE HY010 "Function sequence error" at rollback time. This PR re-lands the optimisation with that issue fixed, and touches up a related diagnostics gap that had been hiding the root cause.

⚠️ Same disclosure as #164 — AI-assisted (Claude Code / Opus 4.7), happy to iterate on anything.

The change

odbc_copy's per-row execute loop called Params::SetExpectedTypes + Params::BindToOdbc for every row, which collapsed to SQLFreeStmt(SQL_RESET_PARAMS) + N × SQLBindParameter on every SQLExecute. That is the shape odbc_copy_from used whenever batch_size == 1 (and the tail of any batched insert when dest_query_single is specified). It is legal ODBC, but it is the least efficient of the three common execute shapes and — as the Firebird silent-corruption bug in #161 showed — it is also the shape most likely to trip driver bugs that rely on sqlvar state being stable across executes.

Params::BindToOdbcIfShapeChanged now caches the (type_id, expected_type, is_null) per slot in a BindCache held on LocalInitData. When the next row has the same shape and every slot is fixed-width, the rebind is skipped entirely — the driver reads the new values from the already-bound addresses on the next SQLExecute. The first row still pays the full bind; subsequent rows just update the values.

Two supporting fixes land with this:

  • Fix 1: close the cursor between reused executes. When the bindings are reused we now call SQLFreeStmt(SQL_CLOSE) before the next SQLExecute. Without it, the DuckDB ODBC driver leaves the statement in a state that makes a later SQLEndTran fail with SQLSTATE HY010 "Function sequence error". Rebinding implicitly cleared that state; reusing bindings does not. This matches what the existing reset_stmt_before_execute quirk does for ClickHouse unconditionally — this PR just applies the same defensive close when (and only when) we've just reused bindings on the previous SQLExecute.
  • Fix 2: preserve the primary exception in Copy()'s catch block. Previously a Rollback-time failure (see HY010 above) could silently replace the underlying CopyInTransaction error. The catch now keeps the primary message and appends any Rollback / SetTransactionMode failure to it.

Design notes

  • LocalInitData ownership of single_row_buf. Each ScannerValue slot needs a stable address across CopyInTransaction calls for the cached SQLBindParameter pointer to stay valid. Moving the vector onto LocalInitData is the minimum change that achieves this; I also guard against a column-count transition (which resets both the buffer and the cache).
  • Fixed-width slots only. TYPE_DECIMAL_AS_CHARS, DUCKDB_TYPE_VARCHAR (WideString), BLOB, UUID etc. all hold their buffer inside a std::vector, which can reallocate when the value changes. The bind cache declines to reuse those and falls back to a full rebind per row — the existing behaviour — so no data-shape regression.
  • Shape invalidation points. The cache is reset whenever the prepared statement changes (tail-insert re-prepare) or when the batch-mode path ran (batch BindToOdbc wipes per-param state via SQL_RESET_PARAMS). This keeps the mixed dest_query / dest_query_single flow — which was the one that exposed the original HY010 — correct.

Coverage

New sqllogic block in test/sql/duckdb/duckdb_copy.test uses batch_size=1 with 50 INTEGER rows and checks min / max / sum on the destination — a single assertion that catches both row loss and value corruption. The existing batch tail query single test (duckdb_copy.test:46) already exercises the mixed batch → single-row transition and still passes — it is the test that caught the original HY010.

I did not replicate the bind-once test across the other *_copy.test files because the optimisation is driver-independent: if it works on one driver it works on all of them. Happy to fan it out per-driver (the way #164 ended up) if you'd prefer.

CI

All driver jobs pass on the branch with the exception of the same two pre-existing, unrelated failures called out on #164:

  • MSSQL Linux: libmsodbcsql-18.6.so.1.1 vs .2.1 symlink mismatch at setup time.
  • Firebird Windows: test/sql/firebird/14_time_with_time_zone.test:30 DST drift.

Both fail on main today and neither is touched by this PR. Happy to file separate issues if that helps.

Suggested merge order

This PR is stacked on top of #164 in spirit (the bug-fix rationale in #164 motivates why the single-row rebind pattern is worth avoiding) but not mechanically — this branch is based on main, so it can land before or after #164 without conflicts. I'll be happy to rebase whichever way you prefer.

@fdcastel

Copy link
Copy Markdown
Contributor Author

Self-review tightened a few rough edges in a5f8ade:

  • BindSlotShape::is_null removed. It was always (type_id == DUCKDB_TYPE_SQLNULL), so the equality check was redundant.
  • Params::TYPE_SQL_GUID added to IsFixedWidthShape. SQLGUID is a raw POD in the ScannerValue union (address-stable), so GUID-keyed single-row inserts now also hit the bind-once fast path.
  • Helper renamed and return-flipped: BindToOdbcIfShapeChangedBindToOdbcIfShapeUnchanged returning bool reused (true = cache hit). Reads better at the call site than the previous did_bind = ...; reused_bindings = !did_bind inversion.
  • Header comment updated to record that integer / float parameters whose target column is CHAR/VARCHAR/WCHAR are coalesced into TYPE_DECIMAL_AS_CHARS by SetExpectedTypes and therefore land on the rebind-per-row fallback (relevant if #164 lands first).
  • Bind-once smoke test bumped 50 → 500 rows so the reuse path is exercised across multiple internal data chunks instead of a single one.

CI: same 12 / 2 split as before — same two pre-existing failures (MSSQL Linux libmsodbcsql version mismatch, Firebird Windows DST drift) noted in the PR description; both still fail on main and neither is touched by this PR.

Marking ready for review.

@fdcastel
fdcastel marked this pull request as ready for review April 25, 2026 18:26

@staticlibs staticlibs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! I think it is a very nice optimization and is doing exactly what the scanner intends to do well - using ODBC drivers as effective as possible. Just if it is possible to use it with batches too and to only re-bind non-fixed buffers (where possible) - I think it can be even more effective.

Comment thread src/params.cpp Outdated
Comment thread src/functions/odbc_copy.cpp Outdated
Comment thread src/functions/odbc_copy.cpp
Comment thread src/params.cpp Outdated
@fdcastel
fdcastel marked this pull request as draft April 27, 2026 00:43
@fdcastel

Copy link
Copy Markdown
Contributor Author

I Will work on all this first thing tomorrow 👍🏻

fdcastel added a commit to fdcastel/odbc-scanner that referenced this pull request Apr 27, 2026
Addresses the four review items on PR duckdb#168:

1. Per-slot rebind (was all-or-nothing). `Params::BindToOdbcWithCache` now
   issues SQLBindParameter only for slots whose shape changed since the cached
   bind, or whose backing buffer is variable-length (DecimalChars / WideString
   / ScannerBlob / ScannerUuid — the std::vector inside can move on
   reassignment). Fixed-width siblings on the same row keep their existing
   binding. A row of (INT, VARCHAR, INT) now rebinds only the VARCHAR slot
   instead of all three.

2. Caching extended to the batch path. `flat_batch` is hoisted to
   `LocalInitData` (matching `single_row_buf`) so its slot addresses are
   stable across CopyInTransaction calls. The batch branch now goes through
   BindToOdbcWithCache against `batch_bind_cache`, hitting the same per-slot
   reuse logic. Both caches are reset together at every prepared-statement
   transition.

3. SQLFreeStmt(SQL_CLOSE) is now called unconditionally before each
   SQLExecute. The previous gating on `reset_stmt_before_execute || reused`
   only existed because rebinding implicitly cleared cursor state — with the
   per-slot path that is no longer guaranteed, and the spec makes SQL_CLOSE
   on a statement with no open cursor a no-op, so always closing simplifies
   the loop and removes the `reused_bindings` flag.

4. `BindSlotShape` got a (type_id, expected_type) constructor; the
   field-by-field assignment in the bind loop is gone. `BindCache::initialized`
   is also dropped — `cache.shape.size() != params.size()` is the new
   "first call" signal and the resize-and-clear handles it.
@fdcastel

Copy link
Copy Markdown
Contributor Author

Done!

#1 (BindSlotShape constructor): added (type_id, expected_type) constructor; the field-by-field assignment in the bind loop is gone.

#2 (caching for batch): done. flat_batch is now hoisted to LocalInitData (matching the existing pattern for single_row_buf) so its slot addresses are stable across CopyInTransaction lls. The batch branch now goes through the same BindToOdbcWithCache against a separate batch_bind_cache, hitting the same per-slot reuse logic. Both caches are reset together at every QLPrepare` site: the tail re-prepare in the inner loop and the post-loop re-prepare for the next chunk.

#3 (unconditional SQL_CLOSE): done. The previous gating on reset_stmt_before_execute || reused_bindings only existed because rebinding implicitly cleared cursor state, and with the r-slot path that's no longer guaranteed (we often bind one slot, the others stay). Per the spec, SQL_CLOSE on a statement with no open cursor is a no-op, so always-close is safe and removes e reused_bindings flag entirely. The quirk still applies in odbc_query.cpp where the driver requirement is different.

#4 (per-slot rebind): this is the big one and the right call. BindToOdbcWithCache now walks the slots and rebinds only when the slot's (type_id, expected_type) differs from the cached lue, or when the slot's type is variable-width (DecimalChars / WideString / ScannerBlob / ScannerUuid: the inner std::vector may move on value reassignment, so the previously bound pointer uld dangle). Fixed-width siblings on the same row keep their existing SQLBindParameter: the value at that address has been updated in-place by the move-assign and the next SQLExecute reads from the same address. A (INT, VARCHAR, INT) row now rebinds only the VARCHAR slot.
The cache also no longer issues SQLFreeStmt(SQL_RESET_PARAMS). Per the spec, calling SQLBindParameter on a previously-bound index overwrites the binding, so per-slot writes are sufficient. The "bind everything" behaviour still happens organically on the first call (size mismatch triggers a resize-and-clear; every slot's cached BindSlotShape{} differs from the real shape).

The per-row execute path in odbc_copy called Params::SetExpectedTypes +
Params::BindToOdbc for every row, which collapsed to a
SQLFreeStmt(SQL_RESET_PARAMS) + N × SQLBindParameter cycle on every
SQLExecute. That is legal ODBC, but it is the least efficient of the
three common execute shapes and — as the Firebird silent-corruption bug
in duckdb#161 showed — it is also the shape most likely to
trip driver bugs that rely on sqlvar state being stable across executes.

This change caches the bound-parameter shape in LocalInitData and skips
the rebind when the next row has the same (type_id, expected_type,
is_null) per slot and every slot is fixed-width. The first row still
pays the full bind; subsequent rows just update the values at the
already-bound addresses. single_row_buf now lives on LocalInitData so
the ScannerValue slot addresses stay stable across CopyInTransaction
calls — that is what makes reusing the previous SQLBindParameter
pointer safe.

Variable-length slots (TYPE_DECIMAL_AS_CHARS, VARCHAR via WideString,
BLOB, UUID, ...) still force a rebind because their backing std::vectors
can reallocate when the value changes.

Two additional fixes land with this:

1. When the bindings are reused we call SQLFreeStmt(SQL_CLOSE) before
   the next SQLExecute. Without it, the DuckDB ODBC driver leaves the
   statement in a state that makes a later SQLEndTran fail with
   SQLSTATE HY010 "Function sequence error"; rebinding implicitly
   cleared that state, reusing bindings does not. This matches what
   the existing reset_stmt_before_execute quirk does unconditionally.

2. The catch block in Copy() now preserves the primary CopyInTransaction
   exception even when Rollback or SetTransactionMode throws on top.
   Previously a Rollback-time failure (see the HY010 above) could
   silently replace the underlying error, which made the original bug
   much harder to diagnose.

Coverage: new sqllogic block in test/sql/duckdb/duckdb_copy.test uses
batch_size=1 with 50 integer rows and checks min/max/sum on the
destination — a single assertion that catches both row loss and value
corruption. The existing batch_tail test (duckdb_copy.test:46) already
exercises the mixed batch→single-row transition; it still passes.
- BindSlotShape: drop the redundant `is_null` field — its value was always
  `type_id == DUCKDB_TYPE_SQLNULL`, so it added nothing to the equality check
  and one more thing to keep in sync.
- IsFixedWidthShape: include `Params::TYPE_SQL_GUID` (raw `SQLGUID` in the
  union, address-stable across move-assignment) so GUID-keyed single-row
  inserts also hit the bind-once fast path.
- Rename the helper to `BindToOdbcIfShapeUnchanged` and flip the return
  semantics to `bool reused` (true = cache hit, false = full rebind). This
  removes the awkward `bool did_bind = ...; reused_bindings = !did_bind;`
  inversion at the call site and matches the local `reused_bindings` flag.
- Header comment: note that integer / float parameters whose target column is
  CHAR/VARCHAR/WCHAR are coalesced to TYPE_DECIMAL_AS_CHARS by
  SetExpectedTypes, so they intentionally stay on the rebind-per-row path.
- duckdb_copy.test: bump the bind-once smoke test from 50 to 500 rows so the
  reuse path is exercised many times across multiple internal data chunks.
Addresses the four review items on PR duckdb#168:

1. Per-slot rebind (was all-or-nothing). `Params::BindToOdbcWithCache` now
   issues SQLBindParameter only for slots whose shape changed since the cached
   bind, or whose backing buffer is variable-length (DecimalChars / WideString
   / ScannerBlob / ScannerUuid — the std::vector inside can move on
   reassignment). Fixed-width siblings on the same row keep their existing
   binding. A row of (INT, VARCHAR, INT) now rebinds only the VARCHAR slot
   instead of all three.

2. Caching extended to the batch path. `flat_batch` is hoisted to
   `LocalInitData` (matching `single_row_buf`) so its slot addresses are
   stable across CopyInTransaction calls. The batch branch now goes through
   BindToOdbcWithCache against `batch_bind_cache`, hitting the same per-slot
   reuse logic. Both caches are reset together at every prepared-statement
   transition.

3. SQLFreeStmt(SQL_CLOSE) is now called unconditionally before each
   SQLExecute. The previous gating on `reset_stmt_before_execute || reused`
   only existed because rebinding implicitly cleared cursor state — with the
   per-slot path that is no longer guaranteed, and the spec makes SQL_CLOSE
   on a statement with no open cursor a no-op, so always closing simplifies
   the loop and removes the `reused_bindings` flag.

4. `BindSlotShape` got a (type_id, expected_type) constructor; the
   field-by-field assignment in the bind loop is gone. `BindCache::initialized`
   is also dropped — `cache.shape.size() != params.size()` is the new
   "first call" signal and the resize-and-clear handles it.
@fdcastel
fdcastel force-pushed the bind-once-execute-many branch from 9f06a3b to d6762bf Compare April 27, 2026 15:48
Previous commit assumed SQLPrepare cleared parameter bindings. Per the ODBC
spec it does not — only SQLFreeStmt(SQL_RESET_PARAMS) (or rebinding the same
index) releases them. With the per-slot cache, transitioning from a 16-row
batch statement to a 1-row tail statement on the same hstmt left 15 stale
bindings pointing at flat_batch addresses that the new statement has no
parameter indices for, segfaulting at least the DuckDB ODBC driver in the
"batch tail query single" duckdb_copy.test case.

BindToOdbcWithCache now issues SQL_RESET_PARAMS itself whenever the cache is
empty (size mismatch with params.size()) — first call after Reset() or a
column-count change. Steady-state per-slot rebinds still skip it. Comments
on BindCache and BindToOdbcWithCache updated to reflect actual ODBC binding
lifetime.
@fdcastel

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (b887398). Picks up #164, #169 and the Firebird timezone / MSSQL libmsodbcsql fixes — only one real conflict (test/sql/duckdb/duckdb_copy.test, both branches added blocks at the tail; resolved by keeping both side-by-side).

The rebase also flushed out a latent bug in the per-slot cache: I had assumed SQLPrepare clears parameter bindings, but per the ODBC spec only SQLFreeStmt(SQL_RESET_PARAMS) (or rebinding the same index) does. With the multi-statement transition in duckdb_copy.test:46 (16-row batch → 1-row tail on the same hstmt), the 15 stale bindings from the batch statement segfaulted the DuckDB ODBC driver. BindToOdbcWithCache now issues SQL_RESET_PARAMS itself whenever the cache is empty (first call after Reset()); steady-state per-slot rebinds still skip it. Comments updated to match real ODBC binding lifetime.

CI green on all jobs (Snowflake skipped as before).

@fdcastel
fdcastel marked this pull request as ready for review April 27, 2026 16:08
@staticlibs
staticlibs merged commit 17c6621 into duckdb:main Apr 28, 2026
15 checks passed
@staticlibs

Copy link
Copy Markdown
Member

Thanks for the update! Looks good to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants