Learning Platform
Глоссарий Troubleshooting
Урок 08.02 · 30 мин
Продвинутый
MaterializationLifecycleRelationsCacheFull refreshAtomicity

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.
Cache state transitions в materialization

Что если забыл обновить кэш

Симптомы зависят от того, что делает downstream-модель в том же dbt run:

Сценарий A — забыли cache_added(target) после CREATE:

Downstream incremental модель делает adapter.get_relation(this.database, this.schema, this.identifier) в начале своего materialization, чтобы определить «существует ли target уже». Без cache_added:

  1. get_relation лезет в кэш — пусто.
  2. Fallback: list_relations_without_caching для schema — лезет в warehouse через DDL list (Snowflake SHOW TABLES, BigQuery INFORMATION_SCHEMA.TABLES).
  3. На eventually-consistent warehouses (Snowflake metadata service, BigQuery) свежесозданный relation может ещё не быть в list. get_relation возвращает None.
  4. Incremental думает: «это first run», делает CREATE TABLE AS вместо MERGE.
  5. Перезаписывает данные. 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 %}

Декомпозиция:

should_full_refresh decision tree
config.get('full_refresh')config(full_refresh=true) в модели — explicit override. Главный приоритет
flags.FULL_REFRESH (CLI --full-refresh)CLI flag --full-refresh из dbt run. Превращается в flags.FULL_REFRESH. Применяется ко всем моделям в run если config(full_refresh=None)
on_schema_change checkon_schema_change='fail' и схема изменилась — full_refresh. Зависит от incremental_strategy и adapter capabilities
existing_relation is Noneget_relation(this) is None — relation не существует. Первый run или relation был дропнут вручную. Принудительно full_refresh
final: full_refresh = OR of allФинальное решение: OR всех условий. Любой true -> full_refresh=true

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_columnsALTER 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.

Backup-rename-swap failure modes

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.

Atomicity DDL per warehouse

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.

Ключевые выводы

  1. RelationsCache — in-memory state о warehouse. load_cached_relation read-only, cache_added/cache_dropped mutate. Забыли cache_added -> downstream incremental думает «first run» -> CREATE поверх MERGE -> data loss.

  2. should_full_refresh() объединяет: config.full_refresh (override), flags.FULL_REFRESH (CLI), on_schema_change check, existing_relation is None. config(full_refresh=False) защищает от --full-refresh CLI.

  3. Backup-rename-swap имеет 4 partial failure windows. Production materialization обязан делать self-healing cleanup в начале — load leftover intermediate/backup и drop.

  4. Atomicity warehouse-specific: BigQuery CTAS atomic — упрощает materialization. Snowflake atomic но стирает grants — COPY GRANTS или CLONE hack. Redshift / Postgres — обязательный backup-swap.

  5. Snowflake COPY GRANTS — native preservation permissions при CREATE OR REPLACE. dbt-snowflake config option copy_grants=true.

Проверка знанийKnowledge check
Senior пишет custom materialization для Snowflake, который должен делать atomic replace с сохранением grants. Какой production approach выбрать и почему?
ОтветAnswer
Snowflake имеет три варианта для atomic replace с preservation grants. Правильный выбор зависит от объёма данных и tolerance к downtime.\n\n**Вариант 1 — `CREATE OR REPLACE TABLE ... COPY GRANTS AS` (рекомендуется)**:\n\n```jinja\n{% materialization table_atomic, adapter='snowflake' %}\n {%- set target_relation = this.incorporate(type='table') -%}\n\n {% call statement('main') %}\n CREATE OR REPLACE TABLE {{ target_relation }}\n COPY GRANTS AS (\n {{ sql }}\n )\n {% endcall %}\n\n {{ adapter.cache_added(target_relation) }}\n {{ return({'relations': [target_relation]}) }}\n{% endmaterialization %}\n```\n\n**Плюсы**:\n\n1. **Native Snowflake feature**. Permissions preserved при CREATE OR REPLACE. Single DDL, atomic.\n2. **No backup-swap**. Меньше DDL roundtrips, проще materialization.\n3. **Includes row access policies / data masking** — все security applied to target сохраняются.\n4. **Cost**: один CREATE OR REPLACE — Snowflake billing по compute времени + storage. Без дополнительной CLONE storage.\n\n**Минусы**:\n\n1. **Не работает для cross-database / cross-schema swaps**. Только в той же database.schema.\n2. **Atomic с точки зрения metadata, но не для existing queries**. Снапшот queries, которые читают target в момент DDL — могут получить stale data или ошибку. Time Travel в Snowflake защищает.\n\n**Вариант 2 — Zero-copy CLONE + RENAME swap**:\n\n```jinja\n{# 1. Создаём new table #}\n{% call statement('create_tmp') %}\n CREATE TABLE {{ tmp_relation }} AS ({{ sql }})\n{% endcall %}\n\n{# 2. Применяем grants from existing (если есть) через manual GRANT statements #}\n{% if existing_relation is not none %}\n {% set existing_grants = adapter.get_grants(existing_relation) %}\n {% for role in existing_grants.select %}\n {% call statement('grant_' ~ role) %}\n GRANT SELECT ON TABLE {{ tmp_relation }} TO ROLE {{ role }}\n {% endcall %}\n {% endfor %}\n{% endif %}\n\n{# 3. Rename swap #}\n{% if existing_relation is not none %}\n {% call statement('rename_old') %}\n ALTER TABLE {{ existing_relation }} RENAME TO {{ backup_relation.identifier }}\n {% endcall %}\n{% endif %}\n{% call statement('rename_new') %}\n ALTER TABLE {{ tmp_relation }} RENAME TO {{ target_relation.identifier }}\n{% endcall %}\n\n{# 4. Drop backup #}\n{% if existing_relation is not none %}\n {% call statement('drop_backup') %}\n DROP TABLE {{ backup_relation }}\n {% endcall %}\n{% endif %}\n```\n\n**Плюсы**:\n\n1. **Lower compute cost** для частых rebuilds большой таблицы. CLONE — zero-copy в Snowflake, не платите за storage пока не меняете.\n2. **Backup есть до drop** — manual rollback возможен если что-то критическое сломалось.\n3. **Granular control over grants** — можете добавить / удалить кастомные roles.\n\n**Минусы**:\n\n1. **Не atomic** — 4 DDL operations. Partial failure windows.\n2. **Race condition с concurrent queries** — query, которая читает target во время rename swap, может получить relation does not exist.\n3. **Storage cost для backup** — даже если zero-copy, metadata overhead.\n4. **Permission edge cases**: future grants, ownership transfer, masking policies — все эти complex permissions сложнее реплицировать вручную.\n\n**Вариант 3 — `SWAP WITH` (Snowflake 2023+, less common in dbt)**:\n\n```sql\nCREATE TABLE {{ tmp_relation }} AS ({{ sql }});\nALTER TABLE {{ target_relation }} SWAP WITH {{ tmp_relation }};\nDROP TABLE {{ tmp_relation }};\n```\n\nSWAP atomic exchanges names + permissions. Single DDL для swap. Но dbt-snowflake пока не использует, потому что COPY GRANTS проще.\n\n**Production recommendation**:\n\n- **Default для большинства cases**: `COPY GRANTS` через config option `copy_grants=true` в встроенной dbt-snowflake materialization. Не нужна custom materialization.\n\n- **Custom case с granular grants**: явный CLONE+RENAME pattern (Вариант 2). Используется в companies с complex security model (ABAC policies, row access policies на runtime).\n\n- **Very large tables с частыми rebuilds**: Иногда CLONE дешевле, чем CREATE OR REPLACE (CLONE = metadata-only, CTAS = full data write). Профилируйте `QUERY_HISTORY` чтобы понять, что для вашей таблицы дешевле.\n\nГде это в dbt-snowflake:\n\n- `dbt-snowflake/dbt/include/snowflake/macros/materializations/table.sql` — implementation с `copy_grants` config\n- `dbt-snowflake/dbt/include/snowflake/macros/adapters.sql::snowflake__copy_grants` — helper\n- В `adapter.get_relation` есть hook для inspect existing grants\n\n**Главный takeaway**: на Snowflake почти всегда `COPY GRANTS`. Custom materialization с manual swap — только когда COPY GRANTS не покрывает (cross-schema, conditional grants).

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Какие 6 фаз lifecycle materialization, в правильном порядке?

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

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

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

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