fix(table): stop Sch-M locks spanning the load in table builds - #822
fix(table): stop Sch-M locks spanning the load in table builds#822Benjamin-Knight wants to merge 14 commits into
Conversation
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>
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.
|
Can we move creation of the empty intermediate table before That keeps the table-creation Proposed lock/transaction flowThe key property is: One nuance: the |
|
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. |
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>
|
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
|
| 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=Falseon 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_assplit into stage and load halves;create_table_asis now their
concatenation, so snapshots and the incremental temp build emit the same statements in the
same order. A test pinswhole == stage + load.adapter.transaction_is_open()— the config-derived proxy is wrong in both directions
(run_hooksskips hooks whose SQL renders empty;mark_full_refresh_incompleteleaves a
transaction open with no hook involved).- Podman
maketargets 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 concurrentpytest-xdistworkers 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.
|
@Benjamin-Knight Amazing will set time aside to check this in deep |
|
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. |
|
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 |
|
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) |
|
@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 |
Resolve #819
Problem
A
table_refresh_method: dmlmodel held aSch-Mlock on its scratch table for thewhole load.
Sch-Mis the one lock mode incompatible with theSch-Slock everymetadata reader takes, so while a slow model built, it blocked metadata readers in every
other session on the database — database-wide
sys/INFORMATION_SCHEMAscans, aconcurrent 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.
SELECT * INTO, which holdsSch-Mon thenew object from the start of the statement to the end, not for the instant of creation.
commit, so a split create inside the transaction holds
Sch-Mjust as long as thefused statement did.
Fix
INSERT ... WITH (TABLOCK)— stillminimally logged, as
SELECT INTOwas. Hoisted intosqlserver__get_create_table_empty_sql/sqlserver__get_tablock_insert_sqland appliedto the dml refresh path, the default
tablebuild, and every othercreate_table_ascaller (incremental full refresh and temp builds, snapshots).
auto_begin=False, so each autocommits and drops itscatalog 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.
DELETE+INSERTswap commits as soon as it completes, instead ofrunning on through index reconciliation, masks, grants and
persist_docswhile holding Xlocks on the target. The swap stays atomic — it is now the whole of its own transaction.
Worth knowing
inside_transaction: true(dbt's default) still re-couples the build.auto_begin=Falseonly declines to open a transaction; a statement still joins onealready open. Documented in the macro and the changelog. Same trade-off as the
incremental path.
__dbt_tmpintermediate behind, since the build commitsstandalone. The existing
OBJECT_IDguard for adapter-generated throwaways drops it onthe next run; a fresh create of a real target still surfaces
Msg 2714rather thandestroying an object dbt doesn't know about.
reconcile against the config rather than applying a delta.
Tests
tests/unit/adapters/mssql/test_table_build_sql.py: renders the batch that actuallyships (non-contract, contract, temp build) end to end, so a missing statement terminator
or an unescaped
EXECliteral fails without a database. Plus a repo-wide check that nomacro fuses a create with its load again.
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.