Skip to content

fix(schema): serialize CREATE SCHEMA so concurrent sessions cannot collide - #842

Open
axellpadilla wants to merge 2 commits into
masterfrom
fix/839-concurrent-create-schema
Open

axellpadilla wants to merge 2 commits into
masterfrom
fix/839-concurrent-create-schema

Conversation

@axellpadilla

@axellpadilla axellpadilla commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #839

The bug

Four macros create a schema on demand, each with its own copy of this guard:

IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '<schema>')
BEGIN
  EXEC('CREATE SCHEMA [<schema>]')
END

That's check-then-act. With threads > 1, or two dbt runs against one database, sessions pass the check together and all but one fail with Msg 2714, There is already an object named '<schema>' in the database. The schema exists and is fine by the time you see the error, which is why a rerun works — and why CI that builds a schema per PR only hits it on a branch's first run.

sqlserver__get_test_sql is the one reported. The same guard is in sqlserver__get_unit_test_sql, sqlserver__create_schema and sqlserver__create_schema_with_authorization. All four are fixed.

8 sessions released from a barrier: 15 failures in 48 attempts, with the reporter's exact message.

The fix

All four now share one create_schema_if_not_exists macro that serializes the check and the create behind an sp_getapplock. Same race after the fix: 0 failures in 48 attempts.

Two decisions worth explaining:

The lock is session-scoped, not transaction-scoped. Giving the guard its own BEGIN TRAN would be tidier — a transaction-scoped lock frees itself on rollback, so no CATCH needed. But it doesn't survive dbt's transaction, which 1.12 opens by default: a nested BEGIN TRAN only bumps @@TRANCOUNT, so the lock is held until dbt commits. In get_test_sql that's after the test query runs, so data tests would queue behind each other — a second test waited 2.6s behind a 3s test query, against 0.0s as written.

The TRY/CATCH is a finally, not a swallow. A session-scoped lock lives until it's released or the connection closes, and XACT_ABORT ON aborts the batch on a failed create, skipping a trailing sp_releaseapplock. Left like that, one failed create strands the lock on a connection dbt reuses all run, and every other thread waits out the 30s timeout. The CATCH releases it and rethrows the original error unchanged. (Releasing works even with the transaction doomed.)

Also: sp_getapplock returns a return code, not a result set, so the caller's SELECT is still the first result set — which matters for get_test_sql. A lock request that times out falls through to the bare check, so the worst case is today's behaviour. And schema and authorization names are now escaped where they land inside string literals, so an apostrophe no longer breaks the statement.

Why not just swallow the error

Catching 2714 and carrying on gains nothing. XACT_ABORT ON (#718) dooms the transaction, so the run fails either way and the work is rolled back either way. All a swallow does is replace There is already an object named '<schema>' with Uncommittable transaction is detected at the end of the batch, which names nothing. SQL Server has no CREATE SCHEMA IF NOT EXISTS, so the only real option is to not race.

Correction: this section previously said a swallow would silently discard the transaction. It doesn't — dbt sees an error either way. The cost is the diagnosis, not silence.

Tests

tests/functional/adapter/mssql/test_concurrent_schema_creation.py, one class over one dbt project:

  • test_racing_sessions_create_schema_once — 8 real connections race adapter.create_schema, three rounds, barrier-synced; all must succeed and the schema must exist exactly once.
  • test_test_sql_serializes_schema_creation — racing dbt test deterministically isn't practical, so this asserts the statement get_test_sql emits is the serialized form.
  • test_failed_create_releases_the_lock — fails a create via AUTHORIZATION to a missing principal, then checks from a second session that the lock came back and the original error was rethrown.

All three fail against the old macros and pass against these.

  • tests/functional — 407 passed, 48 skipped, 2 xfailed (at -n 8, which exercises the concurrent path itself)
  • tests/unit — 638 passed, 5 skipped

Note

Targets master (1.12), rebased onto d44ed4f. release/v1.11 has the same race in the same four macros — happy to backport to 1.11.2.

@Benjamin-Knight Benjamin-Knight left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems very edge case and perhaps just CI related under normal conditions. See the comment, I'm concerned we could block.

Comment thread dbt/include/sqlserver/macros/adapters/schema.sql
axellpadilla and others added 2 commits September 19, 2026 22:10
…llide

IF NOT EXISTS (SELECT * FROM sys.schemas ...) BEGIN CREATE SCHEMA ... END is
check-then-act. With threads > 1, or two dbt processes pointed at one database,
several sessions pass the check together and all but one fail the create with
Msg 2714, "There is already an object named '<schema>' in the database" - on a
schema that by then exists and is usable, which is why a rerun succeeds. CI
building a schema per pull request hits it on a branch's first run.

Four sites carried their own copy of that guard: sqlserver__create_schema,
sqlserver__create_schema_with_authorization, and the copies inside
sqlserver__get_test_sql and sqlserver__get_unit_test_sql. They now share one
create_schema_if_not_exists macro that takes a database-scoped sp_getapplock
around the check and the create.

Catching 2714 was not an option. Every connection runs SET XACT_ABORT ON
(#718), under which the failed create dooms the enclosing transaction -
verified against SQL Server 2022: XACT_STATE() returns -1 in the CATCH block
and the transaction is already gone by the time the batch reaches its COMMIT.
Swallowing the error would trade a loud failure for a discarded transaction.

The lock is session-scoped, not transaction-scoped, because these callers run
both inside dbt's transaction and in autocommit, and the transaction-scoped
owner errors when there is no transaction. A timed-out request falls back to
the bare check, which is no worse than the previous behaviour.

Also escape schema and authorization names where they are interpolated into
string literals, since the new macro rewrites every one of those literals.

Closes #839

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard took a session-scoped applock and released it on the line after
the create. Under `SET XACT_ABORT ON` a failed create aborts the batch, so
that release never ran - and a session-scoped lock lives until it is
released or the connection closes, which for dbt is the rest of the run.
Every other thread building that schema then waited out the full 30s
timeout. Measured on SQL Server 2022: after one failed create, a second
session could not take the lock at all while the first connection stayed
open.

Wrap the create in `TRY`/`CATCH` used as a `finally`: the `CATCH` releases
the lock and `THROW`s the original error on unchanged, so a genuine failure
stays as loud as it was. Releasing works even with the transaction doomed
(`XACT_STATE()` = -1), which is the state `XACT_ABORT` leaves behind.

Keeps the session-scoped owner rather than giving the guard its own
transaction: a nested `BEGIN TRAN` only raises `@@TRANCOUNT`, so the inner
`COMMIT` would not release a transaction-scoped lock - it would be held to
dbt's commit, and in `sqlserver__get_test_sql` that is after the test query
runs. Measured behind a 3s test query, a second test waited 2.6s for the
lock that way against 0.0s as written here.

Adds `TestSchemaLockReleasedWhenCreateFails`, which fails on the previous
macro with the stranded lock and passes on this one. The file's three cases
now share one class: `--dist loadscope` sets the class-scoped `project`
fixture up once per class, so a class each meant three dbt projects built to
test one macro. The race and the failed-create case work on a schema of
their own, so they share the project without sharing state.

Also condenses the changelog entry, a 400-word paragraph, down to what
broke, where, and what changed; the design rationale it carried belongs in
the macro's comment and the PR rather than the release notes. One claim in
it was wrong as well: a swallow does not silently discard the transaction.
Measured against dbt's own sequence (`BEGIN TRANSACTION` / work / guard /
`IF @@TRANCOUNT > 0 COMMIT TRANSACTION`), the doomed transaction raises
`Uncommittable transaction is detected at the end of the batch` before the
commit is reached, so dbt does see an error and the work is rolled back.
What a swallow costs is the diagnosis, not the noise.

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

axellpadilla commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

The lock is session-scoped, so it survives until the connection closes, and dbt keeps that connection for the whole run. So one failed create blocks that schema for every other thread indefinitely, not for 30 seconds. Confirmed on 2022, both in autocommit and inside dbt's transaction.

Fixed: the create now sits in a TRY/CATCH that releases the lock and rethrows the original error. It's a finally, not a swallow — the error stays exactly as loud as before. The bit I wasn't sure about was whether you can even release a lock once XACT_ABORT has doomed the transaction. You can.

I tried the neater option first — give the guard its own BEGIN TRAN and use @LockOwner = 'Transaction', which frees itself on rollback and needs no CATCH. It doesn't work inside dbt's transaction: a nested BEGIN TRAN only bumps @@TRANCOUNT, so the lock is held until dbt commits. In get_test_sql that's after the test query runs, so data tests would serialize — a second test waited 2.6s behind a 3s test query, against 0.0s as it now stands.

test_failed_create_releases_the_lock covers it: fails on the old macro, passes on this one.

Improved PR claims

@axellpadilla
axellpadilla force-pushed the fix/839-concurrent-create-schema branch from 95f62c9 to bebfc5b Compare September 19, 2026 22:55
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.

Schema creation within sqlserver__get_test_sql macro is not concurrency-safe

2 participants