fix(schema): serialize CREATE SCHEMA so concurrent sessions cannot collide - #842
Open
axellpadilla wants to merge 1 commit into
Open
axellpadilla wants to merge 1 commit into
axellpadilla wants to merge 1 commit 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>
| @Resource = '{{ escape_single_quotes(lock_resource) }}', | ||
| @LockMode = 'Exclusive', | ||
| @LockOwner = 'Session', | ||
| @LockTimeout = 30000; |
Collaborator
There was a problem hiding this comment.
I assume the lock time out is a fall back in case the lock is not released? That is fine but right now there is no try finally here so if the schema creation fails, perhaps something transient, then the resource is locked for 30 seconds?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #839
The bug
Four macros create a schema on demand, each with its own copy of the same guard:
That 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 their create withMsg 2714, There is already an object named '<schema>' in the database. The run dies on a schema that, by the time the error surfaces, exists and is perfectly usable, which is why a rerun succeeds and why CI that builds a schema per pull request only sees it on a branch's first run.sqlserver__get_test_sqlis the one reported, but the same guard is insqlserver__get_unit_test_sql,sqlserver__create_schemaandsqlserver__create_schema_with_authorization. All four are fixed.Reproduced on SQL Server 2022 with 8 sessions released from a barrier: 15 failures across 48 attempts, with the reporter's exact message.
Why not TRY/CATCH
Swallowing 2714 looks like the obvious fix and is actively worse. Every connection runs
SET XACT_ABORT ON(#718), under which the failed create dooms the enclosing transaction. Measured, same server:The transaction is already rolled back by the time the batch reaches its
COMMIT, so aTRY/CATCHwould convert a loud, recoverable error into a silently discarded transaction. SQL Server has noCREATE SCHEMA IF NOT EXISTS, so the remaining option is to not race at all.The fix
One shared
create_schema_if_not_existsmacro, used by all four sites, that serializes the check and the create behind a database-scopedsp_getapplock. The lock is held for the microseconds the create takes and released in the same batch. Same race, after: 0 failures across 48 attempts, schema present exactly once each round.Design points, all verified against SQL Server 2022:
dbt_sqlserver_use_dbt_transactionsdefaults on in 1.12) and in autocommit, and@LockOwner = 'Transaction'errors when there is no transaction. Confirmed the session-scoped lock commits cleanly inside an explicit transaction and is released afterwards (APPLOCK_MODE→NoLock).EXEC @rc = sp_getapplockreturns a return code, not a result set. This matters becausesqlserver__get_test_sqlandsqlserver__get_unit_test_sqlemit it ahead of theSELECTwhose rows dbt fetches; confirmed the caller'sSELECTis still the first result set.Also escapes schema and authorization names where they are interpolated into string literals, since the new macro rewrites every one of those literals anyway; a name containing an apostrophe no longer breaks the statement.
Tests
New
tests/functional/adapter/mssql/test_concurrent_schema_creation.py:TestConcurrentCreateSchemaraces 8 real adapter connections throughadapter.create_schema, three rounds, barrier-synchronised, and asserts every session succeeds and the schema exists exactly once.TestDataTestSchemaGuardIsConcurrencySafeasserts the statementsqlserver__get_test_sqlactually emits is the serialized form — racingdbt testdeterministically is not practical, so this pins the reported macro to the safe guard.Both are genuine regression tests: reverted to the current macros they fail, the first with the reporter's exact error (
There is already an object named '..._race' in the database), the second on the missingsp_getapplock.Full suite on SQL Server 2022:
tests/functional— 377 passed, 48 skipped, 2 xfailed (run with-n 8, which exercises the concurrent path itself)tests/unit— 526 passedNote
This targets
master(1.12). The same four macros carry the same race onrelease/v1.11; happy to backport if you want it in 1.11.2.🤖 Generated with Claude Code