Skip to content

fix: don't let a spurious mssql-python driver error during trailing drain fail a successful batch - #815

Closed
axellpadilla wants to merge 1 commit into
masterfrom
fix/814-mssql-python-trailing-drain-transaction-error
Closed

fix: don't let a spurious mssql-python driver error during trailing drain fail a successful batch#815
axellpadilla wants to merge 1 commit into
masterfrom
fix/814-mssql-python-trailing-drain-transaction-error

Conversation

@axellpadilla

@axellpadilla axellpadilla commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Report

Environment: dbt-core 1.12.0, dbt-sqlserver 1.12.0rc2, mssql-python 1.12.0,
backend: mssql-python, threads: 4, dbt_sqlserver_use_dbt_transactions: false

New transaction is not allowed because there are other threads running in the session.

An incremental delete+insert model failed even though the SQL itself succeeded. The failure traced to execute()'s trailing nextset() drain — which runs after execute() had already read the batch's response (cursor.rowcount). Forcing --threads 1 avoided it.

Root cause

microsoft/mssql-python#229 (open upstream): a multi-statement batch that turns SET NOCOUNT back off before its last statement — which sqlserver__get_delete_insert_merge_sql does deliberately, so that statement's rowcount is reported — leaves a DONE_IN_PROC token the driver's nextset() handling does not always walk cleanly. Could not force the exact driver-side race locally; reproduced deterministically instead with a fake cursor.

Fix

_drain_trailing_results swallows only that exact error (duck-typed on exception class + message, same pattern _try_drain_nextset already uses for ADBC's NotSupportedError). This is a hard requirement, not a stylistic choice: nextset() can also carry a genuine, deferred error from a later statement in the same batch — SQL Server's deferred name resolution lets CREATE VIEW ... AS SELECT bad_column FROM t succeed at create time, so a later statement querying that view (e.g. sqlserver__create_table_as's SELECT * INTO) only fails once nextset() walks to it, not on the initial execute(). An earlier version of this fix broadly swallowed every nextset() exception and was caught by tests/functional/adapter/dbt/test_concurrency.py::TestConcurrency::test_concurrency, which builds a model selecting a nonexistent column and asserts the build fails — it silently reported success instead.

Testing

  • test_execute_survives_a_spurious_mssql_python_error_during_trailing_drain — the known-spurious message no longer fails execute().
  • test_execute_still_raises_a_genuine_deferred_error_during_trailing_drain — a different error still raises (regression guard for the mistake above).
  • Full unit suite passes (501 passed).
  • test_concurrency.py, test_transactions.py, test_concurrent_incremental.py, test_materialize_change.py, test_constraints.py all pass locally.

…l-python trailing-drain error

Reported: on the mssql-python backend, with dbt_sqlserver_use_dbt_transactions:
false and threads > 1, a successful multi-statement batch (e.g. the
delete+insert incremental strategy, or a table_refresh_method: dml swap)
could fail the run with:

  Driver Error: Syntax error or access violation; DDBC Error:
  [Microsoft][SQL Server]New transaction is not allowed because there are
  other threads running in the session.

The failure traced to execute()'s trailing nextset() drain, which runs
after get_response(cursor) has already read the batch's own response. This
matches an open upstream mssql-python defect (microsoft/mssql-python#229):
a multi-statement batch that turns SET NOCOUNT back off before its last
statement -- which sqlserver__get_delete_insert_merge_sql does deliberately,
so that statement's rowcount is reported -- leaves a DONE_IN_PROC token the
driver's nextset() handling does not always walk cleanly.

_drain_trailing_results swallows only that exact error (duck-typed on
exception class and message, the same way _try_drain_nextset already
duck-types ADBC's NotSupportedError). nextset() can also carry a genuine,
deferred error from a later statement in the same batch -- SQL Server's
deferred name resolution lets `CREATE VIEW ... AS SELECT bad_column FROM t`
succeed at create time, so a later statement querying that view (e.g.
sqlserver__create_table_as's SELECT * INTO) only fails once nextset() walks
to it -- so every other error still fails the build exactly as before.

Verified with a fake mssql-python cursor: the known-spurious message no
longer fails execute(), while a different error (mirroring
test_concurrency.py's deliberately-broken model) still raises.
@axellpadilla
axellpadilla force-pushed the fix/814-mssql-python-trailing-drain-transaction-error branch from e02f15f to ee30264 Compare August 6, 2026 22:13
@axellpadilla

Copy link
Copy Markdown
Collaborator Author

Closing — investigated further and this was the wrong fix.

The error text ("New transaction is not allowed because there are other threads running in the session") is SQL Server engine error 3988 (XACT_UNSUPPORT_PARALLEL_TRAN2). Its only Microsoft-documented cause is a distributed query joining multiple tables from one remote (linked-server) data source while XACT_ABORT is ON — which this adapter always sets (#718). The reporter's actual failing model joins six SAP tables through a single linked server ({{ var('P_SAPPRD_LS') }}.{{ source(...) }}), which matches that trigger exactly.

My original root-cause citation here (microsoft/mssql-python#229) was wrong — that PR is about local temp-table visibility across separate execute() calls and never mentions this error. I couldn't reproduce the real mechanism locally either (tried a loopback linked server, matching multi-statement batch shapes, and an 8-thread concurrent stress test — none tripped it, most likely because a loopback server and toy tables don't exercise the same distributed-transaction-promotion path a real remote server does), but treating this exact error as always-benign and swallowing it in execute() risks masking a genuine, actionable failure the same way for anyone whose model legitimately fails a linked-server query for this reason.

The real guidance for this case is already in #790: use sqlserver__openquery instead of raw 4-part linked-server names, which avoids error 3988 entirely rather than working around it after the fact. Following up there.

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.

New transaction is not allowed because there are other threads running in the session

1 participant