Skip to content

Fix/undrained probe cursor rollback - #810

Merged
axellpadilla merged 4 commits into
dbt-msft:masterfrom
Benjamin-Knight:fix/undrained-probe-cursor-rollback
Aug 6, 2026
Merged

Fix/undrained probe cursor rollback#810
axellpadilla merged 4 commits into
dbt-msft:masterfrom
Benjamin-Knight:fix/undrained-probe-cursor-rollback

Conversation

@Benjamin-Knight

Copy link
Copy Markdown
Collaborator

Fixes #809.

get_column_schema_from_query executed a query to read its column shape, then returned without fetching the rows or closing the cursor. Closing that cursor while the server was still streaming made the driver cancel the request, and SQL Server answers that cancel by rolling back the open transaction, because every connection runs SET XACT_ABORT ON (#718). Nothing raised, so snapshots failed a statement later with Invalid object name '..._dbt_tmp' and contract-enforced models silently lost their in-transaction pre_hook writes.

Only CTE-headed queries were exposed — anything else is wrapped as select * from (...) where 1 = 0 and returns no rows.

Changes

fix: drain and close probe cursors. Adds _discard_pending_results next to the existing _try_drain_nextset, applied to get_column_schema_from_query and to the row-count probe in expand_column_types, which leaked its cursor on every call.

perf: describe CTE-headed probes instead of executing them. A CTE-headed query can't be wrapped in where 1 = 0, so it ran in full just to have its column names read — a snapshot executed its whole staging query twice per build. sp_describe_first_result_set reports the shape without scanning, which is already how sqlserver__get_columns_in_query handles CTEs (#698). Reported names and types are unchanged: the describe path is mapped back onto the coarser names cursor.description produces, and falls back to executing whenever it can't guarantee that (the adbc backend, a type outside the mapping, or a query the procedure declines to describe, such as one reading a #temp table). Every gap degrades to slower, never to different.

Tests

  • test_transactions.py — snapshot fixtures sized past the timing boundary, covering both timestamp and check strategies. The old seed-sized fixtures were why this went unnoticed.
  • test_constraints.py — the previously untested contract + CTE combination.
  • Unit tests for get_column_schema_from_query and expand_column_types, plus TestCteProbeAvoidsExecution, which pins that a CTE-headed probe no longer executes its query and that the describe and execute paths agree across 22 types.

Benjamin-Knight and others added 4 commits August 5, 2026 17:37
…he build

get_column_schema_from_query wants a query's column shape and gets it by
executing the query, reading cursor.description, then returning without
fetching the rows or closing the cursor. Closing a cursor whose result set
the server is still producing makes the driver cancel the request, and the
cancel arrives as an attention. Every connection runs SET XACT_ABORT ON
(dbt-msft#718), under which SQL Server answers an attention by rolling back the open
transaction.

None of that raises, so the damage surfaces later and elsewhere: a snapshot
loses the staging table it built moments earlier in the same transaction and
fails with `Invalid object name '..._dbt_tmp'`, and a contract-enforced model
silently loses its in-transaction pre_hook writes.

Only queries opening with a CTE were exposed. Anything else is wrapped as
`select * from (...) where 1 = 0` by sqlserver__get_empty_subquery_sql and
returns nothing, which is why snapshot staging queries (`with snapshot_query
as ...`, both check and timestamp strategies) and CTE-headed contract models
were the ones that broke, while everything else stayed quiet.

The size of the result set is not the trigger; the timing is. The cancel only
raises an attention while the request is still in flight, so a few hundred
rows at zero client delay is enough, while ~10ms of client work before the
close makes even 20MB safe. Existing coverage exercised these paths with
tens of narrow rows and so never tripped it -- the snapshot fixtures in
test_transactions.py are now sized past the boundary, and the previously
untested contract + CTE combination is covered too.

_discard_pending_results lives next to _try_drain_nextset rather than
introducing a second draining idiom, and is applied to _get_row_count as
well, which leaked its cursor on every expand_column_types call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sqlserver__get_empty_subquery_sql neuters a probe query as
`select * from (...) where 1 = 0`, but a query opening with a CTE cannot be
wrapped that way and is passed through untouched, so it ran in full purely to
have its column names read. A snapshot executed its whole staging query once
for the probe and again to build the staging table; a contract-enforced model
ran twice per build. Draining that result set (previous commit) makes it safe
but not cheap, and the cost tracks data volume.

sp_describe_first_result_set compiles the query and reports its shape without
scanning, which is all this method ever wanted. It is already how
sqlserver__get_columns_in_query handles CTEs (dbt-msft#698). The macro cannot be the
fix point: its output is also embedded in a CREATE VIEW body by dbt-core's
unit-test materialization, where only a bare SELECT is legal, and reading
cursor.description of an `exec sp_describe...` batch would describe the
procedure's own result shape rather than the query's.

Reported names and types are unchanged. Reading cursor.description reports
Python classes, which collapse whole type families -- every integer width
arrives as int, every string type as varchar -- so the describe path is
mapped back onto exactly those names and fed through Column.create as before,
leaving the dbt_sqlserver_use_native_string_types flag working without the
mapping knowing it exists.

Rather than guess, the describe path declines and lets the caller execute
whenever it cannot guarantee agreement: the adbc backend (whose type names
derive from Arrow codes and would disagree), a type outside the mapping, a
query sp_describe_first_result_set refuses to describe such as one reading a
#temp table, or a describe that returns nothing. Every gap degrades to
slower, never to silently different.

TestCteProbeAvoidsExecution pins both halves: that a CTE-headed probe no
longer executes its query, and that the two branches agree across 22 types.
The second is a guard rather than a symptom and passes either way -- it is
what makes the mapping safe to maintain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the column probe reported a type the driver would not have.

The describe path (previous commit) maps sp_describe_first_result_set's
system_type_name back onto the name reading cursor.description yields, but
that name is the *driver's* choice of Python class, not SQL Server's, and the
two ODBC backends do not agree on three of them. mssql-python decodes
uniqueidentifier as uuid.UUID, datetimeoffset as datetime and sql_variant as
str where pyodbc hands back str, bytearray and bytearray, so one fixed table
could not serve both: a CTE-headed model with a uniqueidentifier column
described as varchar and executed as uniqueidentifier, and its contract
comparison shifted under it. The lookup now follows the backend in use.

Two entries resist tabulation and say so rather than guess. datetimeoffset on
pyodbc depends on whether add_query has registered the -155 output converter
yet -- bytearray on a connection's first query, str on every one after -- so
it is absent and the query is executed, which agrees with itself by
construction. uniqueidentifier on pyodbc depends on the process-global
pyodbc.native_uuid flag, which is readable, so it is read at probe time.

The adbc backend, which skips the describe path entirely, had gaps of its own
in the Arrow maps. tinyint is SQL Server's only unsigned integer and arrives
as uint8, uniqueidentifier as the arrow.uuid extension type; neither was
mapped, so the probe raised "Unsupported SQL Server type code" and the model
failed outright. real arrives as Arrow "float", which is the 4-byte float --
it was mapped to SQL Server's 8-byte float, so a contract declaring real
compared against FLOAT and failed.

The integer-keyed map is keyed on Arrow type ids but held values matching no
Arrow enum: id 3 (int8) said varchar, 7 (int32) said float, 12 (double) said
decimal. Nothing reaches it today -- ADBC reports pyarrow DataType objects,
which the name-keyed map handles -- so it was silently wrong rather than
broken. Both maps are now derived from the same set and pinned against
pyarrow itself instead of transcribed ids.

Types the ADBC driver cannot represent at all (datetimeoffset, and
sql_variant, which arrives as an Arrow struct) fail before the probe is
reached, so the functional matrix covers those two on the ODBC backends only.
# Conflicts:
#	CHANGELOG.md
#	dbt/adapters/sqlserver/sqlserver_adapter.py
@Benjamin-Knight

Copy link
Copy Markdown
Collaborator Author

@axellpadilla In fixing the bug I ended up coming across more bugs with the type mappings when I tried to unroll the underlying cause that triggered the bug, which was a performance issue. As we maintain 3 drivers now the type mapping code ended up being quite complex.

@axellpadilla
axellpadilla merged commit 10ec073 into dbt-msft:master Aug 6, 2026
19 checks passed
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.

Snapshots fail with Invalid object name '..._dbt_tmp' on their second run; contract-enforced models silently lose pre_hook writes

2 participants