Partial parsing: partial_parse.msgpack изнутри
Parse phase — самый дорогой шаг pipeline на больших проектах. Без оптимизации 1500 моделей парсятся 30-60 секунд. Partial parsing — встроенная в dbt-core оптимизация, сводящая warm parse к 1-3 сек.
В этом уроке мы разберём: что записывается в partial_parse.msgpack, как считается hash для инвалидации, что инвалидирует кэш, и какие senior-gotchas с env vars, jinja-функциями, dbt deps.
Что такое partial parsing
Идея простая: если файлы проекта не изменились с прошлого parse, повторно парсить их незачем. Можно сохранить результат parse (Manifest) на диск и загрузить при следующем запуске.
partial_parse.msgpack — это msgpack-сериализованный snapshot Manifest + дополнительные metadata для инвалидации. Расположение — target/partial_parse.msgpack (или другой target_path если кастомный).
При следующем dbt run:
- ManifestLoader сначала проверяет
partial_parse.msgpack. - Если есть и метаданные совпадают — загружает Manifest из msgpack, пропускает full parse.
- Если файлы изменились — пере-парсит изменённые, обновляет Manifest, сохраняет новый snapshot.
# core/dbt/parser/manifest.py (упрощённо)
class ManifestLoader:
@classmethod
def get_full_manifest(cls, config, reset=False):
loader = cls(config, all_projects)
# 1. Try partial parse
if not reset and config.partial_parse:
saved_manifest = loader.read_manifest_for_partial_parse()
if saved_manifest and loader.matches_current_state(saved_manifest):
# Adjust for changed files
manifest = loader.partial_parse(saved_manifest)
return manifest
# 2. Fallback to full parse
manifest = loader.full_parse()
loader.write_manifest_for_partial_parse(manifest)
return manifest
Что хранится в msgpack
partial_parse.msgpack содержит сериализованный Manifest + project_hash + env_vars hash + files metadata:
@dataclass
class PartialManifestData:
manifest: Manifest # сам Manifest object
project_hash: str # hash dbt_project.yml + profiles.yml
env_vars_hash: str # hash всех env vars, использованных при render
files: Dict[str, FileMetadata] # {file_path: FileMetadata(checksum, mtime)}
FileMetadata для каждого parsed файла:
@dataclass
class FileMetadata:
checksum: str # sha256 содержимого
mtime: float # mtime для quick check
Msgpack chosen потому что:
- Быстрее JSON для serialization/deserialization (~3-5x на больших объектах)
- Меньше размер — typical 50MB Manifest становится 15-20MB msgpack
- Schema-less — не требует жёсткой схемы, dataclasses сериализуются нативно
Алгоритм инвалидации
При следующем dbt run:
def matches_current_state(self, saved_manifest_data) -> bool:
# Check 1: project_hash
current_project_hash = compute_project_hash(self.root_config)
if current_project_hash != saved_manifest_data.project_hash:
log("dbt_project.yml or profiles.yml changed — full re-parse")
return False
# Check 2: env_vars_hash
current_env_hash = compute_env_vars_hash(saved_manifest_data.env_vars_used)
if current_env_hash != saved_manifest_data.env_vars_hash:
log("env vars changed — full re-parse")
return False
# Check 3: files changed
# Будет partial — не full re-parse
return True
def partial_parse(self, saved_manifest_data) -> Manifest:
manifest = saved_manifest_data.manifest
# Find changed files
changed_files = self.detect_changed_files(saved_manifest_data.files)
# Re-parse only changed
for file_path in changed_files:
self.re_parse_file(manifest, file_path)
# Process refs again (могут измениться зависимости)
self._process_refs()
self._build_parent_child_maps()
return manifest
Три уровня инвалидации:
- Project-level (
dbt_project.yml,profiles.ymlchanged) — full re-parse. - Env-vars-level (any env_var used in render changed) — full re-parse.
- File-level (specific
.sql,.ymlchanged) — partial re-parse only those files.
Что попадает в env_vars_hash
dbt отслеживает все env vars, к которым обращались macros/configs/Jinja:
# Псевдокод of env var tracking
env_vars_used = set()
def env_var(name: str, default=None) -> str:
env_vars_used.add(name) # tracked
return os.environ.get(name, default)
При сохранении msgpack:
env_vars_snapshot = {name: os.environ.get(name) for name in env_vars_used}
env_vars_hash = sha256(serialize(env_vars_snapshot))
При следующем run:
current_env_snapshot = {name: os.environ.get(name) for name in env_vars_used}
current_env_hash = sha256(serialize(current_env_snapshot))
if current_env_hash != saved.env_vars_hash:
# Re-parse — env var changed
Самая частая senior-проблема: вы добавили {{ env_var('NEW_VAR', 'default') }} в одну из YAML configs. Каждый CI run set’ит NEW_VAR=ci_value, локально не set. Каждый CI run инвалидирует partial parse cache. Symptom: CI parsing медленный.
File detection algorithm
Для каждого parsed файла dbt сохраняет mtime + checksum.
def detect_changed_files(self, saved_files: Dict[str, FileMetadata]) -> List[str]:
changed = []
current_files = walk_all_project_files()
# Removed files
for path in saved_files:
if path not in current_files:
changed.append(path) # marked for removal
# Added files
for path in current_files:
if path not in saved_files:
changed.append(path) # new file
# Modified files
for path, saved_meta in saved_files.items():
if path not in current_files:
continue
current_mtime = os.path.getmtime(path)
if current_mtime != saved_meta.mtime:
# mtime changed — quick check failed, check checksum
current_checksum = compute_checksum(path)
if current_checksum != saved_meta.checksum:
changed.append(path)
return changed
Two-stage check:
- mtime — fast (filesystem call). Если mtime same, file unchanged.
- checksum — slow (read file, sha256). Только если mtime отличается.
Это оптимизация: 99% случаев mtime даёт ответ без чтения. Только если git pull или touch trigger’ит mtime изменение без content change — checksum проверяет.
Что инвалидирует partial parse cache
Senior-gotchas
Gotcha 1: env var с default не инвалидирует, если default тот же
{{ env_var('MY_VAR', 'default_value') }}
Если MY_VAR не set, dbt использует 'default_value'. Если в следующем run MY_VAR set’нут в 'default_value' — hash будет same, кэш валиден. Если в 'other_value' — invalid.
Gotcha 2: env var в macros, который не вызывается на parse
{% macro my_macro() %}
{{ env_var('RARELY_USED_VAR') }}
{% endmacro %}
Если my_macro() не вызывается на parse phase (только runtime), env var не tracked. Изменение RARELY_USED_VAR не инвалидирует кэш — но также и не использовано в Manifest, что нормально.
Gotcha 3: dynamic generate_schema_name
{% macro generate_schema_name(custom_schema_name, node) %}
{% if env_var('CI', 'false') == 'true' %}
{{ custom_schema_name | trim }}
{% else %}
{{ env_var('USER') }}_{{ custom_schema_name | trim }}
{% endif %}
{% endmacro %}
USER env var — это login имя в Unix, отличается между разработчиками. Каждый разработчик имеет свой кэш. На общей CI machine — USER=runner или USER=jenkins, hash same между runs (until разработчики push).
Если у вас CI каждый раз делает full re-parse — посмотрите какие env vars вы используете на parse.
Gotcha 4: file mtime после git pull
git pull устанавливает mtime файлов в момент checkout, не сохраняет original mtime. Если 100 файлов изменились в pull — все 100 будут partial re-parsed (что нормально). Но если только metadata изменилась — partial parse спасает.
Gotcha 5: —no-partial-parse флаг
dbt parse --no-partial-parse — full re-parse forced. Полезно для дебага, CI clean builds. Не нужен в обычной работе.
Gotcha 6: —partial-parse-file-diff
Скрытая опция (1.10+): --partial-parse-file-diff <file> — указать explicitly список изменённых файлов. Используется в advanced CI sequences, когда вы знаете diff.
Дебаг partial parse
# Debug log показывает что произошло с partial parse
DBT_LOG_LEVEL=debug dbt parse 2>&1 | grep -i "partial\|parse"
Типичный вывод:
[debug] Loading partial parse manifest from disk
[debug] partial parse: 1 file changed, 0 files added, 0 files removed
[debug] partial parsing changed file models/staging/stg_users.sql
[debug] Loaded existing manifest from disk
Или (full re-parse):
[debug] Full parse of project because: env var DBT_FOO changed
dbt-core конкретно говорит причину full re-parse. Это invaluable для debug.
Программный доступ к msgpack
Senior может прочитать partial_parse.msgpack напрямую:
import msgpack
from dbt.contracts.graph.manifest import Manifest
with open('target/partial_parse.msgpack', 'rb') as f:
data = msgpack.unpackb(f.read())
# data — это dict с keys: manifest, project_hash, env_vars_hash, files
print(data['files']) # {file_path: {checksum, mtime}}
print(data['env_vars_hash']) # hash
Использование:
- Дебаг инвалидации — сравнить saved env_vars_hash и текущий.
- Observability — кто изменил какие файлы между двумя runs.
- CI cache key — использовать file checksums как cache key для GitHub Actions cache.
Performance numbers
На production проекте 1500 моделей, измерения примерные:
| Сценарий | Time | Comment |
|---|---|---|
| Cold (no msgpack) | 30-60 сек | Full parse — first run или после rm |
| Warm (msgpack hit) | 1-3 сек | All files unchanged |
| Warm + 1 file changed | 1.5-3 сек | One model re-parsed, refs re-processed |
| Warm + 10 files changed | 2-5 сек | 10 models re-parsed |
| Warm + project file changed | 30-60 сек | Full re-parse triggered |
| Warm + env var changed | 30-60 сек | Full re-parse triggered |
Production lesson: minimize project-file и env var changes в active development. Если возможно — keep env vars stable, change в profiles.yml через --target switches.
Попробуй сам
-
Замерьте cold vs warm:
rm -rf target/ time dbt parse # cold time dbt parse # warm -
Поменяйте одну модель, replay:
touch models/staging/stg_users.sql time dbt parse # должно быть warm — 1-3 sec -
Поменяйте
dbt_project.yml, replay:echo "" >> dbt_project.yml # добавить пустую строку time dbt parse # full re-parse -
Trigger env var inval:
export TEST_VAR=foo dbt parse # cache invalidated, full re-parse export TEST_VAR=foo # same value dbt parse # cache valid (same hash) export TEST_VAR=bar dbt parse # cache invalidatedНо только если
env_var('TEST_VAR')где-то в проекте на parse phase. -
Прочитайте msgpack программно:
python -c " import msgpack with open('target/partial_parse.msgpack', 'rb') as f: data = msgpack.unpackb(f.read()) print('Project hash:', data.get('project_hash')) print('Env vars hash:', data.get('env_vars_hash')) print('Files tracked:', len(data.get('files', {}))) "