Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 04.01 · 25 мин
Продвинутый
jinjaexecuteparse-phase

Две фазы Jinja: parse (execute=False) и execute (execute=True)

Это самая частая senior-проблема в dbt: «макрос работает, потом перестаёт парситься», «model падает с TypeError на пустом dict при dbt parse», «adapter.dispatch не находит default__macro». В 90% случаев причина — непонимание двух фаз выполнения Jinja и того, какие объекты доступны в каждой.

В этом уроке мы разберём что такое execute flag, как он работает в dbt-core, и научимся писать macros, которые не ломают parse phase.


Концепт двух фаз

Когда dbt parsing’ит проект, он выполняет Jinja-код для извлечения метаданных:

  • Что такое {{ config(materialized='table') }} — нужно выполнить config() чтобы записать в Manifest
  • Что такое {{ ref('foo') }} — нужно зарегистрировать зависимость
  • Что такое {% if target.name == 'prod' %} {{ ref('a') }} {% else %} {{ ref('b') }} {% endif %} — нужно выполнить (с пустым target если parse phase нет realный) или хотя бы скипнуть

Но dbt не хочет выполнять SQL queries во время parse — это slow, requires connection, может fail если warehouse недоступен.

Решение: переменная execute в Jinja context:

  • execute = Falseparse phase. Render Jinja, но не выполнять реальный SQL. run_query(), statement(), adapter.execute() возвращают None или dummy values.
  • execute = Trueexecution phase. Реальный SQL шлётся в warehouse, results возвращаются.
{% if execute %}
  {# safe to query warehouse #}
  {% set results = run_query('SELECT 1') %}
  {{ log("got results: " ~ results, info=True) }}
{% else %}
  {# parse phase — skip real queries #}
  {{ log("parse phase, skipping", info=True) }}
{% endif %}

Senior правило: любая макро-логика, которая делает реальный SQL или зависит от warehouse state, должна быть обёрнута в {% if execute %}.

execute flag: parse phase vs execute phase (dbt I)

Где execute приходит из

В dbt-core execute — это атрибут context’а. Конкретно — ProviderContext:

# core/dbt/context/providers.py (упрощённо)
class ProviderContext(ConfiguredContext):
    @contextproperty
    def execute(self) -> bool:
        return self.provider.execute

class ParseProvider:
    execute = False  # parse phase

class RuntimeProvider:
    execute = True   # execution phase

Когда dbt парсит model, он создаёт context с ParseProvider -> execute=False. Когда выполняет model — RuntimeProvider -> execute=True.

В Jinja execute доступен глобально как переменная, не как функция:

{# Корректно #}
{% if execute %}
  ...
{% endif %}

{# Неправильно — execute() не function #}
{% if execute() %}
  ...
{% endif %}

Что доступно на parse phase

Что в Jinja context на parse vs execute
Всегда (parse + execute)Базовые объекты, доступные на обеих фазах. ref/source/config — мутируют Manifest и работают.
ref('name')Регистрирует зависимость в Manifest. Возвращает Relation object. Доступен на parse — иначе DAG не построится.
source('schema', 'name')Аналогично ref для sources. Регистрирует зависимость.
config(...)Setting model config из SQL file. Сохраняется в Manifest на parse.
var('name')Reads project vars. Available на parse — может использоваться в config(), ref() args.
env_var('NAME')Reads env. Available на parse — может быть в configs, schemas.
targetTarget info: name, type, schema, database. Available на parse — может быть в configs.
thisRelation object текущей модели. Available на parse — используется в configs (e.g. unique_key=this.something).
log()Print messages. Available always.
exceptions.raise_compiler_errorThrow error. Available always.
Только execute=TrueОбъекты, требующие connection к warehouse. На parse фазе они undefined или return dummy.
statement('main')Send SQL to warehouse, get response. На parse — undefined behavior, не вызывать.
run_query(sql)Execute arbitrary SQL, return agate.Table. На parse — returns None.
adapter.get_columns_in_relationQuery warehouse для column info. На parse — returns empty list.
load_result(name)Получить result statement по имени. На parse — None.
adapter.dispatchTricky. Available на parse для resolution macros, но adapter macros внутри могут зависеть от execute.
model objectCurrent model dict. Available на parse и execute — но с разной полнотой полей.
graph objectManifest as graph. Available после parse phase (на execute). На parse phase — partial.

Anti-pattern 1: run_query без if execute

{# WRONG #}
{% set max_date = run_query("SELECT MAX(date) FROM " ~ source('raw', 'orders')) %}
{% set max_date_str = max_date.columns[0].values()[0] %}
SELECT * FROM {{ source('raw', 'orders') }} WHERE date > '{{ max_date_str }}'

На parse phase run_query returns None. None.columns[0] -> AttributeError. dbt parse падает.

{# CORRECT #}
{% if execute %}
  {% set max_date = run_query("SELECT MAX(date) FROM " ~ source('raw', 'orders')) %}
  {% set max_date_str = max_date.columns[0].values()[0] %}
{% else %}
  {% set max_date_str = '2020-01-01' %}  {# dummy для parse #}
{% endif %}

SELECT * FROM {{ source('raw', 'orders') }} WHERE date > '{{ max_date_str }}'

На parse — max_date_str = '2020-01-01', rendered SQL valid. На execute — реальное значение из warehouse.


Anti-pattern 2: adapter calls без if execute

{# WRONG #}
{% set columns = adapter.get_columns_in_relation(ref('upstream')) %}
{% for col in columns %}
  {{ col.name }} as {{ col.name }}_aliased,
{% endfor %}

На parse get_columns_in_relation returns empty list. Result — пустой SELECT. На parse rendered SQL — invalid SQL, потенциальный fail в later validation.

{# CORRECT #}
{% if execute %}
  {% set columns = adapter.get_columns_in_relation(ref('upstream')) %}
  {% set column_names = columns | map(attribute='name') | list %}
{% else %}
  {% set column_names = [] %}
{% endif %}

SELECT
  {% if column_names %}
    {% for name in column_names %}
      {{ name }} as {{ name }}_aliased
      {%- if not loop.last %},{% endif %}
    {% endfor %}
  {% else %}
    1 as placeholder
  {% endif %}
FROM {{ ref('upstream') }}

placeholder нужен для parse-time valid SQL. На execute заменяется реальными колонками.


Anti-pattern 3: statement() в model без contexts

{# WRONG в models/my_model.sql #}
{% call statement('count_check', fetch_result=True) %}
  SELECT COUNT(*) FROM {{ ref('upstream') }}
{% endcall %}

{% set count = load_result('count_check')['data'][0][0] %}

{% if count > 1000 %}
  SELECT * FROM {{ ref('upstream') }} WHERE active = TRUE
{% else %}
  SELECT * FROM {{ ref('upstream') }}
{% endif %}

statement() в model — это only execute phase. На parse rendered модель будет странной.

Также conditional logic based on warehouse data в model body — это anti-pattern. SQL должен быть deterministic на compile time, не зависеть от warehouse state. Lead это в pre-hook или separate model.


Гибридный pattern: conditional execution

{# Сравнение row count между текущей моделью и upstream #}
{% if execute %}
  {% set this_count = run_query("SELECT COUNT(*) FROM " ~ this).columns[0].values()[0] %}
  {% set upstream_count = run_query("SELECT COUNT(*) FROM " ~ ref('upstream')).columns[0].values()[0] %}
  
  {% if this_count < upstream_count * 0.9 %}
    {{ exceptions.raise_compiler_error("Suspicious row drop: " ~ this_count ~ " vs " ~ upstream_count) }}
  {% endif %}
{% endif %}

SELECT * FROM {{ ref('upstream') }}

На parse — вся {% if execute %} секция skipped, model rendered просто как SELECT * FROM upstream. На execute — runs row count check after model executes (it’s a model body, runs during materialization).

Hmm, но это actually wrong — this table не существует на момент materialization main statement. Этот pattern лучше в post-hook. Но иллюстрирует mental model.


Pattern: adapter.dispatch с macros

{# materializations часто используют dispatch #}
{% macro my_helper(arg) %}
  {{ return(adapter.dispatch('my_helper')(arg)) }}
{% endmacro %}

{% macro default__my_helper(arg) %}
  -- generic SQL
{% endmacro %}

{% macro snowflake__my_helper(arg) %}
  -- Snowflake-specific
{% endmacro %}

adapter.dispatch('my_helper')available на parse. Резолвится в правильный adapter-specific macro based on target.type.

adapter.dispatch: multi-warehouse macros (dbt II)

На parse phase target.type known (из profiles.yml), так что dispatch работает correctly. dispatch это не runtime-only.

Но что внутри macros может быть execute-only:

{% macro snowflake__my_helper(arg) %}
  {% if execute %}
    {% set rows = run_query("SHOW TABLES LIKE '" ~ arg ~ "'") %}
    -- ...
  {% endif %}
{% endmacro %}

run_query внутри snowflake__my_helper — execute-only.


Какой context на parse phase

ProviderContext с ParseProvider:

# core/dbt/context/providers.py (упрощённо)
class ParseProvider:
    execute = False
    
    def ref(self, *args, **kwargs):
        # На parse — регистрируем dependency, возвращаем placeholder Relation
        target_unique_id = self._resolve_ref(*args, **kwargs)
        self.model.refs.append(RefArgs(*args))  # track
        return self._build_placeholder_relation(target_unique_id)
    
    def source(self, *args, **kwargs):
        # Аналогично — register dependency
        self.model.sources.append(list(args))
        return self._build_placeholder_relation(*args)
    
    def config(self, **kwargs):
        # Setting model config — mutates Manifest
        self.model.config.update_from_dict(kwargs)
    
    def adapter(self):
        # Returns ParseProvider's adapter — most methods return dummy
        return self.parse_time_adapter
    
    def statement(self, name, ...):
        # Skipped on parse — does nothing
        return None

На execute phase — RuntimeProvider:

class RuntimeProvider:
    execute = True
    
    def ref(self, *args, **kwargs):
        # На execute — returns real Relation для SQL rendering
        return self._build_full_relation(*args, **kwargs)
    
    def statement(self, name, ...):
        # Sends SQL to warehouse, stores result
        result = self.adapter.execute(sql, fetch=fetch_result)
        self._results[name] = result
        return result

Real-world: configs that depend on warehouse

Часто senior хочет сделать config dynamically:

{# модель: dim_users.sql #}
{{ config(
    materialized='incremental',
    unique_key=get_pk_from_metadata(ref('upstream'))
) }}

get_pk_from_metadata — custom macro, который запрашивает warehouse metadata. Это anti-pattern.

config() вызывается на parse phase. На parse у вас нет access к warehouse. get_pk_from_metadata — она ничего не сможет узнать.

Решение:

  1. Explicit unique_key в config — не magic.
  2. Pre-hook query + var:
    {{ config(
        materialized='incremental',
        unique_key='id'  -- explicit
    ) }}
  3. Macro that returns hardcoded based on table name:
    `{% macro get_pk(table_name) %}`
      `{% if table_name == 'dim_users' %}` user_id
      `{% elif table_name == 'dim_orders' %}` order_id
      `{% else %}` id
      `{% endif %}`
    `{% endmacro %}`
    
    `{{ config(unique_key=get_pk('dim_users')) }}`

Все эти solutions deterministic на parse phase. Никаких warehouse queries.


Дебаг execute issues

Если вы подозреваете что в макросе execute issue:

{% macro my_macro() %}
  {{ log("execute=" ~ execute, info=True) }}
  {{ log("target.name=" ~ target.name, info=True) }}
  
  {% if execute %}
    {{ log("RUNNING IN EXECUTE PHASE", info=True) }}
  {% else %}
    {{ log("RUNNING IN PARSE PHASE", info=True) }}
  {% endif %}
  
  -- остальной macro
{% endmacro %}

log(..., info=True) выводит в terminal. Запустите:

dbt parse  # видите PARSE PHASE
dbt compile --select my_model  # видите EXECUTE PHASE

Если log вышли как ожидаются — execute flag правильно tracked. Если нет — есть context issue.


Попробуй сам

  1. Создайте model с if execute pattern:
    -- models/test_execute.sql
    `{{ config(materialized='view') }}`
    
    `{% if execute %}`
      `{{ log("EXECUTE phase", info=True) }}`
    `{% else %}`
      `{{ log("PARSE phase", info=True) }}`
    `{% endif %}`
    
    SELECT 1 as id
  2. Запустите:
    dbt parse 2>&1 | grep -i "PARSE phase"
    # Должно быть: видите PARSE phase
    
    dbt run --select test_execute 2>&1 | grep -i "phase"
    # Должно быть: видите PARSE (из re-parse) и EXECUTE
  3. Попробуйте anti-pattern:
    -- модель без if execute
    `{% set result = run_query("SELECT 1") %}`
    SELECT `{{ result.columns[0].values()[0] }}` as val
    dbt parse  # должно fail с AttributeError на None.columns
  4. Fix добавлением if execute:
    `{% if execute %}`
      `{% set result = run_query("SELECT 1") %}`
      `{% set val = result.columns[0].values()[0] %}`
    `{% else %}`
      `{% set val = 0 %}`
    `{% endif %}`
    
    SELECT `{{ val }}` as val
  5. Прочитайте core/dbt/context/providers.py в клонированном dbt-core. Найдите ParseProvider и RuntimeProvider. Сравните execute свойство.

Проверка знанийKnowledge check
У вас в команде есть макрос get_relation_columns_safe(rel) который сначала пытается через adapter.get_columns_in_relation, если возвращает empty — fallback на parsing YAML. Макрос вызывается из materialization. dbt run работает, dbt parse падает с TypeError on None.iter. Что в коде проблема и как исправить sustainable?
ОтветAnswer
Корень проблемы — adapter.get_columns_in_relation returns None или empty list на parse phase (execute=False). Когда вы итерируете 'for col in columns: ...' и columns is None, TypeError. Macro вызывается из materialization, но materializations *тоже* parse'ятся (для validation что они существуют и имеют правильную сигнатуру), хотя их main body выполняется только на execute. Sustainable fix: (1) Wrap в if execute: внутри get_relation_columns_safe сначала {% if execute %} {% set cols = adapter.get_columns_in_relation(rel) %} {% else %} {% set cols = [] %} {% endif %}. На parse возвращает empty list, на execute — реальные columns. (2) Защитить итерацию: {% for col in (columns or []) %} — даже если None, не fail. Defensive coding. (3) Если макрос *должен* возвращать non-empty result для logic в materialization — раскладывается на parse-time-safe path. Например, на parse phase возвращайте dummy ['placeholder_col']. На execute — реальный list. Logic в materialization должен tolerate "empty" parse-phase output. (4) Структурный fix: rethink design. Если materialization логика зависит от warehouse metadata, это сложный case. Альтернатива — explicit columns в model.yml config, не runtime introspection. (5) Add log: {{ log("get_relation_columns_safe: execute=" ~ execute ~ ", returned " ~ cols | length ~ " cols", info=True) }}. Тогда в next run вы сразу видите на какой phase macro работает. Sustainable pattern: ВСЕГДА wrap'ить warehouse-dependent macros в if execute, всегда возвращать sensible default из else-branch (empty list, 0, '', not None). Это makes macros safe to call from any context.

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

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

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

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