Skip to content

fix(table): stop Sch-M locks spanning the load in table builds - #822

Open
Benjamin-Knight wants to merge 14 commits into
dbt-msft:masterfrom
Benjamin-Knight:fix/819-sch-m-lock
Open

fix(table): stop Sch-M locks spanning the load in table builds#822
Benjamin-Knight wants to merge 14 commits into
dbt-msft:masterfrom
Benjamin-Knight:fix/819-sch-m-lock

Conversation

@Benjamin-Knight

@Benjamin-Knight Benjamin-Knight commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Resolve #819

Problem

A table_refresh_method: dml model held a Sch-M lock on its scratch table for the
whole load. Sch-M is the one lock mode incompatible with the Sch-S lock every
metadata reader takes, so while a slow model built, it blocked metadata readers in every
other session on the database — database-wide sys / INFORMATION_SCHEMA scans, a
concurrent dbt run's catalog and column lookups, SSMS's object explorer. None of those
asked for the scratch table by name.

Cause

Two independent causes; fixing either alone would have changed nothing.

  1. The scratch table was built by one fused SELECT * INTO, which holds Sch-M on the
    new object from the start of the statement to the end, not for the instant of creation.
  2. That build ran inside the materialization's ambient transaction. Locks are held to
    commit, so a split create inside the transaction holds Sch-M just as long as the
    fused statement did.

Fix

  • Create the table empty, then load it with a separate INSERT ... WITH (TABLOCK) — still
    minimally logged, as SELECT INTO was. Hoisted into
    sqlserver__get_create_table_empty_sql / sqlserver__get_tablock_insert_sql and applied
    to the dml refresh path, the default table build, and every other create_table_as
    caller (incremental full refresh and temp builds, snapshots).
  • Every statement in the build passes auto_begin=False, so each autocommits and drops its
    catalog locks as it finishes — the same treatment the incremental temp build already got.
    This also stops the clustered columnstore index built after the load from holding its
    locks to the end of the materialization.
  • The dml refresh's DELETE+INSERT swap commits as soon as it completes, instead of
    running on through index reconciliation, masks, grants and persist_docs while holding X
    locks on the target. The swap stays atomic — it is now the whole of its own transaction.

Worth knowing

  • A pre-hook with inside_transaction: true (dbt's default) still re-couples the build.
    auto_begin=False only declines to open a transaction; a statement still joins one
    already open. Documented in the macro and the changelog. Same trade-off as the
    incremental path.
  • A crashed run can now leave a __dbt_tmp intermediate behind, since the build commits
    standalone. The existing OBJECT_ID guard for adapter-generated throwaways drops it on
    the next run; a fresh create of a real target still surfaces Msg 2714 rather than
    destroying an object dbt doesn't know about.
  • Index and mask reconciliation reconverge on the next run if the swap fails — both
    reconcile against the config rather than applying a delta.
  • Snapshots get the split, not the lock change; they keep their own transaction semantics.

Tests

  • New tests/unit/adapters/mssql/test_table_build_sql.py: renders the batch that actually
    ships (non-contract, contract, temp build) end to end, so a missing statement terminator
    or an unescaped EXEC literal fails without a database. Plus a repo-wide check that no
    macro fuses a create with its load again.
  • Re-anchored the query-options hint assertions on the split build. The old swap pattern
    matched the scratch load as readily as the swap, so it would have passed on a log where
    only the swap lost its hint — silently testing nothing. Each pattern is now anchored on
    what its statement selects FROM, and verified mutually exclusive against the emitted SQL.

Benjamin-Knight and others added 4 commits August 18, 2026 17:53
sqlserver__create_table_as_prebuilt and the contract branch of
sqlserver__create_table_as each build a table in two statements - create it
empty, then bulk-load it with INSERT ... WITH (TABLOCK) - and each spelled
that out inline. Two more call sites are about to want the same pair (dbt-msft#819),
so hoist it into sqlserver__get_create_table_empty_sql and
sqlserver__get_tablock_insert_sql.

Both macros return SQL and nothing else: no statement() calls, no EXEC()
wrapping, no transaction management. That seam is deliberate - the call sites
disagree on all three (prebuilt interleaves an extended-property marker and
cuts the transaction mid-build; the dml refresh path issues bare statements),
and folding orchestration in would need a flag per caller.

contract_enforced is a parameter rather than a config lookup inside the
macros, because create_table_as suppresses contracts for temporary relations
and prebuilt does not. get_assert_columns_equivalent stays in the create
macro alone so its mismatch assertion still fires exactly once per build.

No behaviour change, with one deliberate exception: prebuilt's non-contract
empty create now goes through escape_single_quotes like every other branch,
so an identifier containing a single quote can no longer break out of the
EXEC literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `table_refresh_method: dml` model held a Sch-M lock on its scratch table for
the entire load. Sch-M is the one mode incompatible with the Sch-S lock every
metadata reader takes, so a slow model blocked metadata readers in every other
session on the database for as long as it ran - including database-wide sys /
INFORMATION_SCHEMA scans, a concurrent dbt run's catalog lookups and SSMS's
object explorer, none of which asked for the scratch table by name.

Two independent causes, and fixing either alone would have changed nothing:

  - the scratch table was built by one fused `SELECT * INTO`, which holds Sch-M
    from the start of the statement to the end rather than for the instant of
    creation; and
  - that build ran inside the materialization's ambient transaction, which held
    the lock through to the trailing commit regardless. Locks are held to
    commit, not to end-of-statement, so a split create inside the transaction
    holds Sch-M just as long as the fused statement did.

So both: the scratch table is created empty and loaded by a separate
INSERT ... WITH (TABLOCK) via the shared macros, and every statement up to the
swap passes auto_begin=False so each autocommits and drops its catalog locks as
it finishes. That mirrors the incremental temp build, which declines the ambient
transaction for the same reason (see incremental.sql).

Also commit the DELETE+INSERT swap as soon as it completes rather than letting
it run to the end of the materialization. The DELETE holds X locks on the
target until commit, and index reconciliation, masks, grants and persist_docs
all sat inside that window, with the index DDL adding Sch-M on the target on
top. The swap stays atomic - it is now the whole of its own transaction - and
index/mask reconciliation reconverges on the next run if it fails, since both
reconcile against the config rather than applying a delta. This is already how
the path behaves with dbt_sqlserver_use_dbt_transactions off.

`main` stays on the load INSERT so adapter_response still reports a row count.
The scratch table is built without contract enforcement, exactly as the fused
statement did: the contract describes the target, which the swap inserts into.

Not fixed: a pre-hook with inside_transaction=true (dbt's default) opens the
ambient transaction before this macro runs, and auto_begin=False only declines
to open one - a statement still joins one already open. Documented in the macro
and the changelog; same trade-off as the incremental path.

Fixes dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The non-contract branch of sqlserver__create_table_as was the last fused
`SELECT * INTO`, so it held Sch-M on the new object for the whole load and
blocked metadata readers in every other session (dbt-msft#819). It now uses the shared
create-empty + INSERT ... WITH (TABLOCK) pair, which every other build path
already does.

The split is only half of it. Locks are held to commit, so inside a transaction
the split create holds Sch-M exactly as long as the fused statement did. The
callers that matter already run this batch outside the ambient transaction - the
incremental temp build via run_query, the incremental full refresh via
statement(auto_begin=False) - but the table materialization's rename path did
not, so its build gets the same treatment, with commit_if_open/begin_if_closed
reopening the transaction before the renames so those keep their semantics and
adapter.commit() still has a matching BEGIN. That also stops the clustered
columnstore index built after the load from holding its locks to the end of the
materialization.

Because the build now commits standalone, a crashed run can leave a __dbt_tmp
intermediate behind. Nothing new is needed for that: the OBJECT_ID guard for
adapter-generated throwaways already drops it on the next run, and table.sql
drops a preexisting intermediate up front. A fresh create of a real target
still surfaces Msg 2714 rather than destroying an object dbt does not know of.

Snapshots reach create_table_as inside their own statement() calls and keep
their transaction semantics; they get the split, not the lock change.

Tests: the batch that actually ships is now rendered end to end (non-contract,
contract, temp build) so a missing statement terminator or an unescaped EXEC
literal fails without a database, plus a repo-wide check that no macro fuses a
create with its load again.

Refs dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… split build

The scratch build is no longer one fused `SELECT * INTO`, so the 'main'
assertion looked for a statement that no longer exists and failed. The
hint was never lost: it rides `INSERT ... WITH (TABLOCK)`, the statement
that moves the rows and takes the memory grant, exactly as the empty
create's contract says it should. Match that statement instead.

Fix the swap assertion at the same time. Both statements are now
`INSERT ... SELECT ... FROM <a __dbt_refresh relation>`, so the old
`INSERT INTO[^;]*SELECT[^;]*__dbt_refresh[^;]*OPTION` pattern matches the
scratch load as readily as the swap — it would have passed on a log where
only the swap lost its hint, silently testing nothing. That defeats the
stated point of the test, which is that a hint on one statement cannot be
mistaken for a hint on the other.

Anchor each pattern on what its statement selects FROM: the scratch load
reads the tmp view (`...__dbt_refresh__dbt_tmp_vw`) and is the only one
carrying `WITH (TABLOCK)`; the swap reads the scratch table, where
`__dbt_refresh` ends the name, so a negative lookahead excludes the tmp
view. Verified mutually exclusive against the emitted SQL, including that
the swap pattern no longer matches when only the swap loses its hint.

Test-only: no adapter behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benjamin-Knight and others added 2 commits August 19, 2026 09:35
Only CHANGELOG.md conflicted: upstream added entries in the same Bugfixes
region (the probe cursor rollback fix and the dbt-msft#409 index-name quoting fix).
Kept both sides.
@axellpadilla

axellpadilla commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Can we move creation of the empty intermediate table before run_hooks(pre_hooks, inside_transaction=True) and remove the early commit_if_open()?

That keeps the table-creation Sch-M outside the model transaction while allowing the long INSERT to run without Sch-M, preserving the transaction boundary for transactional hooks. The rename/swap can acquire Sch-M only at the final cutover.

Proposed lock/transaction flow

OUTSIDE TRANSACTION
│
├─ outside-tx pre-hooks
│
├─ CREATE empty intermediate table
│      └─ Sch-M
│         short, released immediately
│
▼
BEGIN TRANSACTION
│
├─ inside-tx pre-hooks
│
├─ INSERT INTO intermediate
│      └─ long-running load
│         no Sch-M
│
├─ rename / swap
│      └─ Sch-M acquired here
│
├─ transactional tail
│
└─ COMMIT
       └─ Sch-M released

The key property is:

Current problematic shape
begin├──────────────────── long load with Sch-M ────────────────────┤ commit

Proposed
Sch-M ├─ create ─┤
                 begin├──────── long load, no Sch-M ────────┤
                                                       ├─ Sch-M ─┤ commit
                                                          swap

One nuance: the Sch-M acquired by the rename/swap is held until COMMIT, so keeping the post-swap transactional tail short still matters.

@Benjamin-Knight

Copy link
Copy Markdown
Collaborator Author

Not the create — it needs the model SQL to be bindable (the tmp view, or the describe probe on the contract path), so it can't run before a pre-hook that stages what the model reads.

Benjamin-Knight and others added 8 commits August 25, 2026 10:47
A `call statement(...)` block defaults to auto_begin=True, so a probe
issued when no transaction is running opens one, even though it only
reads.

This is not observable in a materialization today: every build path
deliberately holds a transaction open across its tail (table.sql's
begin_if_closed before the rename, table_dml_refresh's before reconcile,
statement('main') on the snapshot and append paths), so a probe there
joins one either way. What it does is make the probes a latent hazard
for any caller running one outside a transaction, and block moving mask
and index reconciliation out of the cutover transaction - a single probe
would reopen one and every mask ALTER and index build after it would
join and hold it to the trailing COMMIT, which is the Sch-M window on
the live target that dbt-msft#819 is about.

get_columns_in_relation, get_mask_index_key_columns,
get_unmaskable_columns, get_existing_principals, describe_indexes and
the two currently-unreferenced probes (find_references in indexes.sql,
list_nonclustered_rowstore_indexes) now pass auto_begin=False, as
find_references in relation.sql already did. Each still joins an open
transaction, so callers that legitimately run inside one are unaffected.
The unreferenced pair is annotated rather than deleted - macros are a
public surface a user project can call.

Also make incremental's trailing adapter.commit() state its
precondition. It raises when nothing is open, and every branch above it
only happened to leave a transaction open (the swap's renames,
prebuilt's trailing load, the append path's statement('main')).
begin_if_closed() replaces that coincidence, as table.sql already did.

Grants are deliberately left alone: default__call_dcl_statements still
opens a transaction, and giving DCL auto_begin=False would change its
failure mode from all-or-nothing to partially-applied. That belongs with
the transaction-boundary work, not here.

A unit test guards the rule at source level so a new probe cannot
silently reintroduce the problem; it skips the cache-population and
docs-generate probes, which never run in a materialization tail.

Verified against SQL Server 2022: tests/functional test_masks,
test_denies, test_index_config, test_index_macros,
test_table_refresh_method, test_xact_abort - 68 passed.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fresh-create branch built straight into target_relation, so it had
no rename swap and no OBJECT_ID drop guard - that guard keys off the
__dbt_tmp suffix and only covers adapter-generated throwaways.

Since dbt-msft#819 split the build into an empty CREATE plus a separate
INSERT ... WITH (TABLOCK), and the build batch declines to open the
ambient transaction, those two statements commit independently. A load
that failed therefore left the empty CREATE committed under the model's
real name. dbt's next run saw a relation that existed and was not a
view, took the append/merge branch, and merged that run's window into an
empty table - no error, and every row the first build should have loaded
gone for good.

Fresh creates now build into the intermediate and swap, as full
refreshes already did: a failed load leaves no target, so dbt correctly
does a fresh create next time.

The swap's target->backup rename is guarded on existing_relation. It is
unconditional today only because no existing caller reaches it without a
target; the fresh-create branch does, and without the guard every first
build of every incremental model fails with Msg 15225 (verified by
removing the guard - TestFirstIncrementalBuildStillSucceeds catches it).

Side effect worth knowing: a first build's CCI is now named from the
intermediate (<schema>_<model>__dbt_tmp_cci), which is what a
--full-refresh has always produced, so the name was never stable across
a rebuild anyway.

Verification: the three new functional tests pass against SQL Server
2022, and the reproduction was confirmed to fail before the fix. The
wider incremental regression sweep (test_incremental, test_basic,
test_transactions, test_concurrent_incremental, microbatch,
temp_relation_cleanup, full_refresh_build) got 33 passed / 2 skipped /
1 xfailed before the container wedged on an unrelated SQL Server stack
dump; it needs re-running once the local Docker environment is back.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A materialization deciding how to scope a build needs one fact: will the
next statement join an open transaction, or start on its own? That is
the predicate SQLConnectionManager.add_query tests before honouring
auto_begin, so expose it directly rather than inferring it.

Inference from config does not work. The obvious proxy - "does this
model declare an in-transaction pre-hook?" - is wrong in both
directions:

  - run_hooks skips a hook whose rendered SQL is empty (hooks.sql), so
    the very common {% if target.name == 'prod' %}...{% endif %} idiom
    declares a transactional pre-hook that opens nothing. A build gated
    on the config would take the wide, lock-holding path in every
    environment where the hook renders empty.

  - macros pairing commit_if_open with begin_if_closed leave a
    transaction open with no hook involved at all -
    sqlserver__mark_full_refresh_incomplete runs before the build on the
    incremental full-refresh branch and always leaves one open.

Reads dbt's bookkeeping rather than @@TRANCOUNT: with
dbt_sqlserver_use_dbt_transactions off, begin/commit flip the flag
without emitting T-SQL, and auto_begin keys off that same flag, so
bookkeeping is the correct answer for "would this statement join
something".

No caller yet - this is groundwork for scoping the pre-hook transaction,
which lands with the config that uses it. No CHANGELOG entry: nothing
user-visible changes.

Unit tests cover both flag states, the no-connection case, coercion to a
real bool (a MagicMock attribute is truthy, which would make the closed
case read as open in Jinja), and the @available marker that macros need.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Locks are held to commit, not to end-of-statement, so an empty CREATE
that shares a transaction with its load holds the new object's Sch-M for
the whole load - blocking every metadata reader in every other session
(dbt-msft#819). Escaping that needs a transaction boundary between the two, and
create_table_as emitted them as one inseparable blob.

Split at that seam:

  sqlserver__get_create_table_stage_sql - USE, the temp view, the empty
    CREATE. Owns the render-time adapter.drop_relation so it fires
    exactly once per build whichever way the halves are rendered.
  sqlserver__get_create_table_load_sql - the TABLOCK INSERT, the tmp
    view drop, the CCI. The view drop moves here because the INSERT
    reads that view; dropping it in the stage half would break a split
    build.

create_table_as is now exactly the two halves back to back, so callers
that run one batch (snapshots, the incremental temp build) get the same
statements in the same order. A unit test pins that invariant directly -
whole == stage + load - so the halves cannot drift from the concatenation.

Both halves keep their EXEC() wrapper. Concatenated, the load still
follows CREATE VIEW inside one batch and a bare statement referencing
that just-created view would fail compilation; EXEC defers it. The
consequence is that the create and the load now sit in one EXEC literal
each rather than sharing one, which changes compiled SQL artifacts and
the DDL those tests assert.

Test changes, all consequences of the split rather than adjustments to
fit it:

  - two unit tests pinned the old shape. The terminator test existed
    because the create shared a literal with the load; the create is now
    last in its literal, so the test moves to the drop guard, which is
    the statement that still precedes it there. The single-EXEC test now
    pins the invariant that actually matters - the load's OPTION clause
    is escaped inside its own literal - with the count documenting which
    three EXECs there are.
  - the two expected_sql fixtures in test_constraints.py were
    regenerated from actual output rather than hand-edited.

Also renamed the new test helpers' _Config/_Contract to _SplitConfig/
_SplitContract: as written they shadowed the module-level _Config that
_render_batch relies on, so the pre-existing batch tests were silently
running against the wrong stub.

Verified against SQL Server 2022 under rootless podman: constraints (20),
snapshots + query options + tablock (42), full refresh + dml refresh +
indexes + masks (69), basic + incremental + temp cleanup (21). 595 unit.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Desktop's WSL integration can leave a distro with broken network
namespaces (iptables/nft failures, vanishing sockets), which takes the
functional test server with it. Rootless podman avoids that path
entirely, and needs nothing the repo did not already have: the same
devops/server.Dockerfile, the same environment docker-compose.yml
passes, and test.env unchanged.

Adds server-podman, server-podman-stop and server-podman-logs alongside
the existing docker `server` target, which is untouched and remains the
documented default. MSSQL_VERSION, PODMAN_IMAGE and PODMAN_CONTAINER are
overridable so another SQL Server release can be tested without editing
the Makefile.

Verified end to end: make server-podman builds, starts and initialises
the instance, and the functional suite passes against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes dbt-msft#819. The empty CREATE now commits before the load rather than
sharing its transaction, and the cutover gets a transaction of its own
that ends after the in-transaction post-hooks - so neither the
intermediate's Sch-M nor sp_rename's Sch-M on the live target spans slow
work any more.

New model config pre_hook_transaction_scope ('schema' | 'build') with
behaviour flag dbt_sqlserver_pre_hook_schema_scope supplying its
default, shipped False so current behaviour is preserved.

The gate is sampled immediately after the in-transaction pre-hooks, in
both materializations, and deliberately not later: mark_full_refresh_
incomplete ends with begin_if_closed and always leaves a transaction
open, so a sample taken at the build site would answer "yes, one is
open" on every full refresh, for reasons having nothing to do with a
pre-hook - silently selecting the transaction-spanning path with the fix
disabled, in the default configuration. It asks the connection
(adapter.transaction_is_open) rather than the pre_hooks config, because
run_hooks skips hooks whose SQL renders empty.

Masks stay INSIDE the cutover transaction on paths that build a new
table. That table carries no masks until apply_masks runs, so moving it
after the commit would leave a failed mask exposing the newly loaded
columns; rolling the swap back instead keeps the old masked table
serving. The dml swap path reconciles masks outside, where the table
persists and already carries them.

table_dml_refresh no longer commits its own swap: it leaves the
transaction open and reports schema_match and the scratch relation back
to table.sql, so in-transaction post-hooks are atomic with the swap
(they were not before) and the tail picks reconcile-then-mask or
mask-then-index accordingly.

statement() writes the compiled artifact for 'main' only, and 'main' is
now the load, so both halves are written back explicitly - otherwise
target/run/ would hold the INSERT without its CREATE, which the
constraint tests read.

Tail closes the transaction create_indexes_no_txn reopens for
ONLINE/RESUMABLE builds, and states its precondition before
adapter.commit() rather than relying on grants having opened one.

Known and documented: in-transaction post-hooks now run before masks and
indexes (transaction: false is the escape hatch); post-hook-created
indexes interact with drop_unmanaged_indexes and the pre-2022 mask
index-key check. docs/transaction_scope.md covers the flow, the config
and the caveats.

Verified against SQL Server 2022: the two scope tests assert opposite
rollback outcomes and both pass, so the config demonstrably changes
behaviour. Plus constraints, hooks, snapshots, grants, masks, denies,
indexes, dml refresh, prebuilt, incremental, concurrency - 180+
functional; 601 unit.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the shipped implementation (rather than the
design) found no runtime bug - Jinja scoping, macro return semantics,
the artifact write ordering and BEGIN/COMMIT balance all verified sound
- but four documentation claims that the code does not honour:

  - "post-hooks now run before masks and indexes" was wrong and
    contradicted the section above it. Masks run BEFORE the post-hooks
    on every fresh-table path, deliberately. Only index creation moved
    after them, plus the dml swap path's mask reconcile.

  - the `build` row promised pre-hook rollback unconditionally. Two
    paths commit the pre-hook before the load whatever the setting says:
    prebuilt commits its in-progress marker onto its own transaction,
    and an incremental --full-refresh marks the table before building.
    Both do so precisely so the marker survives a failed load, which is
    incompatible with rolling that load back.

  - `schema` was presented as fixing dbt-msft#819 everywhere. On the dml and
    prebuilt paths the build joins a pre-hook's open transaction and
    nothing commits in between, so the lock is held regardless;
    transaction: false on the hook is still the remedy there.

  - the mask paragraph promised "the swap rolls back and the old masked
    table keeps serving". True for the rename swap and the dml fallback;
    prebuilt has no swap and leaves an empty marked target instead.

Also: table_dml_refresh returns refresh_relation only on the swap path.
The fallback renames the scratch table into the target, so that name is
already vacated and the tail's DROP was a no-op against it - harmless,
but it read as though it might drop the target.

And the gate comment in table.sql cited mark_full_refresh_incomplete,
which only the incremental materialization calls; prebuilt's own
commit/reopen is the one that applies there.

No behaviour change. 601 unit, plus dml refresh and the scope tests.

Refs: dbt-msft#819

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A linked server is instance-wide, and this class hard-coded the name
LOCALLOOP. Under `pytest -n auto` against one SQL Server, two workers
running these tests fight over it: the fixture's setup does
`IF EXISTS ... sp_dropserver` then recreates, and its teardown drops
outright, so one worker pulls the server out from under another mid-run.

The victim's models fail with Msg 7202, "Could not find server
'LOCALLOOP' in sys.servers" - immediately after its own fixture asserted
the server existed. Both are true at once because catalog writes are
transactional and connection-scoped in effect: the creating worker sees
its own row while the other worker's connection does not. That
contradiction is what makes this read as impossible rather than as a
race.

Each worker process now gets its own name (LOCALLOOP_GW0, LOCALLOOP_GW1,
... ; LOCALLOOP_MAIN when not under xdist), derived from
PYTEST_XDIST_WORKER. Deliberately the worker id rather than a uuid: it
is stable across reruns, so the IF EXISTS guard still cleans up a server
left behind by a crashed run instead of leaking a fresh one every time.

Setup SQL, model bodies and the emitted-SQL assertions are all still
written against the LOCALLOOP placeholder and rewritten in one place, so
there is a single definition of the name rather than nine literals.

Unrelated to dbt-msft#819; it rides on this branch because it is what is failing
that PR's CI.

Verification, stated plainly because it is incomplete: the race
reproduces locally at -n 4 on the unmodified test, failing the same two
tests CI reported; the per-worker name derivation is verified
deterministically; and two -n 4 runs passed 9/9 after the fix. Further
repeat runs could not be done - the local SQL Server container wedged
again (port open, refusing connections), as it has repeatedly in this
environment. Two clean samples is thinner than I would like for a race.

Also unverified here: the failing CI leg is SQL2025, and this file
documents a separate 2025-specific TLS problem in the same fixture
(_create_linked_server_sql). Fixing the race may reveal that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Benjamin-Knight

Copy link
Copy Markdown
Collaborator Author

Ok, there is a lot of complex changes in the transaction flow here that definitely need another pair of eyes on them @axellpadilla

I've also added a fix for what I think was a race condition in the usage of the linked server in the open query tests that would intermittently fail my local and the GitHub CI flows.

Default table path (rename swap)

BEGIN  ← first in-tx pre-hook statement, if any
├─ in-tx pre-hooks
│     ▸ scope gate sampled here: transaction_is_open() and scope == 'build'
├─ CREATE VIEW model__dbt_tmp_vw
├─ SELECT TOP 0 * INTO model__dbt_tmp       ← Sch-M, but TOP 0 moves no rows
COMMIT                                       ← Sch-M released, effectively instant
   │
   ├─ INSERT … WITH (TABLOCK)                ← autocommitted; X table lock, never Sch-M
   ├─ CREATE CLUSTERED COLUMNSTORE INDEX     ← autocommitted, on a private name
   │
BEGIN
├─ sp_rename target → backup                 ← Sch-M on the live name
├─ sp_rename intermediate → target
├─ apply_masks                               ← inside: a failure rolls the swap back
├─ in-tx post-hooks
COMMIT                                       ← cutover atomic; Sch-M released
   │
   ├─ create_indexes                         ← the long one, now outside
   ├─ grants / denies / persist_docs
   └─ drop backup, outside-tx post-hooks

INSERT ... WITH (TABLOCK) takes an exclusive table lock, which is compatible with Sch-S
— so the long load never blocks a metadata reader. The hint is also what keeps it minimally
logged; it is not there to be removed for "less blocking".

table_refresh_method: dml

   ├─ scratch build (auto_begin=False)        ← create empty, then TABLOCK load
BEGIN
├─ DELETE + INSERT swap                       ← X locks on the target
├─ in-tx post-hooks                           ← now atomic with the swap; previously not
COMMIT
   │
   ├─ reconcile_indexes → apply_masks         ← reconcile first: index drops before re-masking
   ├─ DROP scratch table
   └─ grants / denies / persist_docs

The macro no longer commits its own swap. It leaves the transaction open and returns
schema_match plus the scratch relation, so the shared tail owns the boundary and picks the
index strategy. On the schema-changed fallback it renames the scratch table into place, masks
inside the transaction, and returns refresh_relation: none — that name is already vacated.

full_refresh_build: prebuilt

   ├─ drop existing, rebuild in place         ← marker committed onto its own transaction
├─ apply_masks                                ← inside the load's transaction
├─ in-tx post-hooks
COMMIT
   ├─ create_indexes / grants / denies / persist_docs

No swap, so a mask failure leaves an empty target carrying dbt_full_refresh_incomplete
rather than rolling back to the old table. That is prebuilt's existing trade, now documented.

incremental

Same gate and same split build on the create_table_as branches. Fresh creates now stage
through __dbt_tmp and swap — they previously built straight into the real target, so a
failed first load committed an empty table and the next run merged into it. Silent data
loss.
The append/merge path is untouched: the strategy DML is the cutover, so it keeps its
transaction.


What is atomic with what

The transaction spans in-tx pre-hooks → the cutover → in-tx post-hooks. That is what a hook
declaring transaction: true is asking for: atomicity with the model. Index reconciliation,
grants, denies and persist_docs are the adapter's own housekeeping and were never part of
that promise.

Masks are the exception. On a path that builds a new table it carries no masks until
apply_masks runs, so running that after the cutover committed would leave the new table live
with its columns exposed. Masks stay inside the transaction there.

Two behaviour changes

  1. In-tx post-hooks now run before index creation (masks are unaffected — they still run
    first). A post-hook that needs indexes present should declare transaction: false, which
    runs it after the whole tail.
  2. If you create indexes from post-hooks — the idiom predating the indexes config —
    drop_unmanaged_indexes: true now drops them in the same run, and such an index on a masked
    column trips the index-key check on SQL Server before 2022.

New config

Transaction covers Pre-hook rolls back with a failed load #819 fixed
pre_hook_transaction_scope: schema pre-hooks + CREATE VIEW + empty CREATE no yes
pre_hook_transaction_scope: build pre-hooks + the whole build yes, except below no

dbt_sqlserver_pre_hook_schema_scope supplies the default. It ships False (meaning build)
so current behaviour is preserved, and is expected to flip in a later release.

Inert where it cannot help: a model with no transactional pre-hook always takes the fixed
path. On dml and prebuilt the build joins a pre-hook's open transaction with nothing
committing in between, so schema changes nothing there. And build cannot deliver rollback
on prebuilt or an incremental --full-refresh — both commit an in-progress marker before the
load precisely so it survives a failure. transaction: false on the hook remains the remedy.

Full detail, including the caveats, in docs/transaction_scope.md.

Also in here

  • auto_begin=False on seven read-only catalog probes. They were reopening the ambient
    transaction and dragging the tail's mask and index DDL back inside it. A unit test enforces
    the rule so a new probe cannot reintroduce it.
  • sqlserver__create_table_as split into stage and load halves; create_table_as is now their
    concatenation, so snapshots and the incremental temp build emit the same statements in the
    same order. A test pins whole == stage + load.
  • adapter.transaction_is_open() — the config-derived proxy is wrong in both directions
    (run_hooks skips hooks whose SQL renders empty; mark_full_refresh_incomplete leaves a
    transaction open with no hook involved).
  • Podman make targets for the local test server, for anyone who would rather not run Docker.
  • An unrelated test fix riding along: the openquery tests hard-coded an instance-wide linked
    server name, so concurrent pytest-xdist workers dropped and recreated each other's server
    mid-run. Each worker now gets its own.

Verification

Functional suites against SQL Server 2022 (constraints, hooks, snapshots, grants, masks,
denies, indexes, dml refresh, prebuilt, incremental, concurrency) plus 601 unit tests. The two
pre_hook_transaction_scope tests assert opposite rollback outcomes and both pass, so the
config demonstrably changes behaviour rather than merely compiling.

@axellpadilla

Copy link
Copy Markdown
Collaborator

@Benjamin-Knight Amazing will set time aside to check this in deep

@Benjamin-Knight

Copy link
Copy Markdown
Collaborator Author

pre_hook_transaction_scope is probably one of the larger changes, the intent is that we swap behaviour from current to schema only at some point and users opt in to pre hooks holding the transaction open for the entire build. In most cases we do not want the pre hook to do so and schema is the right call, but there are use cases where the pre hook does need to roll back properly but because of the schema lock this causes users should opt in when they need it rather than cause schema locks for the entire project.

@axellpadilla

Copy link
Copy Markdown
Collaborator

I will try some ideas too when checking this, maybe we could use a different connection to create the table at the right time?, I'm currently using latest rc3 with transaction off but I think this PR is the only missing blocker

@Feckinotter

Feckinotter commented Aug 26, 2026

Copy link
Copy Markdown

I'm using rc3 with transaction on, other than the schema lock no issues so far. But the schema lock can cause issues, I've also seen an increase in other locks but I'm not sure these are rc3 related as they are parralellism inside queries deadlocking, my only concern is I never had these before transactions in DBT. (Ben on personal account)

@axellpadilla

axellpadilla commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

@Feckinotter yes there should be some limits on parallelism but I'm also not sure that kind of in-process locking have something to do with transactions, here are other best practices that help both with and without transactions https://github.com/dbt-msft/dbt-sqlserver/blob/master/docs/sqlserver-best-practices.md

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.

table_refresh_method: dml, the default, holds a Sch-M lock on the scratch table for the entire load

3 participants