Materialization как Jinja-макрос особого типа
В middle-курсе вы уже видели синтаксис {% materialization %} и писали external_parquet / immutable_table для DuckDB. Этот урок не повторяет — мы спускаемся ниже Jinja-слоя и смотрим: что именно хранится в Manifest про materialization, как dbt-core находит «правильную» реализацию через registry lookup chain, где Python вступает в игру, и какие fail-safe гарантии есть у hooks.
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:
- Извлекает
nameиadapter(илиdefault) из параметров. - Строит canonical macro name:
materialization_<name>_<adapter_or_default>. Например,{% materialization view, default %}превращается в макросmaterialization_view_default.{% materialization audit, adapter='snowflake' %}— вmaterialization_audit_snowflake. - Кладёт получившийся
ParsedMacroвManifest.macrosпод полным unique_id видаmacro.<package>.materialization_<name>_<adapter>. - Дополнительно помечает запись флагом
is_materialization=TrueвParsedMacro.metadata— это используется при lookup чтобы отфильтровать обычные макросы.
То есть с точки зрения хранилища manifest не различает materialization и обычный macro — это всё ParsedMacro объекты. Различие — в naming convention (materialization_<name>_<adapter>) и в специальном execution context во время run-фазы.
Ссылка на код: 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 модуля.
В dbt 1.7+ есть behavior flag require_explicit_package_overrides_for_builtin_materializations (в dbt_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-snowflakePython 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(). Он:
- Получает скомпилированный SQL модели (
node.compiled_code). - Вычисляет имя materialization-макроса через
get_materialization_macro(node.config.materialized, adapter.type()). - Строит
MaterializationContextс расширенными builtins (this,sql,pre_hooks,post_hooks,config,adapter,load_relation,run_hooks,statement). - Вызывает Jinja-render тела материализации через
execute_macro(). - Парсит возврат: ожидает
{'relations': [...]}. Если что-то не так — оборачивает вCompilationError. - Обновляет
RelationsCacheи пишет result вrun_results.json.
Ссылки на конкретные файлы:
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:
Ключевые fail-safe правила:
-
До
start_transaction— всё, что упало, не оставляет следа в warehouse. Out-tx pre-hooks могут только подготовить session (SET role), не должны делать destructive DDL. -
Между
start_transactionиcommit— атомарность. Любой fail = ROLLBACK. Все DDL/DML в этой зоне либо все применены, либо все откачены. Это сильнейшая гарантия dbt. -
После
commit— точка невозврата. Out-tx post-hooks могут падать, но это уже не повлияет на состояние warehouse — relation создан и виден всем. dbt run покажет error на эту модель, downstream skipped, но это не отменяет факт создания.
Грабли: 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— implementationrun_hooks.
Ключевые выводы
-
Materialization хранится в
Manifest.macrosкакParsedMacroс canonical namematerialization_<name>_<adapter_or_default>. Это та же структура, что у обычных macros, только с naming convention иis_materialization=True. -
Registry lookup chain в
MacroResolver.get_materialization_macro: для каждого project в search order проверяется adapter-specific, потом default. Local > packages > global project. -
Python entry point —
ModelRunner.materialize()вcore/dbt/task/run.py. Он вызывает Jinja-render тела черезexecute_macro()с подготовленнымMaterializationContext. -
MaterializationContextинжектитthis,sql,pre_hooks,post_hooks,config,adapter,load_relation,statement,run_hooks— это builtins, которых нет в обычных macros. -
Hooks injection points строго определены: out-tx pre -> start_tx -> in-tx pre -> main DML -> in-tx post -> commit -> out-tx post. Fail-safe: до commit всё атомарно, после commit — точка невозврата.
-
Behavior flag
require_explicit_package_overrides_for_builtin_materializations(dbt 1.7+) защищает от accidental shadowing built-in materializations.