Learning Platform
Глоссарий Troubleshooting
Урок 03.01 · 24 мин
Продвинутый
parsertree-sitterperformance

Tree-sitter и static parsing: зачем dbt парсит SQL без Jinja

Когда dbt парсит модель customers.sql, его задача — извлечь метаданные: какие ref(), какие source(), какие config(), какие включаемые macros. Эти метаданные попадают в Manifest и используются для построения DAG.

Очевидное решение — выполнить Jinja-рендеринг с execute=False и парсить результат. Но это медленно: 50-200мс на модель × 1000 моделей = 50-200 секунд только на parse. На производительных проектах это критический боттлнек.

Tree-sitter — это альтернатива: статический парсинг SQL+Jinja без выполнения Jinja-кода. В этом уроке мы разберём что такое tree-sitter, как он используется в dbt-core, какие модели он умеет обрабатывать, какие нет, и почему это даёт 30-100x ускорение.


Что такое tree-sitter

Tree-sitter — это incremental parser generator, изначально написанный для Atom editor (потом перенят VS Code, Neovim, GitHub). Его use case в editor — syntax highlighting и code navigation на больших файлах: парсинг должен быть быстрым (миллисекунды) и поддерживать incremental updates (когда вы добавляете символ, переparsить нужно только окрестность).

Технически tree-sitter — это:

  • Parser generator — описываете grammar (типа BNF), tree-sitter генерирует парсер на C.
  • Runtime library — runtime на C с bindings для многих языков (Python, JavaScript, Rust).
  • Grammar repositoriestree-sitter-python, tree-sitter-javascript, tree-sitter-sql, tree-sitter-jinja2, …

В dbt-core используется специальный grammar — tree-sitter-jinja-stripper (репозиторий dbt-labs/tree-sitter-jinja-stripper). Это grammar, который понимает Jinja внутри SQL и может извлекать specific patterns.


Что делает tree-sitter в dbt-core

Tree-sitter не пытается выполнить Jinja. Он парсит исходник модели как AST (abstract syntax tree) и матчит specific patterns:

Jinja: expressions, statements, comments (dbt I)
  • {{ ref('foo') }} — pattern для ref calls
  • {{ source('schema', 'name') }} — pattern для sources
  • {{ config(materialized='table') }} — pattern для config calls
  • {{ var('my_var') }} — pattern для var calls
  • Comments {# ... #} — игнорируются
  • Conditional {% if ... %} — игнорируется (но содержимое внутри парсится)

Из этих matches извлекается список refs, sources, configs модели — без рендеринга, без выполнения Python кода macros.

# Псевдокод того что делает tree-sitter wrapper в dbt
def extract_metadata(raw_code: str) -> ModelMetadata:
    tree = tree_sitter.parse(raw_code, grammar='jinja-sql')
    refs = []
    sources = []
    configs = {}
    for node in tree.walk():
        if matches_ref_pattern(node):
            refs.append(extract_ref_args(node))
        elif matches_source_pattern(node):
            sources.append(extract_source_args(node))
        elif matches_config_pattern(node):
            configs.update(extract_config_args(node))
    return ModelMetadata(refs=refs, sources=sources, configs=configs)

Реальный код в dbt-core живёт в core/dbt/parser/manifest.py и связанных файлах, использует Python tree_sitter package + dbt’s wrapper.


Сколько это ускоряет

Сравнение для модели среднего размера (~100 строк):

ApproachTime per model1000 моделей
Full Jinja render (execute=False)50-200 мс50-200 сек
Tree-sitter static parse0.5-2 мс0.5-2 сек

30-100x speedup на typical production workload. На больших проектах с 5000+ моделей это разница между dbt parse за минуту и за час.

Но не каждая модель пригодна для tree-sitter parsing. Если static parser не справляется — dbt fallback’ится на full Jinja render.


Когда tree-sitter справляется

Когда tree-sitter справляется vs fallback
Простая модельref/source/config в прямом виде. Никаких циклов, никаких variable assignments, никаких сложных условных.
100% статика — ref('foo')Argument к ref — это string literal. Не переменная, не вызов функции. Tree-sitter извлекает 'foo' напрямую.
Условный ref{% if target.name == 'dev' %} ref('foo') {% else %} ref('bar') {% endif %}. Tree-sitter видит оба ref внутри branches, добавляет обе зависимости — pessimistic.
Both refs addedDAG получит depends_on на оба foo и bar. Что норм — execution выберет правильный, parse pessimistic.
Loop with ref{% for tbl in ['a', 'b', 'c'] %} {{ ref(tbl) }} {% endfor %}. Tree-sitter видит ref(tbl), но tbl — переменная. Не может извлечь.
Full Jinja renderЗапускается execute=False render. Loop unrolled, refs резолвятся. Медленнее.
Custom macro{{ my_macro_returning_ref() }}. Tree-sitter не может предсказать что macro возвращает.
Full Jinja renderMacro выполняется, refs извлекаются из output.

Правило: если все аргументы ref/source — это string literals, и нет ref внутри loops/dynamic expressions — tree-sitter справляется. Если есть variables, functions, comprehensions — fallback.

В типичных production проектах 80-95% моделей парсятся через tree-sitter. Это даёт большую часть ускорения.


Как tree-sitter интегрирован в parser pipeline

# core/dbt/parser/manifest.py (упрощённо)
class ManifestLoader:
    def _load_node(self, file: AnyFile):
        raw_code = file.contents
        
        # Step 1: Try tree-sitter static parsing
        try:
            metadata = static_parser.parse(raw_code)
            # Got refs, sources, configs without Jinja
            node = build_model_node(file, metadata)
            self.manifest.nodes[node.unique_id] = node
            return
        except StaticParsingError:
            pass  # fallback
        
        # Step 2: Fallback — full Jinja render
        node = self._render_node_fully(file)
        self.manifest.nodes[node.unique_id] = node

StaticParsingError — это когда tree-sitter не справился (нашёл dynamic expressions в ref args). dbt-core гладко fallback’ится без шума пользователю. Но в --debug логах будет видно static parser failure on model X, falling back to full render.


Tree-sitter и partial parsing

Partial parsing (следующий урок) — это отдельная оптимизация: при изменении одного файла не пере-парсим всё. Tree-sitter — про скорость parse одной модели. Они независимы и работают вместе:

  • Cold start без partial parse: tree-sitter сильно ускоряет. Парсит ~5000 моделей за 5-15 сек вместо 5-15 минут.
  • Warm start с partial parse: даже без tree-sitter было бы быстро (msgpack load), но tree-sitter ускоряет parse тех файлов, что изменились.

В dbt Fusion (модуль 12) parsing полностью на Rust — там tree-sitter уступает место SDF compiler с собственным parsing pipeline. Но идея та же — static analysis вместо Jinja render.

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

Ограничения static parsing

WARNING

Senior должен понимать ограничения tree-sitter — это критично для дебага «почему модель не находится в DAG».

  1. Dynamic refs не извлекаются автоматически. Пример:

    `{% for table in get_tables_via_macro() %}`
      SELECT * FROM `{{ ref(table) }}`
    `{% endfor %}`

    table — переменная, tree-sitter pessimistically не извлекает ref. Fallback на full render.

  2. String concatenation в args:

    `{{ ref('stg_' ~ entity) }}`

    'stg_' ~ entity — это expression, не literal. Tree-sitter не извлекает. Fallback.

  3. Refs внутри SQL comments:

    -- TODO: use ref('staging_users') eventually
    SELECT * FROM `{{ ref('raw_users') }}`

    Tree-sitter может ошибочно поднять ref('staging_users') из комментария. На самом деле dbt-core grammar обрабатывает это корректно — -- comments стрипаются перед поиском patterns. Но это типичный gotcha для custom parsers.

  4. Custom Jinja-функции, которые возвращают строки:

    `{% set my_ref = make_ref('foo') %}`
    `{{ my_ref }}`

    Tree-sitter не выполняет custom функции. Fallback.


Дебаг static parsing failures

Senior может проверить, какие модели падают на static parser и fallback’ятся:

DBT_LOG_LEVEL=debug dbt parse 2>&1 | grep "static parser"

В выводе:

[debug] 2026-05-19 ... static parser failure on model.my_project.dynamic_model, falling back to full render
[debug] 2026-05-19 ... static parser failure on model.my_project.macro_ref, falling back to full render

Если у вас сотни таких сообщений — parse phase медленнее, чем мог бы быть. Reduction strategy:

  1. Rewrite dynamic refs в explicit list. Вместо for table in get_tables():

    `{% for table in ['foo', 'bar', 'baz'] %}`
      `{{ ref(table) }}`
    `{% endfor %}`

    Tree-sitter видит string literals в loop body, может справиться (на новых версиях dbt).

  2. Move dynamic logic в for_each configs (1.10+):

    - name: my_model
      config:
        for_each:
          - { name: 'foo' }
          - { name: 'bar' }

    Parser обрабатывает на уровне YAML — статически.

  3. Принять fallback как trade-off. На 50 dynamic моделей это +5-10 сек parse. Часто not worth refactor.


Tree-sitter в действии: пример

Возьмём модель:

-- models/marts/dim_customers.sql
{{ config(materialized='table', schema='marts') }}

{% if target.name == 'prod' %}
  SELECT * FROM {{ ref('stg_customers_prod') }}
{% else %}
  SELECT * FROM {{ ref('stg_customers_dev') }} LIMIT 1000
{% endif %}

JOIN {{ source('crm', 'accounts') }} USING (account_id)

Tree-sitter AST (упрощённо):

file
├── jinja_statement: config(materialized='table', schema='marts')
│   └── extracted: configs = {materialized: 'table', schema: 'marts'}
├── if_block
│   ├── condition: target.name == 'prod'
│   ├── then_branch:
│   │   └── jinja_expr: ref('stg_customers_prod')
│   │       └── extracted: refs.append(RefArgs(name='stg_customers_prod'))
│   └── else_branch:
│       └── jinja_expr: ref('stg_customers_dev')
│           └── extracted: refs.append(RefArgs(name='stg_customers_dev'))
├── sql_text: "JOIN"
└── jinja_expr: source('crm', 'accounts')
    └── extracted: sources.append(SourceArgs(name='accounts', source_name='crm'))

Результат tree-sitter parse:

ModelMetadata(
    refs=[
        RefArgs(name='stg_customers_prod'),
        RefArgs(name='stg_customers_dev'),
    ],
    sources=[
        SourceArgs(source_name='crm', name='accounts'),
    ],
    configs={'materialized': 'table', 'schema': 'marts'},
)

Обе ref’ы добавлены — pessimistic. DAG получит зависимость на обе модели stg_customers_prod и stg_customers_dev. Execution выберет одну based on target.name. Это правильное поведение — нельзя предсказать target.name в parse time.

Time: ~1 мс на этой модели. Full Jinja render был бы ~50 мс.


Попробуй сам

  1. Включите debug log на parse:
    DBT_LOG_LEVEL=debug dbt parse 2>&1 | tee parse.log
  2. Найдите static parser activity:
    grep "static parser" parse.log | head -20
  3. Найдите fallback cases:
    grep "falling back to full render" parse.log
  4. Если у вас большой проект — замерьте time с и без static parser:
    # С static parser
    time dbt parse
    
    # Without (если доступен флаг — в dbt-core 1.11 есть DBT_PARTIAL_PARSE=false, но static parser всегда on)
  5. Откройте core/dbt/parser/ в клонированном dbt-core. Файлы связанные с tree-sitter — core/dbt/parser/search.py, core/dbt/parser/manifest.py. Используется package dbt-extractor (на Rust!), который wrapped вокруг tree-sitter.

Проверка знанийKnowledge check
У вас в проекте есть модель, которая использует кастомный макрос get_tables_for_env() возвращающий list строк, и в loop делает ref(table) для каждой. Static parsing fallback'ится на эту модель. Какие подходы решить это без переписать макрос?
ОтветAnswer
Несколько подходов в order от простого к sophisticated. (1) Принять fallback — на одной модели это ~50мс vs ~1мс, на масштабе проекта незаметно. Если у вас 5 таких моделей — это +250мс на parse, не критично. (2) Заменить макрос на ref-config — если возможно, выписать список tables напрямую через YAML config (for_each в dbt 1.10+): models/my_model.yml с config.for_each = [tables list]. Parser обрабатывает on YAML level статически. (3) Сделать loop явным — заменить for table in get_tables_for_env() на for table in ['t1', 't2', 't3'] в SQL файле. Less DRY, но static parser справится с string literals. (4) Pre-compute список в pre-hook — если макрос делает что-то комплексное (читает БД), вынести в pre-hook, передать через var. Но это не help static parser. (5) Использовать ref-objects напрямую через depends_on: yaml: models/my_model.yml с config.depends_on = [...] (explicit). Тогда parser использует YAML, не SQL inference. (6) Жить с этим — на больших проектах parse доминируется fixed costs (file walk, Manifest serialization), не Jinja render. 5-10 dynamic моделей не делают погоды. Diagnose first: time dbt parse с и без этой модели, посмотрите реальную delta. Если delta меньше 500мс на проекте 1000 моделей — оптимизировать не стоит, читаемость макроса важнее.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Tree-sitter в dbt-core используется для извлечения метаданных моделей (refs, sources, configs) без полного Jinja-рендеринга. Какой главный benefit и какая trade-off?

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

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

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

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