Skip to content

fix: concurrent-incremental catalog deadlock, and (nolock) on the remaining catalog lookups - #792

Open
axellpadilla wants to merge 4 commits into
masterfrom
fix/catalog-lock-contention
Open

fix: concurrent-incremental catalog deadlock, and (nolock) on the remaining catalog lookups#792
axellpadilla wants to merge 4 commits into
masterfrom
fix/catalog-lock-contention

Conversation

@axellpadilla

Copy link
Copy Markdown
Collaborator

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: True deadlock with error 1205. The victim graph is an X/S inversion over sys.sysschobjs index nc1 — system catalog rows, not user data.

The temp-relation build (CREATE OR ALTER VIEW …__dbt_tmp_vwSELECT * INTO …__dbt_tmpDROP 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_references in sqlserver__get_drop_sql) that 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.sqlfind_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 is also create_table_as DDL, so it 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 — because the temp build now commits standalone, a crashed run can leave a throwaway table behind. SELECT * INTO drops adapter-generated throwaways first instead of failing the next run with Msg 2714. A fresh create of a real target still surfaces 2714 rather than silently destroying an object dbt does not know about.
  • hooks.sql — the COMMIT 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 semantics are unchanged: nothing is committed early, dbt only declines to open a transaction for a read. A transaction: true pre-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 default LOCK_TIMEOUT is -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:

macro file
get_relation_last_modified metadata.sql
drop_fk_constraints, drop_pk_constraints, drop_fk_references indexes.sql
list_nonclustered_rowstore_indexes indexes.sql
find_references relation.sql

Measured against a second session holding 40 uncommitted CREATE statements in the same schema, reader at LOCK_TIMEOUT 5000 so blocking is observable rather than indefinite:

query no hint with (nolock)
get_relation_last_modified blocked 5.00s 0.00s
drop_fk_references blocked 5.02s 0.01s
drop_pk_constraints blocked 5.01s 0.01s
list_relationsalready hinted, run un-hinted as a control blocked 5.00s 0.00s

The control row confirms the existing hints are load-bearing rather than decorative. find_references did not block in measurement — its plan seeks sys.sql_expression_dependencies first and reaches sys.objects by object_id on 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 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 left un-hinted

Catalog reads that act as correctness guards rather than discovery: the schema-exists check before CREATE SCHEMA, the principal-exists check in apply_grants, and the dbt_full_refresh_incomplete extended-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 USER statements. 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, only v1.11.0rc1. dbt_sqlserver_use_dbt_transactions is 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

1596c98 is deliberately free of any CHANGELOG change so it cherry-picks clean. Verified against release/v1.11 (#791) as well as the v1.10.1 and v1.9.2 tags; on 1.10 and 1.9 the indexes.sql hunks conflict on adjacent QUOTENAME hardening that landed later on master, and the resolution is noted in the commit message.

Companion PR: #791

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.
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.

1 participant