Learning Platform
Глоссарий Troubleshooting
Урок 08.05 · 25 мин
Продвинутый
MaterializationDispatchPrecedenceDebugging

Precedence и dispatch: 6 уровней

В реальном dbt-проекте materialization с одним именем может быть определён в трёх местах одновременно: в вашем проекте, в установленном пакете и в global project (встроенные в dbt-adapters). Если все три объявляют materialization audit — какой выберется?

Это precedence problem. dbt-core имеет чёткий алгоритм из 6 уровней приоритета. Знать его обязательно, потому что иначе вы будете дебажить «магически странное поведение» — модель использует не тот materialization, что вы думаете.

Materialization-aware dispatch (dbt II)

Правило одной строкой

adapter-specific > default
local project > installed packages > global project (built-in)

На каждом из трёх «уровней проекта» (local / package / global) сначала ищется adapter-specific, потом default. Если ничего не найдено — спускаемся на следующий уровень.

Итого 6 точек поиска, в порядке убывания приоритета:

6 уровней materialization precedence

Где это в коде dbt-core

Алгоритм реализован в dbt-core/dbt/context/providers.py, метод _materialization_macro:

def _materialization_macro(
    self,
    materialization_name: str,
    adapter_type: str,
) -> Optional[MacroProtocol]:
    # 1. Local project, adapter-specific
    local_specific = self.manifest.find_materialization_macro_by_name(
        project_name=self.config.project_name,
        materialization_name=materialization_name,
        adapter_type=adapter_type,
    )
    if local_specific:
        return local_specific

    # 2. Local project, default
    local_default = self.manifest.find_materialization_macro_by_name(
        project_name=self.config.project_name,
        materialization_name=materialization_name,
        adapter_type='default',
    )
    if local_default:
        return local_default

    # 3-4. Installed packages (любой)
    for package_name in self.manifest.macros_by_project_dependency_order():
        package_specific = self.manifest.find_materialization_macro_by_name(
            project_name=package_name,
            materialization_name=materialization_name,
            adapter_type=adapter_type,
        )
        if package_specific:
            return package_specific

        package_default = self.manifest.find_materialization_macro_by_name(
            project_name=package_name,
            materialization_name=materialization_name,
            adapter_type='default',
        )
        if package_default:
            return package_default

    # 5-6. Global project (dbt-adapters)
    # ... аналогично

Это упрощённая версия — реальная имеет дополнительные проверки на behavior flags и search_order. Но суть та же: проход по уровням от high к low priority.

NOTE

Внутри одного «уровня» (например, между двумя пакетами) приоритет определяется search_order в dbt_project.yml пакетов или порядком установки. Это редкая ситуация — обычно пакеты не определяют одинаковые materializations.


Пример коллизии: project default vs package adapter-specific

Допустим, у вас есть:

Local project (my_project/macros/audit.sql):

{% materialization audit, default %}
  -- Простая реализация: создаёт таблицу с audit_log колонкой
{% endmaterialization %}

Установленный пакет (dbt_audit_pkg/macros/audit.sql):

{% materialization audit, adapter='snowflake' %}
  -- Продвинутая Snowflake-specific реализация с CDC и time travel
{% endmaterialization %}

Adapter: Snowflake.

Что выберется? — Package adapter-specific (уровень 3).

Это часто сюрприз для разработчиков, которые думают: «У меня в проекте есть materialization, он точно используется». Но adapter-specific важнее default, даже когда default — в local project, а adapter-specific — в пакете.

Как это узнать заранее:

dbt --debug compile --select test_audit
# В логах ищите:
# Found materialization for 'audit' from project 'dbt_audit_pkg' (adapter: snowflake)

Или через программную проверку:

import json
with open('target/manifest.json') as f:
    manifest = json.load(f)

# Найти все materializations с именем 'audit'
for macro_id, macro in manifest['macros'].items():
    if 'materialization_audit' in macro_id:
        print(f"{macro['package_name']}.{macro_id}: {macro.get('macro_sql', '')[:80]}")

Когда возникают коллизии

В практике senior’а коллизии бывают в трёх ситуациях:

Случай 1: package overrides built-in

Пакет вроде dbt_external_tables определяет materialization external, default. Это перекрывает built-in external materialization из dbt-duckdb (которая materialization external, adapter='duckdb').

Что произойдёт:

Level 1 (local adapter): nothing
Level 2 (local default): nothing
Level 3 (package adapter): nothing (dbt_external_tables не имеет 'external, duckdb')
Level 4 (package default): MATCH — dbt_external_tables 'external, default'

Built-in adapter-specific (external, duckdb из dbt-duckdb) не выбирается, потому что пакет уже «съел» его на уровне 4.

Решение: либо удалить package если он не нужен, либо явно объявить materialization external, adapter='duckdb' в своём проекте (уровень 1):

-- macros/external_override.sql
{% materialization external, adapter='duckdb' %}
  -- Скопировать реализацию из dbt-duckdb и использовать
{% endmaterialization %}

Случай 2: project overrides package

Пакет имеет audit materialization. Вы хотите кастомизировать только для своего проекта.

-- my_project/macros/audit_override.sql
{% materialization audit, default %}
  -- Своя реализация — перекрывает package version
{% endmaterialization %}

Уровень 2 (local default) > уровень 4 (package default). Ваш override победит.

Но если пакет имеет adapter-specific version (уровень 3), а вы — только default (уровень 2) — пакет всё ещё выиграет на adapter’е, где есть его version!

Решение: либо явно объявить и adapter-specific:

-- my_project/macros/audit_override.sql
{% materialization audit, default %}
  -- Универсальная version
{% endmaterialization %}

{% materialization audit, adapter='snowflake' %}
  -- Snowflake-specific
{% endmaterialization %}

{% materialization audit, adapter='duckdb' %}
  -- DuckDB-specific
{% endmaterialization %}

Либо понять, что вы action на packageless adapter’е, где package не имеет adapter-specific.

Случай 3: dbt-utils macro precedence (не совсем materialization)

Похожая логика применяется к обычным макросам в dbt — особенно для adapter.dispatch(). Если у вас есть macro current_timestamp в проекте и в dbt_utils, что выберется?

Это управляется через dispatch: секцию в dbt_project.yml:

dispatch:
  - macro_namespace: dbt
    search_order: ['my_project', 'dbt']

«Сначала искать в my_project, потом в dbt-default». Для materializations такого explicit конфига нет — порядок hardcoded в dbt-core.


Debugging precedence

Если model работает не так как ожидаете и подозреваете wrong materialization:

Шаг 1: —debug compile

dbt --debug compile --select my_model 2>&1 | grep -i materialization

Найдёте строки:

Building materialization 'audit' from package 'dbt_external_tables' (adapter: default)

Это говорит: используется уровень 4 (package default), а не то, что вы могли ожидать.

Шаг 2: проверка через manifest.json

import json

with open('target/manifest.json') as f:
    manifest = json.load(f)

# Найти все материализации с именем X
target_name = 'audit'
for macro_id, macro in manifest['macros'].items():
    if f'materialization_{target_name}_' in macro_id:
        print(f"{macro['package_name']}.{macro['name']}")

Выведет:

my_project.materialization_audit_default
dbt_external_tables.materialization_audit_snowflake
dbt_external_tables.materialization_audit_default

Видно, что есть три кандидата. Зная precedence rules, можно вывести: на snowflake выберется dbt_external_tables.materialization_audit_snowflake (уровень 3), на остальных — my_project.materialization_audit_default (уровень 2).

Шаг 3: dbt run --debug и search в логах

21:23:45  Found materialization for 'audit' from package 'dbt_external_tables' (adapter: snowflake)

dbt-core версии 1.5+ пишет эту строку в —debug mode для каждой materialization-dispatch операции. Это самый прямой способ узнать, что используется.


Behavior flag: require_explicit_package_overrides

dbt 1.7 добавил behavior flag для безопасности при коллизиях. Если в вашем проекте есть materialization с именем, совпадающим с built-in (table, view, incremental, и т.д.), dbt предупредит:

Warning: Materialization 'view' is defined in your project, but you have not
explicitly opted in to overriding the built-in materialization.

Включить «strict» поведение:

# dbt_project.yml
flags:
  require_explicit_package_overrides_for_builtin_materializations: true

С включённым флагом dbt падает если local project переопределяет built-in без explicit opt-in. Это защищает от accidental overrides.

Когда нужно: большие проекты с многими разработчиками, где есть риск, что кто-то случайно скопирует view.sql из dbt-adapters в свой проект и сломает поведение.


Реальный кейс: коллизия между dbt-duckdb и проектом

dbt-duckdb имеет встроенный materialized='external'. Допустим, мы написали свой (как в уроке 4) с именем my_external — конфликта нет.

Но что если мы назвали наш materialization просто external (как в research)?

Cases:

  1. Если у нас в проекте: {% materialization external, adapter='duckdb' %}:

    • Level 1: MATCH. Используется наш materialization.
    • dbt-duckdb’s materialization external, adapter='duckdb' не используется (level 5).
  2. Если у нас: {% materialization external, default %}:

    • Level 1: nothing (we don’t have adapter=‘duckdb’).
    • Level 2: MATCH. Наш default используется.
    • dbt-duckdb’s adapter-specific не используется, потому что уровень 2 победил уровень 5.
  3. Если у нас вообще нет external:

    • Levels 1-4: nothing.
    • Level 5 (global adapter-specific): dbt-duckdb’s materialization external, adapter='duckdb'. MATCH.

Точное знание этого позволяет аккуратно расширять / переопределять built-in без сюрпризов.

WARNING

В прошлых уроках мы намеренно использовали имена my_view, my_external — чтобы избежать коллизий с built-in. В production советую делать так же: имена начинать с префикса проекта (mycompany_view, mycompany_external), чтобы коллизии были невозможны.


Как искать materialization в установленном пакете

Чтобы понять, какие materializations предоставляет пакет:

# Список всех файлов в установленном пакете
find dbt_packages/dbt_external_tables -name "*.sql" | xargs grep -l "materialization"

# Извлечь имена materializations
grep -h "{% materialization" dbt_packages/dbt_external_tables/macros/**/*.sql

Или через Python:

import json
with open('target/manifest.json') as f:
    manifest = json.load(f)

# Все materializations из всех packages
for macro_id, macro in manifest['macros'].items():
    if 'materialization_' in macro_id and macro['package_name'] != 'my_project':
        print(f"{macro['package_name']}: {macro['name']}")

Полезный аудит перед добавлением пакета — посмотреть, какие materializations он принесёт.


Попробуй сам

  1. Создайте macros/my_view_override.sql с {% materialization view, default %} (намеренно — чтобы переопределить built-in).

    `{% materialization view, default %}`
      `{{ log("USING CUSTOM VIEW MATERIALIZATION", info=True) }}`
      -- Простейшая реализация
      `{%- set target_relation = this.incorporate(type='view') -%}`
      `{% call statement('main') %}`
        CREATE OR REPLACE VIEW `{{ target_relation }}` AS (`{{ sql }}`)
      `{% endcall %}`
      `{{ adapter.commit() }}`
      {{ return({'relations': [target_relation]}) }}
    `{% endmaterialization %}`
  2. Создайте модель с materialized='view'. Запустите dbt run. В логах найдите USING CUSTOM VIEW MATERIALIZATION — это значит, ваш override победил built-in.

  3. Включите strict flag:

    flags:
      require_explicit_package_overrides_for_builtin_materializations: true

    Запустите dbt parse. Должны увидеть warning/error о том, что переопределяете built-in.

  4. Эксперимент: добавьте {% materialization view, adapter='duckdb' %} (если на DuckDB). Запустите. Должен использоваться adapter-specific (уровень 1) вместо default (уровень 2).

  5. Уберите ваш view materialization. Установите dbt_utils или другой пакет. Через manifest.json посмотрите, какие materializations имеет пакет (если есть).


Ключевые выводы

  1. 6 уровней precedence: local adapter -> local default -> package adapter -> package default -> global adapter -> global default. От high к low priority.

  2. Adapter-specific важнее default на каждом уровне. То есть package adapter-specific победит local default — частый сюрприз.

  3. Debug через --debug compile или manifest.json analysis — главные инструменты для понимания, какой materialization фактически выбран.

  4. Behavior flag require_explicit_package_overrides_for_builtin_materializations (dbt 1.7+) защищает от случайных overrides built-in.

  5. Best practice: давайте custom materializations имена с префиксом проекта (mycompany_external), чтобы избежать коллизий.

  6. Реальные коллизии возникают в трёх случаях: package overrides built-in, project overrides package, dispatch macros (контролируется через dispatch: в project config).

Проверка знанийKnowledge check
Project has `{% materialization external, default %}`. Installed `dbt-external-tables` package has `{% materialization external, default %}` AND `{% materialization external, adapter='postgres' %}`. На adapter Postgres что выберется и почему?
ОтветAnswer
**`dbt-external-tables.materialization_external_postgres`** (уровень 3 — package adapter-specific).\n\nПроход по уровням:\n\n**Level 1 (local adapter-specific)**: nothing. Local project не имеет `external, adapter='postgres'`.\n\n**Level 2 (local default)**: project's `external, default` есть. Но мы НЕ останавливаемся здесь, потому что **adapter-specific важнее default**, и dbt идёт дальше искать adapter-specific на нижних уровнях.\n\n_Стоп!_ Это распространённое заблуждение. Реальный алгоритм dbt-core такой:\n\nНа самом деле dbt **не идёт ниже level 2 если на level 2 есть match для default**. Логика «adapter > default» работает **внутри одного уровня поиска**, не между уровнями.\n\nДавайте уточним правильный алгоритм:\n\n```\nFor each level (1 to 6):\n Try to find macro at this level\n If found — return it\n Else — go to next level\n\nLevels:\n 1. local + adapter\n 2. local + default \n 3. package + adapter\n 4. package + default\n 5. global + adapter\n 6. global + default\n```\n\nТо есть ПРАВИЛЬНЫЙ результат в нашем сценарии: **`local.materialization_external_default`** (уровень 2).\n\nLevel 1 — пусто.\nLevel 2 — есть match (local default). **Возвращаем его. Остальные уровни не проверяются.**\n\nЭто означает: `local default` **bи́jet** `package adapter-specific`!\n\n**Почему важно**: если в вашем проекте определён any materialization с именем X (даже default), он автоматически перекрывает все materializations с этим именем в packages, включая adapter-specific.\n\n**Когда это поведение проявляется в реальной жизни**:\n\n1. Вы случайно скопировали `external.sql` из примера в свой проект.\n2. Через 3 месяца установили `dbt-external-tables` package для работы с Snowflake.\n3. Ожидаете, что package обработает Snowflake-specific логику.\n4. **Не обработает** — ваша local default version пере overrides всё.\n\n**Решение**:\n\n1. Если хотите чтобы package adapter-specific работала — **удалите** local default или переименуйте.\n\n2. Или объявите local adapter-specific для всех нужных warehouses:\n\n```jinja\n{% materialization external, adapter='snowflake' %}\n {# delegate к package #}\n {{ return(dbt_external_tables.materialization_external_snowflake()) }}\n{% endmaterialization %}\n```\n\n3. Используйте unique имя (`mycompany_external`) чтобы избежать коллизий.\n\n**Это и есть та самая precedence trap** из senior gotchas. Знать её обязательно. Debug — `dbt --debug compile` покажет, что фактически выбрано.
Проверка знанийKnowledge check
Senior проект использует `dbt_external_tables` пакет и недавно добавил dbt 1.7. После обновления materialization `view` ведёт себя странно. Откуда искать?
ОтветAnswer
Это классический симптом **silent precedence shift** при обновлении dbt-core.\n\n**Что могло измениться в 1.7**:\n\n**1. Built-in materialization внесли breaking change**.\n\ndbt 1.7 ввёл behavior flag `require_explicit_package_overrides_for_builtin_materializations` — но он по default OFF. То есть это не должно ломать существующие проекты. Но другие изменения могли быть.\n\nПример: 1.7 изменил порядок `run_hooks(pre_hooks, inside_transaction=False)` в `view` materialization. Если ваш custom view скопирован со старой версии — поведение разъезжается.\n\n**2. Package обновился**.\n\n`dbt_external_tables` могла выпустить новую версию, которая ввела `{% materialization view, default %}` (раньше не было). Теперь package перекрывает built-in.\n\nКак проверить:\n\n```bash\ncd dbt_packages/dbt_external_tables\ngrep -r "{% materialization view" .\n```\n\nЕсли есть match — package добавил view materialization, и вы получили unintended override.\n\n**3. Adapter обновился**.\n\n`dbt-duckdb`, `dbt-snowflake` могли изменить adapter-specific `view` materialization. Например, добавили behavior, не совместимый с предыдущей версией.\n\nПроверить:\n\n```bash\npip show dbt-duckdb\n# и сравнить версию с предыдущим pip freeze\n```\n\n**Debug strategy**:\n\n**Шаг 1: понять, какой materialization фактически выбран**.\n\n```bash\ndbt --debug compile --select my_view_model 2>&1 | grep -i 'materialization'\n```\n\nДолжна быть строка:\n\n```\nFound materialization 'view' from package 'dbt_external_tables' (adapter: default)\n```\n\nИли:\n\n```\nFound materialization 'view' from project 'dbt' (adapter: duckdb)\n```\n\nСравните с тем, что вы ожидали.\n\n**Шаг 2: проверить manifest.json**.\n\n```python\nimport json\nwith open('target/manifest.json') as f:\n m = json.load(f)\n\nfor macro_id, macro in m['macros'].items():\n if 'materialization_view_' in macro_id:\n print(macro_id, '->', macro['package_name'])\n```\n\nОтобразит все доступные view materializations. По precedence rules можно понять, что выбралось.\n\n**Шаг 3: сравнить с предыдущей версией**.\n\nЕсли есть git tag для предыдущего deploy:\n\n```bash\ngit diff prev-deploy HEAD -- dbt_project.yml packages.yml\ngit diff prev-deploy HEAD -- macros/\n```\n\nИзменения в packages.yml versions = новые package versions = возможные новые materializations.\n\n**Шаг 4: explicit pin или override**.\n\nЕсли проблема в package, который теперь перекрывает built-in:\n\n**Option A**: downgrade package:\n\n```yaml\npackages:\n - package: dbt-labs/dbt_external_tables\n version: "менее 0.10" # последняя без view override\n```\n\n**Option B**: явно переопределить view в проекте:\n\n```jinja\n-- macros/view_pin.sql\n{% materialization view, default %}\n {# Скопировано из dbt-adapters 1.7.0 #}\n ...\n{% endmaterialization %}\n```\n\nЭтот переопределит package on level 2.\n\n**Шаг 5: включить strict flag и пересмотреть проект**.\n\n```yaml\nflags:\n require_explicit_package_overrides_for_builtin_materializations: true\n```\n\nЭто failwise тех проектов, которые случайно overrides built-in. Параллельно делаете audit pacakges.\n\n**Prevention**:\n\n1. Pin package versions в `packages.yml` (`version: "=0.9.1"` вместо `version: "не меньше0.9"`).\n2. Run `dbt --debug compile` после каждого major dependency update.\n3. Сравнивать manifest.json между deploys (есть инструменты для diff).\n4. CI gate, который failwise если materialization сменил источник между deploys.\n\nЭто senior'ная гигиена. Без неё obscure issues типа «view ведёт себя странно» съедают часы дебага.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Каков правильный порядок materialization precedence (6 уровней)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5