Skip to content

perf: make hot-path adapter catalog queries sargable - #686

Merged
axellpadilla merged 2 commits into
dbt-msft:masterfrom
joshmarkovic:master
Jun 1, 2026
Merged

perf: make hot-path adapter catalog queries sargable#686
axellpadilla merged 2 commits into
dbt-msft:masterfrom
joshmarkovic:master

Conversation

@joshmarkovic

Copy link
Copy Markdown
Contributor

Optimizes the four highest-frequency catalog queries in the SQL Server adapter so they seek instead of scan:

  • list_relations_without_caching: filter by schema_id = SCHEMA_ID(@Schema) before computing names, instead of scanning every table+view in the database and post-filtering with LIKE on SCHEMA_NAME(...).
  • get_relation_without_caching: same predicate-pushdown; also switch identifier match from LIKE (no wildcards) to equality.
  • get_columns_in_relation: drop the wrapping CTE and the no-op ROW_NUMBER() OVER (PARTITION BY object_name(c.object_id) ...); the WHERE already pins one object_id. Order directly by c.column_id.
  • get_view_definition_sql: replace UPPER()-on-both-sides join across sys.views + sys.schemas with a single OBJECT_DEFINITION(OBJECT_ID( '[schema].[name]', 'V')) lookup. Preserves zero-row behavior when the view doesn't exist so the view materialization's diff-skip logic is unchanged.

Optimizes the four highest-frequency catalog queries in the SQL Server
adapter so they seek instead of scan:

- list_relations_without_caching: filter by schema_id = SCHEMA_ID(@Schema)
  before computing names, instead of scanning every table+view in the
  database and post-filtering with LIKE on SCHEMA_NAME(...).
- get_relation_without_caching: same predicate-pushdown; also switch
  identifier match from LIKE (no wildcards) to equality.
- get_columns_in_relation: drop the wrapping CTE and the no-op
  ROW_NUMBER() OVER (PARTITION BY object_name(c.object_id) ...); the
  WHERE already pins one object_id. Order directly by c.column_id.
- get_view_definition_sql: replace UPPER()-on-both-sides join across
  sys.views + sys.schemas with a single OBJECT_DEFINITION(OBJECT_ID(
  '[schema].[name]', 'V')) lookup. Preserves zero-row behavior when
  the view doesn't exist so the view materialization's diff-skip
  logic is unchanged.
@joshmarkovic
joshmarkovic marked this pull request as draft May 26, 2026 19:56
@joshmarkovic
joshmarkovic marked this pull request as ready for review May 26, 2026 20:21

@axellpadilla axellpadilla 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.

The performance direction looks good, but I think get_view_definition_sql needs one more pass before merge.

The new object_id('[{{ relation.schema }}].[{{ relation.identifier }}]', 'V') path changes semantics in two ways:

  1. It manually bracket-quotes identifiers but does not escape ], so legal identifiers containing ] can produce an invalid object name. Consider building the object name with quotename(schema) + '.' + quotename(identifier).

  2. The previous query used upper(s.name) = upper(...) and upper(v.name) = upper(...), so it intentionally did case-insensitive lookup even on CS_AS databases. OBJECT_ID(...) may return NULL when relation casing differs from catalog casing under case-sensitive collations. Since CI runs CS_AS variants, please add a focused test or preserve the old case-insensitive semantics, and check whether we have enough coverage for these changes.

The column-query simplification and schema_id predicate pushdown otherwise look sound.

Address PR review feedback on get_view_definition_sql:

- Build the object name with quotename(schema) + '.' + quotename(identifier)
  instead of manual [..].[..] bracketing. quotename() doubles embedded ]
  characters, so legal identifiers containing ] no longer produce a malformed
  object name (which OBJECT_ID would resolve to NULL).
- Add a focused functional test (runs under both CI collations, CS_AS and
  CI_AS) covering an identifier that contains ] and the zero-row behavior for
  a missing view that the view materialization's diff-skip relies on.

OBJECT_ID resolution still follows database collation rules. In the view
materialization, get_view_definition_sql is only ever called on the cached
existing_relation, whose casing comes straight from the catalog, so the
lookup casing always matches the stored object even under CS_AS.

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

Copy link
Copy Markdown
Collaborator

We lost the information_schema_hints() in the get view definition, was that planned?

We are swapping from SCHEMA_NAME(t.schema_id) to '{{ schema_relation.schema }}'. I'm not sure this really matters, the catalog should have the same casing as is returned by SCHEMA_NAME as I assume the catalog is populated via that method, but I've not checked. I think the CI would have picked up any issues.

@joshmarkovic

Copy link
Copy Markdown
Contributor Author

We lost the information_schema_hints() in the get view definition, was that planned?

information_schema_hints() fell out of the rewrite rather than being removed on purpose. information_schema_hints() just adds with (nolock), which is a hint you put on a table. The old query read from sys.views and sys.schemas, so it had tables to put the hint on. The new query has no FROM, it's just object_definition(object_id('[schema].[name]', 'V')), which are built-in functions, not tables, so there's nothing left for nolock to attach to.

@axellpadilla

Copy link
Copy Markdown
Collaborator

Hi guys, please let me know if you conclude the same. Short version:

I don’t think removing NOLOCK is a meaningful locking regression here.

The old query was not truly “non-locking”. SQL Server docs say that all queries, including READUNCOMMITTED / NOLOCK queries, still acquire schema-stability (Sch-S) locks during compilation and execution, and can still be blocked by schema-modification (Sch-M) locks.
Source: https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table

Also, for this specific flow, get_view_definition_sql(existing_relation) appears to run via run_query(...) before the materialization enters the explicit transaction section / BEGIN area, so this metadata read should not be held open by the later model transaction.
Source: https://github.com/dbt-msft/dbt-sqlserver/blob/master/dbt/include/sqlserver/macros/materializations/models/view/view.sql

So the previous WITH (NOLOCK) hints on catalog views would not have prevented metadata/schema-change blocking. NOLOCK mostly affects normal dirty-read / shared-lock behavior, not schema-stability locking.
Source: https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql

From a locking perspective, this looks reasonable and reducing time. The main things worth validating are semantic compatibility: identifier quoting, case-sensitive collation behavior, permissions/metadata visibility, and missing-object behavior, which look mostly covered.

@joshmarkovic

Copy link
Copy Markdown
Contributor Author

We are swapping from SCHEMA_NAME(t.schema_id) to '{{ schema_relation.schema }}'. I'm not sure this really matters, the catalog should have the same casing as is returned by SCHEMA_NAME as I assume the catalog is populated via that method, but I've not checked. I think the CI would have picked up any issues.

I'd argue the literal is the better choice because it's cheaper. SCHEMA_NAME(t.schema_id) is a function call SQL Server runs for every row. The literal is resolved once by dbt at compile time, so there's no per-row work which lines up with the point of this PR (filter by schema_id and let the query seek instead of scan).

@joshmarkovic

Copy link
Copy Markdown
Contributor Author

The main things worth validating are semantic compatibility: identifier quoting, case-sensitive collation behavior, permissions/metadata visibility, and missing-object behavior

Quoting: get_view_definition_sql now uses QUOTENAME() on both parts, with a regression test for an identifier containing ]. The other macros pass identifiers as single-quoted literals (same convention as before this PR)

Collation: The old get_view_definition_sql wrapped both sides in UPPER(), the new OBJECT_ID() respects database
collation. No-op on the default CI-AS collation; on case-sensitive DBs the stored case has to match (which it does for dbt-managed objects).

Permissions: OBJECT_ID/SCHEMA_ID/OBJECT_DEFINITION and the sys.* catalog views all honor the same visibility rules: NULL/zero rows for objects the caller can't see, so no change here.

Missing-object: get_view_definition_sql preserves the zero-row contract via WHERE OBJECT_ID() IS NOT NULL (covered by
test_missing_view_returns_no_rows). The other macros get zero rows via SCHEMA_ID/OBJECT_ID returning NULL when the object doesn't exist (which is the same end state as the old LIKE filter).

Let me know if there's anything else I can do to 1) get this merged 2) facilitate cutting a new release with the latest changes :)

@Benjamin-Knight

Copy link
Copy Markdown
Collaborator

Hi guys, please let me know if you conclude the same. Short version:

I don’t think removing NOLOCK is a meaningful locking regression here.

The old query was not truly “non-locking”. SQL Server docs say that all queries, including READUNCOMMITTED / NOLOCK queries, still acquire schema-stability (Sch-S) locks during compilation and execution, and can still be blocked by schema-modification (Sch-M) locks. Source: https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table

I do not think removing the no lock is an issue, but if anyone does update what goes in information_schema_hints() expecting it to be applied to all information schema queries we just have one that it does not. Can't think of a use case right now, just noting it.

@axellpadilla axellpadilla added this to the v1.10.0 milestone May 31, 2026
@joshmarkovic

Copy link
Copy Markdown
Contributor Author

Is it possible to cut a new release after this merges? TIA

@axellpadilla

Copy link
Copy Markdown
Collaborator

Is it possible to cut a new release after this merges? TIA

Included on v1.10, and good news I benchmarked it, 1% to 10% improvement on different scenarios without increasing any locking issue or timing. Improvements increases with objects, just tried with 2000 to 20000 simulated on the schemas, people with more or complex pipelines could get noticeable differences.

@axellpadilla
axellpadilla merged commit f536263 into dbt-msft:master Jun 1, 2026
53 checks passed
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.

3 participants