—defer: deep dive в механику
В предыдущем уроке мы запускали dbt build --select state:modified+ --state ./prod/. Но был один кусок неявный: что произойдёт, если изменённая модель зависит от unchanged? Например, новая модель fct_orders использует {'{{ ref(\'stg_customers\') }}'}, но stg_customers не менялась.
Без --defer: dbt будет искать stg_customers в CI schema (pr_123.stg_customers), которая пустая -> ERROR relation does not exist.
С --defer: dbt вместо построения unchanged моделей в CI ссылается на prod schema (prod.stg_customers). Запрос работает.
В этом уроке — детальный разбор как defer работает, что копируется/нет, какие edge cases.
Базовая механика defer
dbt build \
--select state:modified+ \
--defer \
--state ./prod-state/ \
--target ci
Visually это похоже на:
-- Без --defer
CREATE TABLE pr_123.fct_orders AS
SELECT * FROM pr_123.stg_customers c
INNER JOIN pr_123.stg_orders o
ON c.id = o.customer_id
-- ERROR: pr_123.stg_customers не существует
-- С --defer
CREATE TABLE pr_123.fct_orders AS
SELECT * FROM prod.stg_customers c -- ← deferred
INNER JOIN pr_123.stg_orders o -- ← новая модель из CI
ON c.id = o.customer_id
-- OK: читаем prod, пишем в CI
—state vs —defer-state
В dbt 1.7+ появилось разделение:
| Флаг | Что значит |
|---|---|
--state | manifest для comparison (определить modified). Без deferral. |
--defer-state | manifest для deferral (резолв ссылок в prod). Без comparison. |
--state + --defer | один manifest и для comparison, и для defer (классический Slim CI). |
В типичном Slim CI используют третий вариант — один и тот же manifest. Но в сложных кейсах можно разделить:
# Чуть продвинутый: сравниваем с одним manifest, defer на другой
dbt build \
--select state:modified+ \
--state ./staging-manifest/ \
--defer-state ./prod-manifest/
Например, staging-manifest — это manifest от integration tests (где stg_customers уже включает новую колонку), а prod-manifest — production (где старая версия). Можно различить modified от prod, но defer на staging.
В большинстве случаев — один manifest. Не усложняйте без необходимости.
Что копируется/не копируется
Defer работает только на чтение. Не копирует данные, не создаёт таблицы. dbt просто переписывает ref()/source() в compiled SQL.
| Что | Действие при --defer |
|---|---|
| Unchanged models (ref) | Резолвится в prod schema |
Sources (source()) | Резолвится как обычно — в schema, заданную в _sources.yml |
| Seeds | Резолвится в prod schema (если есть в manifest) |
| Snapshots | Резолвится в prod schema |
| Test’ы | Резолвятся через ту же логику |
source() интересен — он не deferred, потому что source — это external таблица (raw данные от Fivetran). И в dev, и в prod source ссылается на одну и ту же raw схему. Defer не нужен.
Ограничения и подводные камни
1. Cross-schema permissions
Если в prod CI пользователь не имеет SELECT на prod schema — defer упадёт:
ERROR: SQL compilation error: schema "PROD" does not exist or not authorized
Решение — grant’ы:
-- В Snowflake
GRANT USAGE ON SCHEMA PROD TO ROLE CI_ROLE;
GRANT SELECT ON ALL TABLES IN SCHEMA PROD TO ROLE CI_ROLE;
GRANT SELECT ON FUTURE TABLES IN SCHEMA PROD TO ROLE CI_ROLE;
Без FUTURE TABLES grant — каждая новая prod таблица не будет видна CI.
2. Incremental модели и unique_key conflicts
{'{{ config(materialized=\'incremental\', unique_key=\'order_id\') }}'}
SELECT * FROM {'{{ ref(\'stg_orders\') }}'}
С --defer defer-резолв даёт prod.stg_orders. Если order_id 123 уже есть в prod.fct_orders (production result), то новый PR build тоже создаст 123 в pr_123.fct_orders. Но это разные таблицы — конфликт не происходит.
Реальный edge case: разработчик меняет unique_key='order_id' на unique_key='order_uuid'. Slim CI запускает fct_orders (изменилась config). Но prod.stg_orders может не иметь колонки order_uuid — она появится только после merge. Defer ссылается на prod stg_orders -> колонка отсутствует -> SQL error.
Решение — fast follow PRs. Сначала smerge изменения в stg_orders (с новой колонкой order_uuid), потом отдельный PR на fct_orders.
3. Custom schema mismatch
Если в проекте используется generate_schema_name с target.name == 'prod'-conditional — defer всегда резолвится в production-схему.
Но если custom_schema задаётся через config:
models:
jaffle_shop:
finance:
+schema: finance # final schema = analytics_finance в prod
В prod manifest хранится database: analytics, schema: analytics_finance. Defer резолвит туда. Это работает корректно.
Гочча — когда CI target использует другой database. Например, prod в ANALYTICS_DB, CI в CI_DB. Defer всегда смотрит на manifest’s database. Если в prod manifest стоит ANALYTICS_DB.analytics_finance.fct_orders — defer оттуда и возьмёт.
4. dbt версия mismatch
Если prod manifest от dbt 1.10, а CI на dbt 1.11 — структура manifest может различаться. dbt пытается читать legacy формат, но не всегда корректно. Решение — после upgrade dbt в prod, дать пройти полному prod build, потом продолжить Slim CI.
—defer без —select
Можно запустить defer на ВЕСЬ build без selection:
dbt build --defer --state ./prod-state/
Это эквивалентно dbt build (полный run), где unchanged модели резолвятся в prod. Использование:
- Хотите проверить весь DAG на изменения логики в макросе/конфиге.
- Тестируете большой refactor где Slim CI ненадёжен.
Но обычно это лишний compute — full Slim CI ловит то же самое.
—defer для конкретного селектора
Можно использовать defer вне Slim CI — для частичных runs:
# Запустить только marts/, читать staging/intermediate из prod
dbt build --select marts.* --defer --state ./prod-state/
Это удобно когда хотите сфокусироваться на конкретном слое:
- В development: «я работаю над marts, не хочу строить staging заново».
- В CI: «нужно протестировать marts с свежими данными staging из prod».
defer + tests
Тесты тоже deferred. Когда тест unique пишется в:
models:
- name: stg_customers
columns:
- name: customer_id
data_tests:
- unique
Тест компилируется в SQL:
SELECT customer_id, COUNT(*) FROM {'{{ ref(\'stg_customers\') }}'} GROUP BY 1 HAVING COUNT(*) > 1
С --defer ref('stg_customers') для unchanged модели резолвится в prod.stg_customers. Тест проверяет prod данные, не CI build.
Это полезно — мы убеждаемся, что unique уже соблюдается в prod (если PR не изменил эту модель).
Но есть гочча: если ваша CI среда хочет полную проверку (изолированно), это не то поведение. Можно skipать тесты defer’нутых моделей:
dbt test --select state:modified --defer --state ./prod-state/
# Тесты только на изменённые модели, не downstream
Производительность defer
dbt-iii: manifest.json use cases — Slim CI, observability, costDefer практически бесплатен на чтение — это просто переписывание ref() в SQL. Но есть compute стоимость:
- Чтение prod таблиц через JOIN — оплачивается тем, кто платит за CI compute. На Snowflake это CI warehouse читает данные prod таблиц.
- No data movement — defer не копирует данные между schemas. Это критично для performance.
Сравнение:
Без defer (нужно построить ВСЕ unchanged модели в CI):
- 200 моделей × 10MB средний = 2GB записи в CI schema
- Время: 20 минут
- Стоимость: $5
С defer (читаем prod, пишем только изменённое):
- 13 моделей × 10MB = 130MB записи
- Время: 3 минуты
- Стоимость: $0.5
Дебагинг defer
При проблемах с defer полезно посмотреть compiled SQL:
dbt compile --select fct_orders --defer --state ./prod-state/
cat target/compiled/jaffle_shop/models/marts/fct_orders.sql
Откроется compiled SQL. Если defer работает правильно — увидите prod.schema.stg_customers для unchanged моделей. Если нет — увидите ci.pr_123.stg_customers (defer не сработал).
Частые причины не-работы:
- Забыли
--defer(только--state). - Модель в selection (state:modified+ её включает — она будет строиться в CI, не defer).
- Manifest не имеет нужной модели (старая версия без неё).
Полный workflow с defer
- name: Slim CI build with defer
run: |
dbt build \
--target ci \
--select state:modified+ \
--defer \
--state ./prod-state/ \
--fail-fast
# Можно ещё параллельно тесты только на изменённые
- name: Tests on modified models
run: |
dbt test \
--target ci \
--select state:modified \
--defer \
--state ./prod-state/
В реальности dbt build включает и тесты и snapshots — обычно одной командой достаточно.
Попробуй сам
В вашем dbt-проекте на DuckDB. DuckDB — это single-file db, defer работает немного по-другому (нет cross-database), но принцип тот же.
- Сделайте clean build:
dbt build --target dev
cp target/manifest.json ./prod-state/manifest.json
- Создайте новую модель
models/marts/mrt_test_defer.sqlкоторая ссылается на существующую:
SELECT customer_id, name, NOW() AS test_time
FROM {'{{ ref(\'stg_customers\') }}'}
- Запустите с defer:
dbt parse
dbt build --select state:modified+ --defer --state ./prod-state/ --target dev
Только mrt_test_defer должна построиться.
- Посмотрите compiled SQL:
cat target/compiled/<project>/models/marts/mrt_test_defer.sql
Увидите как {'{{ ref(\'stg_customers\') }}'} резолвится — для DuckDB это будет main.stg_customers (prod schema).
- Без defer:
dbt build --select state:modified+ --state ./prod-state/ --target dev
Тоже сработает (потому что в DuckDB всё в одной db), но в production warehouse без --defer это бы упало с “relation does not exist”.
Бонус: попробуйте удалить prod-state/manifest.json и запустите. Увидите как dbt падает потому что не может resolve modified.
Ключевые выводы
- —defer — flag для dbt. Заставляет ref() unchanged моделей резолвиться в
--statemanifest (prod), а не CI schema. - Защита от “relation does not exist” — без defer Slim CI падает на ref() к unchanged моделям, потому что они не built в
pr_<NUMBER>schema. - Defer работает только на чтение. Не копирует таблицы, не дублирует данные. Просто переписывает ref() в compiled SQL.
- —state vs —defer-state:
--stateдля comparison (modified),--defer-stateдля resolution. Обычно используют один manifest для обоих. - Cross-schema permissions — CI user должен иметь SELECT на prod schema. Включая
FUTURE TABLES(для новых production таблиц). - Edge cases: incremental с изменением unique_key, custom schemas, dbt version mismatch — могут ломать defer. Решения: fast follow PRs, grant’ы, version sync.
- Дебагинг:
dbt compile+ просмотр compiled SQL покажет правильно ли работает defer. - Производительность: defer экономит compute и storage (нет дублирования данных в CI), но добавляет cross-schema reads (минимальная стоимость).