Learning Platform
Глоссарий Troubleshooting
Урок 08.01 · 28 мин
Продвинутый
MaterializationManifestdbt-core internalsRegistryHooks

Materialization как Jinja-макрос особого типа

В middle-курсе вы уже видели синтаксис {% materialization %} и писали external_parquet / immutable_table для DuckDB. Этот урок не повторяет — мы спускаемся ниже Jinja-слоя и смотрим: что именно хранится в Manifest про materialization, как dbt-core находит «правильную» реализацию через registry lookup chain, где Python вступает в игру, и какие fail-safe гарантии есть у hooks.

NOTE

Recap из dbt-ii / 05 / 03: materialization объявляется {% materialization name, adapter='X' %} или default. Dispatch по adapter — внутри материализации, не через adapter.dispatch. Тело обязано выполнить main DML через statement('main') и вернуть {'relations': [target_relation]}. Если это всё знакомо — едем дальше.


Materialization — это не macro, но почти

В Jinja-парсере {% materialization %} обрабатывается похоже на {% macro %}, но с другим post-parse трансформом. Когда dbt-core грузит файл macros/my_mat.sql, парсер видит блок и вызывает специальный handler в core/dbt/clients/jinja.py::MacroExtension.parse(). Этот handler:

  1. Извлекает name и adapter (или default) из параметров.
  2. Строит canonical macro name: materialization_<name>_<adapter_or_default>. Например, {% materialization view, default %} превращается в макрос materialization_view_default. {% materialization audit, adapter='snowflake' %} — в materialization_audit_snowflake.
  3. Кладёт получившийся ParsedMacro в Manifest.macros под полным unique_id вида macro.<package>.materialization_<name>_<adapter>.
  4. Дополнительно помечает запись флагом is_materialization=True в ParsedMacro.metadata — это используется при lookup чтобы отфильтровать обычные макросы.

То есть с точки зрения хранилища manifest не различает materialization и обычный macro — это всё ParsedMacro объекты. Различие — в naming convention (materialization_<name>_<adapter>) и в специальном execution context во время run-фазы.

Golden Rule выбора материализации (dbt I) Recap is_incremental() + философия incremental (dbt II)
AST трансформация materialization при parse
Source: {% materialization audit, snowflake %}Исходный файл macros/my_mat.sql с {% materialization audit, adapter='snowflake' %}
Jinja parse -> canonical nameMacroExtension.parse() в core/dbt/clients/jinja.py извлекает имя и adapter, строит canonical macro name
Manifest.macros[macro.pkg.materialization_audit_snowflake]Manifest.macros[unique_id] хранит ParsedMacro с is_materialization=True. Это in-memory структура из core/dbt/contracts/graph/manifest.py

Ссылка на код: core/dbt/clients/jinja.py::MacroExtension, и хранилище — core/dbt/contracts/graph/manifest.py::Manifest.macros.


Registry resolution: default_materialization_default_<name>

Когда модель имеет config(materialized='audit') и dbt-core доходит до её выполнения на adapter Snowflake, происходит lookup chain. Запускающий метод — core/dbt/context/providers.py::generate_runtime_model_context()::_materialization, который вызывается перед рендером модельного SQL.

Псевдокод lookup chain (core/dbt/context/macros.py::MacroResolver.get_materialization_macro):

def get_materialization_macro(self, name: str, adapter_type: str) -> Optional[ParsedMacro]:
    candidates = []
    # Build search order: local project -> deps -> global project
    for project_name in self._search_order:
        # 1. Try adapter-specific first
        specific_id = self._build_macro_id(
            project_name, f"materialization_{name}_{adapter_type}"
        )
        if specific_id in self.manifest.macros:
            candidates.append((self.manifest.macros[specific_id], 'specific'))
        # 2. Then default
        default_id = self._build_macro_id(
            project_name, f"materialization_{name}_default"
        )
        if default_id in self.manifest.macros:
            candidates.append((self.manifest.macros[default_id], 'default'))
    # Pick first specific; if none — first default
    specific = [c for c, k in candidates if k == 'specific']
    if specific:
        return specific[0]
    defaults = [c for c, k in candidates if k == 'default']
    return defaults[0] if defaults else None

Ключевое: для каждого project в _search_order сначала проверяется adapter-specific, затем default. Между projects — local выше packages, packages выше dbt-adapters global project. Результат: adapter-specific важнее default в рамках одного project, но local default может проиграть adapter-specific из package. Этот фрагмент мы вынесли в детальный урок 5 модуля.

WARNING

В dbt 1.7+ есть behavior flag require_explicit_package_overrides_for_builtin_materializationsdbt_project.yml под flags:). Когда включён, dbt предупреждает / падает, если local project переопределяет built-in materialization без явного dispatch: config. Это защита от accidental shadowing встроенного view или table.


Что Manifest хранит про materialization

После parse-фазы каждый materialization в manifest.json выглядит примерно так:

{
  "macro.my_project.materialization_audit_snowflake": {
    "name": "materialization_audit_snowflake",
    "resource_type": "macro",
    "package_name": "my_project",
    "path": "macros/audit_mat.sql",
    "original_file_path": "macros/audit_mat.sql",
    "unique_id": "macro.my_project.materialization_audit_snowflake",
    "macro_sql": "{% materialization audit, adapter='snowflake' %}\n  ...\n{% endmaterialization %}",
    "depends_on": {"macros": ["macro.dbt.statement"]},
    "supported_languages": ["sql"],
    "arguments": []
  }
}

Поля, на которые dbt-core опирается при lookup:

  • name — canonical имя materialization_<name>_<adapter_or_default>. По нему ищется в get_materialization_macro.
  • package_name — определяет позицию в search order.
  • macro_sql — исходный текст блока, который Jinja потом отрендерит.
  • depends_on.macros — статически вычисленный список macros, на которые ссылается тело. Используется DAG’ом, чтобы знать, что нужно скомпилировать раньше.
  • supported_languages["sql"] для обычных materializations. Если materialization работает с Python моделями (как dbt-snowflake Python model materialization), будет ["python"] или ["sql", "python"].

manifest.json пишется в target/ после dbt parse / dbt compile. Открыв его, можно найти все materializations через jq '.macros | with_entries(select(.value.name | startswith("materialization_")))'.


Lifecycle entry point в Python: core/dbt/task/runnable.py

Самое интересное — что происходит внутри Python, когда run-фаза доходит до модели. Это другая граница ответственности: Jinja отвечает за рендер SQL, Python — за orchestration, transactions, error handling.

Цепочка вызовов (упрощённо, но с реальными именами файлов / методов):

dbt run
  |
  v
core/dbt/cli/main.py::cli_run()
  |
  v
core/dbt/task/run.py::RunTask.run()
  |
  v
core/dbt/task/runnable.py::GraphRunnableTask.execute_nodes()
  |
  v  (per-node, в ThreadPoolExecutor)
core/dbt/task/run.py::ModelRunner.execute()
  |
  v
core/dbt/task/run.py::ModelRunner.materialize()
  |
  v
core/dbt/context/providers.py::execute_macro(materialization_macro_name)

Главный entry point — ModelRunner.materialize(). Он:

  1. Получает скомпилированный SQL модели (node.compiled_code).
  2. Вычисляет имя materialization-макроса через get_materialization_macro(node.config.materialized, adapter.type()).
  3. Строит MaterializationContext с расширенными builtins (this, sql, pre_hooks, post_hooks, config, adapter, load_relation, run_hooks, statement).
  4. Вызывает Jinja-render тела материализации через execute_macro().
  5. Парсит возврат: ожидает {'relations': [...]}. Если что-то не так — оборачивает в CompilationError.
  6. Обновляет RelationsCache и пишет result в run_results.json.
Lifecycle entry point: Python -> Jinja -> Python
GraphRunnableTask.execute_nodesGraphRunnableTask.execute_nodes() берёт ноду из очереди и передаёт ModelRunner. Параллелизм управляется ThreadPoolExecutor с size=threads из profile
ModelRunner.before_execute()ModelRunner.before_execute: pre-model hooks с inside_transaction=False. Не путать с pre_hooks внутри materialization!
ModelRunner.execute()ModelRunner.execute() компилирует SELECT модели (резолвит ref, source, var, macros) и подготавливает контекст для materialization
MaterializationContext + builtinsMaterializationContext добавляет в Jinja namespace специальные builtins: this, sql, pre_hooks, post_hooks, load_relation, statement
execute_macro(mat_macro)execute_macro() в core/dbt/context/providers.py — вызывает Jinja-render тела materialization. Внутри тела вызывается statement('main') -> adapter.execute -> warehouse
Parse return: {relations: [...]}Парсинг return value. Ожидается dict с ключом relations. Иначе — CompilationError. Этот dict кладётся в RunResult.adapter_response
after_execute() + cache updateModelRunner.after_execute: обновление RelationsCache (cache_added/cache_dropped), запись в run_results.json
Write run_results.jsonrun_results.json содержит per-node RunResult: status, timing, adapter_response с relations, error если упало

Ссылки на конкретные файлы:

  • core/dbt/task/runnable.py::GraphRunnableTask — общий orchestrator.
  • core/dbt/task/run.py::ModelRunner — runner специально для model nodes.
  • core/dbt/context/providers.py::execute_macro — Jinja render с подготовленным контекстом.
  • core/dbt/context/providers.py::generate_runtime_model_context — построение MaterializationContext.
  • core/dbt/adapters/base/impl.py::BaseAdapter.execute — фактический warehouse-call внутри statement('main').

Pre/post hooks injection: где в lifecycle они срабатывают

В middle-курсе говорилось, что hooks делятся на pre/post + transaction true/false. Здесь — где именно они injected в lifecycle и какие fail-safe гарантии есть.

Поток для одной модели с pre/post hooks:

Hooks injection points
Load hooks: model + project-levelModel-level pre_hooks и post_hooks загружаются из config модели + project-level +pre-hook / +post-hook. Объединяются в один список, project-level идут первыми
run_hooks(pre, inside_tx=False)run_hooks(pre_hooks, inside_transaction=False) — Jinja-макрос фильтрует hooks по transaction=False и выполняет в порядке объявления. Каждый hook через statement()
adapter.start_transaction()adapter.start_transaction() — на transactional warehouses (Postgres/Snowflake/Redshift) — BEGIN. На autocommit (DuckDB/BigQuery) — noop
run_hooks(pre, inside_tx=True)run_hooks(pre_hooks, inside_transaction=True) — фильтр transaction=True (default). Выполняются ВНУТРИ транзакции
statement('main') — main DMLstatement('main') — основной DML. CREATE TABLE / VIEW / MERGE. Внутри транзакции
run_hooks(post, inside_tx=True)run_hooks(post_hooks, inside_transaction=True) — после main DML, но ДО commit. GRANT обычно сюда
adapter.commit()adapter.commit() — фиксация. После этого момента модель закоммичена, undo через DDL невозможно
run_hooks(post, inside_tx=False)run_hooks(post_hooks, inside_transaction=False) — ПОСЛЕ commit. ANALYZE, webhooks, audit-INSERT в external system

Ключевые fail-safe правила:

  1. До start_transaction — всё, что упало, не оставляет следа в warehouse. Out-tx pre-hooks могут только подготовить session (SET role), не должны делать destructive DDL.

  2. Между start_transaction и commit — атомарность. Любой fail = ROLLBACK. Все DDL/DML в этой зоне либо все применены, либо все откачены. Это сильнейшая гарантия dbt.

  3. После commit — точка невозврата. Out-tx post-hooks могут падать, но это уже не повлияет на состояние warehouse — relation создан и виден всем. dbt run покажет error на эту модель, downstream skipped, но это не отменяет факт создания.

DANGER

Грабли: pre-hook с transaction: True на Snowflake пытается сделать SET role = analyst_role — упадёт с Cannot perform statement within an active transaction. Snowflake’овский SET не работает внутри BEGIN. Решение: transaction: False. Это первая причина, почему hooks делятся на in-tx и out-tx.


Что инжектится в MaterializationContext

Внутри тела {% materialization %} доступен расширенный namespace по сравнению с обычным macro. Это специальный context, который собирается в core/dbt/context/providers.py::generate_runtime_model_context() только для materialization вызовов.

Доступные builtins (из MaterializationExtension.get_context()):

# Псевдокод того, что добавляет MaterializationContext поверх ModelContext
ctx['this'] = node.refs_self_as_relation()           # Relation для текущей ноды
ctx['sql'] = node.compiled_code                       # Скомпилированный SELECT
ctx['pre_hooks'] = node.config.pre_hook              # List[Hook] из config
ctx['post_hooks'] = node.config.post_hook            # List[Hook] из config
ctx['config'] = config_proxy                          # Прокси для config.get/.set/.require
ctx['adapter'] = adapter                              # BaseAdapter instance
ctx['load_relation'] = lambda r: adapter.get_relation_cached(r)
ctx['load_cached_relation'] = lambda r: adapter.get_relation_cached(r)
ctx['statement'] = StatementBlock()                  # Jinja block tag для SQL exec
ctx['run_hooks'] = run_hooks_macro                   # Helper macro из global project
ctx['model'] = node                                   # Full ModelNode dataclass

В обычном macro pre_hooks, post_hooks, statement недоступны. Поэтому helper-макросы вроде materialization_table_safe_create_as пишутся только для использования из materialization context.


Ссылки на dbt-core source

Для полной картины senior должен прочитать (это часы, не дни):

  • core/dbt/clients/jinja.py::MacroExtension.parse() — как {% materialization %} парсится.
  • core/dbt/contracts/graph/manifest.py::Manifest — где хранится. Поля macros, _get_macros_by_name.
  • core/dbt/context/macros.py::MacroResolver — registry lookup chain.
  • core/dbt/context/providers.py::generate_runtime_model_context — построение MaterializationContext.
  • core/dbt/task/run.py::ModelRunner.materialize — Python entry point.
  • core/dbt/task/runnable.py::GraphRunnableTask.execute_nodes — orchestration.
  • core/dbt/adapters/include/global_project/macros/materializations/hooks.sql — implementation run_hooks.

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

  1. Materialization хранится в Manifest.macros как ParsedMacro с canonical name materialization_<name>_<adapter_or_default>. Это та же структура, что у обычных macros, только с naming convention и is_materialization=True.

  2. Registry lookup chain в MacroResolver.get_materialization_macro: для каждого project в search order проверяется adapter-specific, потом default. Local > packages > global project.

  3. Python entry point — ModelRunner.materialize() в core/dbt/task/run.py. Он вызывает Jinja-render тела через execute_macro() с подготовленным MaterializationContext.

  4. MaterializationContext инжектит this, sql, pre_hooks, post_hooks, config, adapter, load_relation, statement, run_hooks — это builtins, которых нет в обычных macros.

  5. Hooks injection points строго определены: out-tx pre -> start_tx -> in-tx pre -> main DML -> in-tx post -> commit -> out-tx post. Fail-safe: до commit всё атомарно, после commit — точка невозврата.

  6. Behavior flag require_explicit_package_overrides_for_builtin_materializations (dbt 1.7+) защищает от accidental shadowing built-in materializations.

Проверка знанийKnowledge check
Senior пишет custom materialization. В `manifest.json` находит запись `macro.my_project.materialization_audit_default`, но при `dbt run` модель использует другую реализацию из dbt-adapters package. Почему так может быть и как дебажить?
ОтветAnswer
Это типичный senior bug, связанный с lookup chain. Несколько возможных причин.\n\n**Причина 1 — adapter-specific важнее default**.\n\nЕсли в dbt-adapters package (или другом installed package) есть `materialization_audit_<adapter_type>` — он перекрывает local default. Локальный default проигрывает adapter-specific даже из package, потому что adapter-specific ищется ДО default на каждом уровне search order.\n\nLookup chain (упрощённо):\n```\nfor project in [local, package_1, package_2, dbt-adapters, dbt-core]:\n # 1. adapter-specific first\n if f"materialization_audit_{adapter_type}" exists in project.macros:\n candidate = (...)\n # 2. then default\n if f"materialization_audit_default" exists in project.macros:\n candidate = (...)\n\n# Pick first adapter-specific across all projects; fallback to first default\n```\n\nЕсли есть `materialization_audit_snowflake` в любом package — он выиграет у local `materialization_audit_default`.\n\n**Причина 2 — behavior flag**.\n\nС dbt 1.7+ есть `require_explicit_package_overrides_for_builtin_materializations`. Если включён в `dbt_project.yml` и local materialization shadow'ит built-in без `dispatch:` config — dbt предупредит или будет использовать built-in.\n\n**Дебаг через `dbt --debug`**:\n\n```bash\ndbt --debug run --select my_audit_model 2>&1 | grep -i materialization\n```\n\nВ debug-логе будет видно, какой именно `materialization_<name>_<adapter>` был выбран — с полным `unique_id` (`macro.<package>.materialization_audit_<adapter>`).\n\n**Дебаг через `manifest.json`**:\n\n```bash\ndbt parse\njq '.macros | with_entries(select(.value.name | startswith("materialization_audit_")))' target/manifest.json\n```\n\nПокажет все варианты materialization_audit_*, которые есть в manifest. Если найдёте больше одного — там и причина.\n\n**Fix-варианты**:\n\n1. **Создать adapter-specific override**. Если хотите вашу реализацию для всех adapters — добавить `materialization_audit_<your_adapter>` в local project. Тогда на вашем adapter local выиграет.\n\n2. **Использовать dispatch config**. В `dbt_project.yml`:\n```yaml\ndispatch:\n - macro_namespace: dbt\n search_order: ['my_project', 'dbt']\n```\nЭто меняет search order для namespace `dbt` (built-ins) — local будет проверяться первым.\n\n3. **Удалить shadowing built-in materialization из package**. Если package не нужен полностью — удалить его. Если нужен другой код из этого package — issue в репо, чтобы вытащили materialization в отдельный package.\n\n**Где это в коде**:\n\n- `core/dbt/context/macros.py::MacroResolver.get_materialization_macro` — основной метод lookup.\n- `core/dbt/contracts/graph/manifest.py::Manifest._build_macro_resolver` — построение search order по `dispatch` config из `dbt_project.yml`.\n- `core/dbt/parser/manifest.py::ManifestLoader` — где manifest конструируется и куда попадают наши macros.\n\n**Production lesson**: всегда смотрите `--debug` output при отладке materialization lookup. Несколько лет debug-логи разработчики dbt улучшали — там сейчас полная информация про lookup chain.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В чём разница между {% materialization name, default %} и {% materialization name, adapter='snowflake' %}?

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

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

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

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