fix: concurrent-incremental catalog deadlock, and (nolock) on the remaining catalog lookups - #792
Open
axellpadilla wants to merge 4 commits into
Open
fix: concurrent-incremental catalog deadlock, and (nolock) on the remaining catalog lookups#792axellpadilla wants to merge 4 commits into
axellpadilla wants to merge 4 commits into
Conversation
Two concurrent incremental models at threads=2 with
dbt_sqlserver_use_dbt_transactions on deadlocked (error 1205) on an X/S
inversion over sys.sysschobjs (index nc1) - user-DB system catalog rows,
not user data. The temp-relation build (CREATE OR ALTER VIEW __dbt_tmp_vw
-> SELECT * INTO __dbt_tmp -> DROP VIEW) ran inside the materialization's
ambient transaction, so its catalog X keylocks were held until commit
while a second worker's name resolution asked for S on them.
Nothing in the temp build needs that transaction: the relation is
throwaway. The transaction was opened accidentally, by a read-only
catalog lookup - find_references in sqlserver__get_drop_sql - which used
statement()'s default auto_begin=True. dbt-adapters' own
relations/drop.sql already wraps the surrounding drop in auto_begin=False;
this override just missed it.
- relation.sql: find_references runs with auto_begin=false, so the temp
build autocommits statement by statement and releases catalog locks as
it goes.
- incremental.sql: the fresh-create / full-refresh statement('main')
batch, which is also create_table_as catalog DDL, runs with
auto_begin=False; commit_if_open/begin_if_closed after it reopen the
ambient transaction for the swap and the tail. The incremental branch
keeps the default auto_begin so its strategy DML stays atomic through
adapter.commit().
- create.sql: OBJECT_ID drop guard on the SELECT * INTO temp path, scoped
to temp/intermediate relations, so a crash-left __dbt_tmp is recovered
next run instead of raising Msg 2714. Fresh creates of a real target
still surface 2714.
- hooks.sql: the COMMIT emitted before the first transaction=false hook is
now guarded with @@TRANCOUNT > 0. It was only ever safe because
something had accidentally auto-begun a transaction; with that removed
it raised Msg 3902.
Pre-hook rollback is preserved: nothing is committed early, we only
decline to open a transaction for a read. A transaction=true pre-hook
still opens one, and the temp build correctly joins it.
Verified on the catalog-scanning repro harness (mssql-python 1.10.0,
threads=2): 9 deadlocks/12 runs before, 0/40 after. Regression gate green
(test_transactions, test_incremental, test_temp_relation_cleanup,
test_xact_abort, test_concurrency, test_concurrent_incremental).
Refs: docs/plan/20260801-dbt-sqlserver-concurrent-incremental-deadlock
SQL Server takes transaction-scoped X locks on the system catalog for every
CREATE/ALTER/DROP. A catalog read from any other session under the default
READ COMMITTED has to wait for those to be released, and the default
LOCK_TIMEOUT is -1, so it waits forever. An un-hinted catalog query in the
adapter is therefore a hard dependency on every other writer in the database -
a second dbt project, an ETL tool, a DBA mid-deployment - and dbt just appears
to hang with no error and no visible failure.
information_schema_hints() (with (nolock)) is the existing answer to this and
is already applied at ~60 sites, including list_relations_without_caching.
These were missed:
metadata.sql get_relation_last_modified sys.objects / sys.schemas
indexes.sql drop_fk_constraints sys.foreign_keys / sys.tables
indexes.sql drop_pk_constraints sys.indexes / sys.tables
indexes.sql drop_fk_references sys.foreign_key_columns + joins
indexes.sql list_nonclustered_rowstore_indexes (both branches)
Measured against a second session holding 40 uncommitted CREATE statements in
the same schema, with LOCK_TIMEOUT 5000 on the reader so blocking is
observable rather than indefinite:
get_relation_last_modified no hint: blocked 5.00s with (nolock): 0.00s
drop_fk_references no hint: blocked 5.02s with (nolock): 0.01s
drop_pk_constraints no hint: blocked 5.01s with (nolock): 0.01s
list_relations (hinted today) no hint: blocked 5.00s with (nolock): 0.00s
The last row is the control: it confirms the existing hints are load-bearing,
not decorative.
with (nolock) is preferred over SET TRANSACTION ISOLATION LEVEL READ
UNCOMMITTED because it is scoped per table reference. It cannot leak a dirty
read onto user data the way a session-level isolation change would.
Deliberately NOT hinted: catalog reads that act as correctness guards rather
than discovery - the schema-exists check before CREATE SCHEMA (schema.sql),
the principal-exists check in apply_grants.sql, and the sys.extended_properties
full-refresh marker (relations/table/create.sql). A dirty read there would
weaken the guard, not just widen discovery. None of them blocked in the
measurements above.
Regression test: tests/functional/adapter/mssql/test_catalog_nolock.py. It
opens a second dbt connection on its own thread, holds 40 uncommitted CREATE
TABLE statements in the test schema, and calls get_relation_last_modified with
LOCK_TIMEOUT 5000. Without the hint it fails with Msg 1222 "Lock request time
out period exceeded"; with it, it returns in milliseconds. Verified to fail
when only the metadata.sql hunk is reverted.
Backport notes (cherry-pick checked against v1.10.1 and v1.9.2):
- metadata.sql and the new test apply cleanly to both. The macro body is
byte-identical there apart from apply_label() vs get_query_options().
- indexes.sql conflicts on two hunks (drop_fk_constraints,
drop_pk_constraints) because master has since hardened those SELECTs with
QUOTENAME and added a schema filter. The conflict is adjacent, unrelated
content: resolve by keeping the older branch's select line and taking only
the {{ information_schema_hints() }} tokens on the from/join lines. The
other three indexes.sql hunks apply cleanly.
- information_schema_hints() itself has existed since 1.4, so no new
machinery is needed on any target branch.
Split out from the previous commit so that one stays as backportable as possible: this hunk's context lines sit next to the auto_begin=false change from the deadlock fix, and CHANGELOG.md conflicts on any backport by construction. find_references is the same class of query as the ones hinted in the previous commit - read-only discovery over sys.objects / sys.schemas, whose result only drives DROP VIEW IF EXISTS statements, so a dirty read costs nothing. Unlike those, it did NOT block in measurement: its plan seeks sys.sql_expression_dependencies first and reaches sys.objects by object_id on the clustered index, missing the contended name-index range. That is a property of one plan on one dataset, not a guarantee, so hint it for consistency across the discovery class rather than leaving it as the odd one out. CHANGELOG: the catalog hints are filed under v1.11.0, not v1.12.0. v1.11.0 final has not been released (only v1.11.0rc1 is published; the v1.11.0 tag has no release behind it), the hints are independent of the transaction work, and both hint commits cherry-pick clean onto the v1.11.0 tag - verified. The deadlock fix stays under v1.12.0: it is only reachable with dbt_sqlserver_use_dbt_transactions on, which defaults to False in 1.11, and it depends on commit_if_open/begin_if_closed, which 1.11 does not have.
All three ship in v1.11.0 via backport/1.11-transaction-fixes (based on release/v1.11, which is exactly the v1.11.0 tag): the ONLINE/RESUMABLE index and full-refresh-marker fix, the concurrent-incremental deadlock, and the catalog hints. v1.11.0 final has not been released yet - only v1.11.0rc1 is published - so there is no need for a v1.11.1 section. Filing them here keeps the two changelogs consistent and makes the 1.11 -> 1.12 upgrade a pure default flip rather than a default flip plus three latent-bug fixes. Reworded "(now the default)" to "(opt-in in 1.11, the default from 1.12)", which is what the flag actually does on the release these entries now belong to. v1.12.0's Bugfixes section is empty as a result and is dropped.
This was referenced Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent catalog-locking fixes, plus the changelog bookkeeping that follows from #791.
1. Concurrent incremental models deadlock at
threads> 1 (7fe0f06)Two incremental models running concurrently with
dbt_sqlserver_use_dbt_transactions: Truedeadlock with error 1205. The victim graph is an X/S inversion oversys.sysschobjsindexnc1— system catalog rows, not user data.The temp-relation build (
CREATE OR ALTER VIEW …__dbt_tmp_vw→SELECT * INTO …__dbt_tmp→DROP VIEW) is all catalog DDL, and it was running inside the materialization's ambient transaction, so its exclusive key locks were held until commit while a second worker's name resolution asked for shared locks on the same rows.Nothing in that build needs a transaction — the relation is throwaway. The transaction was being opened accidentally, by a read-only catalog lookup (
find_referencesinsqlserver__get_drop_sql) that usedstatement()'s defaultauto_begin=True. dbt-adapters' ownrelations/drop.sqlalready wraps the surrounding drop inauto_begin=False; this override just missed it.relation.sql—find_referencesruns withauto_begin=false, so the temp build autocommits statement by statement and releases catalog locks as it goes.incremental.sql— the fresh-create / full-refreshstatement('main')batch is alsocreate_table_asDDL, so it runs withauto_begin=False;commit_if_open/begin_if_closedafter it reopen the ambient transaction for the swap and the tail. The incremental branch keeps the defaultauto_begin, so its strategy DML stays atomic throughadapter.commit().create.sql— because the temp build now commits standalone, a crashed run can leave a throwaway table behind.SELECT * INTOdrops adapter-generated throwaways first instead of failing the next run withMsg 2714. A fresh create of a real target still surfaces 2714 rather than silently destroying an object dbt does not know about.hooks.sql— theCOMMITbefore the firsttransaction: falsehook is now guarded with@@TRANCOUNT > 0. It was only ever safe because something had accidentally auto-begun a transaction; with that removed it raisedMsg 3902.Pre-hook rollback semantics are unchanged: nothing is committed early, dbt only declines to open a transaction for a read. A
transaction: truepre-hook still opens one, and the temp build correctly joins it.2. Un-hinted catalog lookups block on unrelated writers (
1596c98,d0e76c5)SQL Server holds transaction-scoped exclusive locks on the system catalog for the duration of any
CREATE/ALTER/DROP. A catalog read from another session under READ COMMITTED waits for those, and the defaultLOCK_TIMEOUTis-1— so it waits forever. dbt appears to hang, with no error and no visible failure.information_schema_hints()(with (nolock)) is the existing answer and is already applied at ~60 sites. These were missed:get_relation_last_modifiedmetadata.sqldrop_fk_constraints,drop_pk_constraints,drop_fk_referencesindexes.sqllist_nonclustered_rowstore_indexesindexes.sqlfind_referencesrelation.sqlMeasured against a second session holding 40 uncommitted
CREATEstatements in the same schema, reader atLOCK_TIMEOUT 5000so blocking is observable rather than indefinite:with (nolock)get_relation_last_modifieddrop_fk_referencesdrop_pk_constraintslist_relations— already hinted, run un-hinted as a controlThe control row confirms the existing hints are load-bearing rather than decorative.
find_referencesdid not block in measurement — its plan seekssys.sql_expression_dependenciesfirst and reachessys.objectsbyobject_idon the clustered index — but it is the same class of read-only discovery query, so it is hinted for consistency rather than left as the odd one out.with (nolock)is preferred overSET TRANSACTION ISOLATION LEVEL READ UNCOMMITTEDbecause it is scoped per table reference: it cannot leak a dirty read onto user data the way a session-level isolation change would.Deliberately left un-hinted
Catalog reads that act as correctness guards rather than discovery: the schema-exists check before
CREATE SCHEMA, the principal-exists check inapply_grants, and thedbt_full_refresh_incompleteextended-property marker.The first two provably cannot block — they are literal-name seeks, and stay at 0.00s even under 20 uncommitted
CREATE SCHEMA/CREATE USERstatements. The marker read does block, and that is correct behaviour: the marker is dropped inside an explicit transaction that wraps the whole bulk load, so a dirty read there would see it already gone while the rebuild is still in flight and could still roll back — letting a second invocation append onto a half-rebuilt table, which is exactly what the marker exists to prevent. Blocking is the interlock working; the reader waits for the rebuild to commit or roll back and then gets the true answer.3. Changelog (
29b1ab5)Both fixes are filed under
### v1.11.0, not### v1.12.0, because they ship in v1.11.0 via #791 — v1.11.0 final has not been released yet, onlyv1.11.0rc1.dbt_sqlserver_use_dbt_transactionsis described as "(opt-in in 1.11, the default from 1.12)", which is what the flag actually does on that release. v1.12.0's Bugfixes section is empty as a result and is dropped.Backport
1596c98is deliberately free of any CHANGELOG change so it cherry-picks clean. Verified againstrelease/v1.11(#791) as well as thev1.10.1andv1.9.2tags; on 1.10 and 1.9 theindexes.sqlhunks conflict on adjacentQUOTENAMEhardening that landed later on master, and the resolution is noted in the commit message.Companion PR: #791