Lifecycle deep dive: кэш, full_refresh, swap, atomicity
Middle уже знает, что в materialization есть фазы (prepare, hooks, DML, commit, return). Senior нужны детали каждой фазы — там, где обычно ломается production: кэш желает stale relation state, should_full_refresh() принимает решение по сложному дереву, backup-rename swap имеет partial failure modes, и atomicity радикально отличается между BigQuery (CTAS = atomic) и Snowflake (требует CLONE-trick).
Recap is_incremental() + философия incremental (dbt II) Golden Rule выбора материализации (dbt I)
Cache management deep dive
RelationsCache — это in-memory структура, которая держит state о существующих relations в warehouse. Живёт она в core/dbt/adapters/cache.py::RelationsCache и инициализируется в начале каждого dbt run через list_relations_without_caching для schemas, в которые проект пишет.
Public методы, которые materialization должен дёргать в правильных местах:
load_cached_relation(relation)— read-only lookup. ВозвращаетRelationесли есть в кэше, иначеNone. Не делает live-запрос в warehouse. Это критично: при первом use cache ещё может не знать про только что созданный relation.adapter.cache_added(relation)— добавить relation в кэш. Должно быть вызвано после успешногоCREATE+commit.adapter.cache_dropped(relation)— удалить из кэша. ПослеDROP.adapter.cache_renamed(old, new)— атомарный rename. Поддерживает swap pattern.
Что если забыл обновить кэш
Симптомы зависят от того, что делает downstream-модель в том же dbt run:
Сценарий A — забыли cache_added(target) после CREATE:
Downstream incremental модель делает adapter.get_relation(this.database, this.schema, this.identifier) в начале своего materialization, чтобы определить «существует ли target уже». Без cache_added:
get_relationлезет в кэш — пусто.- Fallback:
list_relations_without_cachingдля schema — лезет в warehouse через DDL list (SnowflakeSHOW TABLES, BigQueryINFORMATION_SCHEMA.TABLES). - На eventually-consistent warehouses (Snowflake metadata service, BigQuery) свежесозданный relation может ещё не быть в list.
get_relationвозвращаетNone. - Incremental думает: «это first run», делает
CREATE TABLE ASвместо MERGE. - Перезаписывает данные. Catastrophic data loss.
Сценарий B — забыли cache_dropped(old) после rename swap:
Кэш считает, что и target (новый) и old_target_backup существуют. При следующем DDL operation на старом relation (например, ваш custom GC макрос дропает backup) — adapter.drop_relation думает, что нужно дропнуть существующий объект, лезет в warehouse — а его уже нет (был дропнут в рамках того же materialization, но кэш не знает). Ошибка relation does not exist.
Сценарий C — кэш «забыл» сам:
Бывает на параллельных runs (несколько dbt-процессов на один warehouse). Process A создаёт relation, process B держит свой in-memory кэш и не знает про него. B думает что нужно создать с нуля. Race condition.
Решение: cache_added / cache_dropped правильно. Для concurrent dbt runs — отдельный механизм через dbt-cloud или внешний lock.
Stale relation state
Stale cache — когда между фазами start_transaction и commit другой process поменял warehouse. Ваш кэш не знает.
Пример: process A в materialization дропает target, потом создаёт. Между этими операциями process B (другой dbt run) лезет за target через get_relation — кэш B всё ещё показывает старое состояние, но в warehouse оно уже дропнуто. B падает с relation does not exist.
Защита от stale cache:
- Не использовать concurrent dbt runs на одних schemas. Это самая частая причина.
adapter.cache.set_relations()— принудительный refresh кэша целиком. Дорогая операция (list для каждого schema), не для частого использования.- Использовать
--cache-selected-only(dbt 1.5+) — кэш только для тех schemas, что в selection. Уменьшает race surface.
should_full_refresh() semantics
should_full_refresh — это макрос, который incremental materialization (и custom incremental-style) вызывает чтобы решить: делаем normal incremental update или full table rebuild?
Sources of truth, которые should_full_refresh объединяет:
{% macro should_full_refresh() %}
{% set config_full_refresh = config.get('full_refresh') %}
{% if config_full_refresh is none %}
{% set config_full_refresh = flags.FULL_REFRESH %}
{% endif %}
{% set needs_full_refresh = (
config_full_refresh
or on_schema_change_full_refresh_required(this)
or first_run_or_relation_dropped(this)
) %}
{{ return(needs_full_refresh) }}
{% endmacro %}
Декомпозиция:
Force-flag через flags.FULL_REFRESH
flags.FULL_REFRESH — это глобальный flag, который инжектируется в Jinja context из CLI argument --full-refresh. Внутри dbt-core: core/dbt/flags.py::Flags.FULL_REFRESH.
Важный нюанс: flags.FULL_REFRESH применяется ко всем моделям, у которых config(full_refresh=None) (то есть не указано явно). Если в модели config(full_refresh=False) — CLI --full-refresh не сработает. Это позволяет защитить экспенсивную модель от accidental full refresh.
{{ config(
materialized='incremental',
full_refresh=false -- защищает от --full-refresh CLI flag
) }}
SELECT * FROM {{ ref('events') }}
{% if is_incremental() %}
WHERE event_date >= (SELECT MAX(event_date) FROM {{ this }}) - INTERVAL '7 day'
{% endif %}
При dbt run --full-refresh эта модель не будет full-refreshed.
on_schema_change semantics
on_schema_change определяет, что делать если SELECT возвращает новый набор колонок:
ignore(default) — игнорировать новые колонки, MERGE по существующим.fail— упасть с error.append_new_columns—ALTER TABLE ADD COLUMNдля новых, потом MERGE.sync_all_columns— добавить новые + дропнуть отсутствующие.
should_full_refresh сам не реагирует на schema change. Решение принимается отдельно через process_schema_changes() макрос внутри incremental materialization (в dbt-adapters/dbt/include/global_project/macros/materializations/models/incremental/on_schema_change.sql).
Backup-rename-swap pattern: failure modes
Backup-rename-swap — production pattern для атомарного replace в transactional warehouses. Структура:
1. existing_relation -> backup_relation (rename)
2. CREATE temp_relation AS (...)
3. temp_relation -> target_relation (rename)
4. DROP backup_relation
Каждый шаг — отдельный DDL statement, не один atomic transaction (DDL обычно auto-commit в warehouse). Это создаёт partial failure windows.
Self-healing leftover cleanup в production materialization:
{% materialization custom_table, default %}
{%- set existing_relation = load_cached_relation(this) -%}
{%- set target_relation = this.incorporate(type='table') -%}
{%- set intermediate_relation = make_intermediate_relation(target_relation) -%}
{%- set backup_relation = make_backup_relation(target_relation, 'table') -%}
{# Self-healing: cleanup leftovers from previously failed run #}
{%- set leftover_intermediate = load_cached_relation(intermediate_relation) -%}
{%- set leftover_backup = load_cached_relation(backup_relation) -%}
{% if leftover_intermediate is not none %}
{{ log("Dropping leftover from previous run: " ~ leftover_intermediate, info=True) }}
{{ adapter.drop_relation(leftover_intermediate) }}
{% endif %}
{% if leftover_backup is not none %}
{{ log("Dropping leftover backup: " ~ leftover_backup, info=True) }}
{{ adapter.drop_relation(leftover_backup) }}
{% endif %}
{# ... rest of materialization #}
{% endmaterialization %}
Это обязательный паттерн в production custom materialization. Без него __dbt_tmp и __dbt_backup копятся в schema и засоряют namespace.
Atomic vs non-atomic warehouses
Atomicity DDL операций радикально различается между warehouses. Это диктует, какие materializations safe «as is», а какие требуют hack.
BigQuery: CTAS atomic — упрощённый materialization
Для BigQuery CREATE OR REPLACE TABLE это single DDL operation, atomic с точки зрения metadata service. Downstream queries никогда не видят intermediate state.
{% materialization table, adapter='bigquery' %}
{%- set target_relation = this.incorporate(type='table') -%}
{{ run_hooks(pre_hooks, inside_transaction=False) }}
{% call statement('main') -%}
CREATE OR REPLACE TABLE {{ target_relation }}
{{ partition_by_clause(config) }}
{{ cluster_by_clause(config) }}
OPTIONS({{ table_options(config) }})
AS (
{{ sql }}
)
{%- endcall %}
{{ run_hooks(post_hooks, inside_transaction=False) }}
{{ adapter.cache_added(target_relation) }}
{{ return({'relations': [target_relation]}) }}
{% endmaterialization %}
Никакого intermediate_relation, backup_relation, no rename swap. CTAS делает всё атомарно. dbt-bigquery materialization именно так и реализован — посмотрите dbt-bigquery/dbt/include/bigquery/macros/materializations/table.sql.
Snowflake: CLONE + RENAME hack для zero-downtime
На Snowflake CREATE OR REPLACE TABLE тоже atomic в metadata, но сбрасывает permissions / row access policies / data masking. Это критично для production — каждый dbt run стирает GRANT.
Production workaround — zero-copy CLONE + RENAME:
{% materialization table_preserve_grants, adapter='snowflake' %}
{%- set target_relation = this.incorporate(type='table') -%}
{%- set tmp_relation = make_intermediate_relation(target_relation) -%}
{# Create new table в tmp без stripping permissions #}
{% call statement('main') -%}
CREATE OR REPLACE TABLE {{ tmp_relation }} AS ({{ sql }})
{%- endcall %}
{# Snowflake zero-copy CLONE для preserving permissions #}
{% if load_cached_relation(target_relation) is not none %}
{%- set backup_relation = make_backup_relation(target_relation, 'table') -%}
{% call statement('grants_swap') -%}
ALTER TABLE {{ target_relation }} RENAME TO {{ backup_relation.identifier }};
ALTER TABLE {{ tmp_relation }} RENAME TO {{ target_relation.identifier }};
{# Permissions copy через CLONE was applied to target_relation — теперь снова grants #}
GRANT SELECT ON TABLE {{ target_relation }} TO ROLE analyst;
DROP TABLE {{ backup_relation }};
{%- endcall %}
{% else %}
{% call statement('rename_initial') -%}
ALTER TABLE {{ tmp_relation }} RENAME TO {{ target_relation.identifier }}
{%- endcall %}
{% endif %}
{{ adapter.cache_added(target_relation) }}
{{ return({'relations': [target_relation]}) }}
{% endmaterialization %}
Это hack — каждый раз GRANT приходится переаплаить. Более чистый подход — Snowflake COPY GRANTS clause:
CREATE OR REPLACE TABLE {{ target_relation }} COPY GRANTS AS (
{{ sql }}
)
COPY GRANTS сохраняет permissions при CREATE OR REPLACE. Это native Snowflake feature, появилась в 2020. dbt-snowflake поддерживает через copy_grants config option.
Redshift: обязательный backup-swap
Redshift не поддерживает CREATE OR REPLACE TABLE. Производственный path только через backup-rename-swap. dbt-redshift materialization (см. dbt-redshift/dbt/include/redshift/macros/materializations/table.sql) делает классический 4-шаговый swap.
Где смотреть в dbt-core
core/dbt/adapters/cache.py::RelationsCache— implementation кэша,cache_added,cache_dropped,cache_renamed.core/dbt/adapters/base/impl.py::BaseAdapter.list_relations_without_caching— fallback, когда кэш empty.dbt-adapters/dbt/include/global_project/macros/materializations/models/incremental/should_full_refresh.sql— implementation.dbt-adapters/dbt/include/global_project/macros/materializations/models/table/table.sql— reference table materialization с backup-swap.dbt-bigquery/dbt/include/bigquery/macros/materializations/table.sql— упрощённая CTAS-based.dbt-snowflake/dbt/include/snowflake/macros/materializations/table.sql— COPY GRANTS pattern.dbt-redshift/dbt/include/redshift/macros/materializations/table.sql— обязательный full swap.
Ключевые выводы
-
RelationsCache— in-memory state о warehouse.load_cached_relationread-only,cache_added/cache_droppedmutate. Забылиcache_added-> downstream incremental думает «first run» -> CREATE поверх MERGE -> data loss. -
should_full_refresh()объединяет:config.full_refresh(override),flags.FULL_REFRESH(CLI), on_schema_change check, existing_relation is None.config(full_refresh=False)защищает от--full-refreshCLI. -
Backup-rename-swap имеет 4 partial failure windows. Production materialization обязан делать self-healing cleanup в начале — load leftover intermediate/backup и drop.
-
Atomicity warehouse-specific: BigQuery CTAS atomic — упрощает materialization. Snowflake atomic но стирает grants — COPY GRANTS или CLONE hack. Redshift / Postgres — обязательный backup-swap.
-
Snowflake COPY GRANTS — native preservation permissions при CREATE OR REPLACE. dbt-snowflake config option
copy_grants=true.