fix(schema): serialize CREATE SCHEMA so concurrent sessions cannot collide - #842
axellpadilla wants to merge 2 commits into
Conversation
…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>
|
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 I tried the neater option first — give the guard its own
Improved PR claims |
95f62c9 to
bebfc5b
Compare
Closes #839
The bug
Four macros create a schema on demand, each with its own copy of this guard:
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 withMsg 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_sqlis the one reported. The same guard is insqlserver__get_unit_test_sql,sqlserver__create_schemaandsqlserver__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_existsmacro that serializes the check and the create behind ansp_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 TRANwould be tidier — a transaction-scoped lock frees itself on rollback, so noCATCHneeded. But it doesn't survive dbt's transaction, which 1.12 opens by default: a nestedBEGIN TRANonly bumps@@TRANCOUNT, so the lock is held until dbt commits. Inget_test_sqlthat'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/CATCHis afinally, not a swallow. A session-scoped lock lives until it's released or the connection closes, andXACT_ABORT ONaborts the batch on a failed create, skipping a trailingsp_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. TheCATCHreleases it and rethrows the original error unchanged. (Releasing works even with the transaction doomed.)Also:
sp_getapplockreturns a return code, not a result set, so the caller'sSELECTis still the first result set — which matters forget_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 replaceThere is already an object named '<schema>'withUncommittable transaction is detected at the end of the batch, which names nothing. SQL Server has noCREATE SCHEMA IF NOT EXISTS, so the only real option is to not race.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 raceadapter.create_schema, three rounds, barrier-synced; all must succeed and the schema must exist exactly once.test_test_sql_serializes_schema_creation— racingdbt testdeterministically isn't practical, so this asserts the statementget_test_sqlemits is the serialized form.test_failed_create_releases_the_lock— fails a create viaAUTHORIZATIONto 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 skippedNote
Targets
master(1.12), rebased ontod44ed4f.release/v1.11has the same race in the same four macros — happy to backport to 1.11.2.