Learning Platform
Глоссарий Troubleshooting
Урок 05.04 · 25 мин
Средний
Snapshotshard_deletesSoft deleteDuckDBMigration

hard_deletes: ignore / invalidate / new_record

dbt snapshot по умолчанию не замечает удалений в source. Если строка пропала — её последняя версия остаётся «активной» с устаревшими данными. Это default hard_deletes: ignore.

В dbt 1.9+ появилась нормальная альтернатива: конфиг hard_deletes с тремя значениями. Этот урок — как они работают, как мигрировать с legacy invalidate_hard_deletes, и что делать на DuckDB, где hard_deletes не реализован.


Три опции конфига

snapshots:
  - name: customers_snapshot
    relation: source('app', 'customers')
    config:
      schema: snapshots
      unique_key: customer_id
      strategy: timestamp
      updated_at: updated_at
      hard_deletes: invalidate  # или 'ignore' (default) / 'new_record'
ЗначениеПоведениеКогда выбирать
ignore (default)Пропавшая строка остаётся активной с устаревшими данными. dbt_valid_to не закрывается.Source гарантирует append-only / soft-delete. Удалений не бывает.
invalidateПропавшая строка закрывается: dbt_valid_to = run_started_at. Активной версии больше нет.Source действительно удаляет строки (или soft-deleted строки нужно исключать из active).
new_recordПропавшая строка закрывается + создаётся новая «sentinel»-версия с dbt_is_deleted = TRUE.Нужна явная пометка «удалено», а не просто отсутствие активной версии. Audit / compliance.
hard_deletes: три варианта поведения после DELETE в source
ignoreignore: пропавшая строка остаётся с dbt_valid_to=NULL. Snapshot не видит DELETE. Это default.
invalidateinvalidate: пропавшая строка закрывается. dbt_valid_to = run_started_at. Активной версии нет.
new_recordnew_record: dbt_valid_to закрывается + новая sentinel-строка с dbt_is_deleted=TRUE как явная пометка.

Что меняется в схеме snapshot при new_record

Если выбрали hard_deletes: new_record, к snapshot добавляется колонка:

КолонкаТипЗначение
dbt_is_deletedBOOLEAN / TEXTTRUE для «delete-сигнала», FALSE (или NULL) для обычных версий.

Downstream запросы могут различать:

-- Активные клиенты сейчас
SELECT *
FROM customers_snapshot
WHERE dbt_valid_to IS NULL
  AND dbt_is_deleted = FALSE;  -- исключаем явно удалённых

-- Когда был удалён клиент 42?
SELECT dbt_valid_from AS deleted_at
FROM customers_snapshot
WHERE customer_id = 42
  AND dbt_is_deleted = TRUE;

С invalidate той же логики добиться сложнее: «нет активной версии» может означать как удаление, так и баг загрузки. С new_record явная пометка — proof of deletion.


Когда выбирать какую опцию

GDPR и право на забвение — почему hard_deletes критичны для compliance

ignore (default):

  • Source action гарантирует, что строки никогда не удаляются (только soft-delete с флагом).
  • Snapshot для immutable таблиц.
  • Не критично знать «когда удалили». Например, в небольшом проекте c append-only данными.

invalidate:

  • Source может физически удалять строки.
  • Downstream-логика просто фильтрует WHERE dbt_valid_to IS NULL.
  • Достаточно знать, что строки «больше нет», не нужно знать «когда удалена».

new_record:

  • Compliance / regulatory требования. Нужен audit trail удалений.
  • Аналитика «когда / сколько удалений в день».
  • BI-отчёты, где нужно явно показывать удалённых клиентов с пометкой.

В production я бы предложил new_record как default для customer-related snapshots (compliance), invalidate для technical reference dimensions, ignore — только если source гарантирован append-only.


Миграция с legacy invalidate_hard_deletes

До dbt 1.9 был только один булевый флаг: invalidate_hard_deletes: true / false. Это эквивалентно hard_deletes: invalidate / ignore соответственно.

# Старый синтаксис (deprecated)
config:
  invalidate_hard_deletes: true

# Новый синтаксис 1.9+
config:
  hard_deletes: invalidate

Что нужно сделать при миграции с 1.8 -> 1.9+:

  1. Заменить ключ в YAML: invalidate_hard_deletes: true -> hard_deletes: invalidate. invalidate_hard_deletes: false -> hard_deletes: ignore (или просто удалить — это default).
  2. Проверить тесты. dbt 1.9 даёт WARN на старый ключ, в 1.10+ может стать ERROR.
  3. Опционально мигрировать на new_record для audit-critical snapshots.
# Если в проекте много snapshot — поиск-замена
grep -rn "invalidate_hard_deletes" snapshots/

После замены — dbt snapshot --select snapshot:* должен пройти без warnings.

NOTE

В legacy .sql-snapshot с Jinja-блоком {% snapshot %} тоже работает новый ключ:

{{
    config(
      strategy='timestamp',
      hard_deletes='invalidate'
    )
}}

Алгоритм dbt при разных hard_deletes

Рассмотрим в деталях, какой SQL генерирует dbt.

ignore (default):

-- 1. Закрываем changed versions (что есть в source и в snapshot, но изменилось)
UPDATE customers_snapshot
SET dbt_valid_to = source.updated_at
WHERE customer_id IN (changed_ids)
  AND dbt_valid_to IS NULL;

-- 2. INSERT new versions
INSERT INTO customers_snapshot (...)
SELECT ... FROM source
WHERE customer_id IN (new_ids + changed_ids);

-- DELETED IDs не трогаются — остаются активными.

invalidate:

-- Шаги 1-2 как в ignore.

-- 3. Закрываем deleted: те, что есть в snapshot активные, но нет в source
UPDATE customers_snapshot
SET dbt_valid_to = '{{ run_started_at }}'
WHERE dbt_valid_to IS NULL
  AND customer_id NOT IN (SELECT customer_id FROM source);

new_record:

-- Шаги 1-3 как в invalidate.

-- 4. Вставляем sentinel-строки для deleted
INSERT INTO customers_snapshot (...)
SELECT
    snap.customer_id,
    snap.name,
    snap.tier,
    ...
    TRUE AS dbt_is_deleted,
    '{{ run_started_at }}' AS dbt_valid_from,
    NULL AS dbt_valid_to,
    md5(snap.customer_id || '{{ run_started_at }}') AS dbt_scd_id
FROM customers_snapshot snap
WHERE snap.dbt_valid_to = '{{ run_started_at }}'  -- только что закрытые
  AND snap.customer_id NOT IN (SELECT customer_id FROM source);

Это упрощённая схема. Реальный SQL зависит от warehouse и адаптера.


DuckDB: hard_deletes не работает

На dbt-duckdb 1.10.x состояние 2026 года hard_deletes не реализован. Это известное ограничение адаптера.

Что происходит, если попытаться:

config:
  hard_deletes: invalidate  # на DuckDB

Поведение зависит от версии адаптера:

  1. В некоторых версиях — silent ignore. Snapshot работает как hard_deletes: ignore, но без warning.
  2. В новых — compile error: Adapter does not support hard_deletes config.

Это означает: на DuckDB нет нативного способа отслеживать удаления через snapshot.

Workaround 1: Soft-delete pattern (рекомендуется)

В source держите колонку is_deleted boolean. При логическом удалении выполняйте UPDATE, а не DELETE:

-- В источнике
UPDATE app.customers SET is_deleted = TRUE WHERE customer_id = 42;

Snapshot отслеживает is_deleted как обычную SCD2-колонку:

С timestamp-стратегией (если updated_at обновился при is_deleted=TRUE):

config:
  strategy: timestamp
  updated_at: updated_at
  # hard_deletes не нужен

Когда is_deleted переходит false -> true, дополнительно обновлённый updated_at приведёт к новой SCD2-версии. Downstream: WHERE is_deleted = FALSE для активных.

С check-стратегией:

config:
  strategy: check
  check_cols:
    - tier
    - is_deleted  # обязательно в check_cols

Любой переход is_deleted создаёт новую версию.

Workaround 2: post-hook на snapshot

Если нельзя добавить колонку в source — добавьте post-hook, который вручную закрывает «пропавшие» строки:

config:
  strategy: timestamp
  updated_at: updated_at
  post-hook: |
    UPDATE {{ this }}
    SET dbt_valid_to = '{{ run_started_at }}'
    WHERE dbt_valid_to IS NULL
      AND customer_id NOT IN (
        SELECT customer_id FROM {{ source('app', 'customers') }}
      )

Это эквивалент invalidate вручную. Минусы:

  • Менее декларативно — нужно помнить про post-hook.
  • Не работает для new_record (нужно более сложный INSERT).
  • post-hook выполняется после snapshot run, в той же транзакции — должно быть корректно атомарно.

Workaround 3: переезд на Postgres / Snowflake

Если бизнесу критично знать «кто удалён и когда» — DuckDB не подходит. Это знак, что snapshot пора переносить в полноценное warehouse, где hard_deletes нативно.


Edge case: false positives при invalidate

Snapshot прогоняется с invalidate. Source — это VIEW, которая JOIN-ит несколько таблиц. Если одна из таблиц временно недоступна / часть данных не подгрузилась — VIEW вернёт меньше строк, и snapshot закроет все «пропавшие» как deleted.

Через час источник восстановился — VIEW снова показывает полные данные. Snapshot создаёт новые версии для всех «вернувшихся» — теперь у каждого клиента две закрытые версии и одна активная с искусственными разрывами.

Mitigation:

  • Source freshness check перед snapshot:
dbt source freshness && dbt snapshot

Если freshness fail — не запускать snapshot.

  • Row count sanity check:
-- В CI / pre-snapshot job
{% set source_count = run_query("SELECT COUNT(*) FROM " ~ source('app', 'customers')) %}
{% set snapshot_count = run_query("SELECT COUNT(*) FROM " ~ ref('customers_snapshot') ~ " WHERE dbt_valid_to IS NULL") %}
{% if source_count[0][0] < snapshot_count[0][0] * 0.5 %}
  {{ exceptions.raise_compiler_error("Source row count dropped > 50%, abort snapshot") }}
{% endif %}
  • Не делать snapshot на VIEW — только на материализованных таблицах source.
WARNING

invalidate чувствителен к качеству source. Любой пропуск данных в source приведёт к ложному закрытию SCD2-версий. Защита — source freshness + row count check.


Полный production пример с new_record + soft-delete

# snapshots/customers_snapshot.yml
snapshots:
  - name: customers_snapshot
    description: |
      SCD2-история customers с audit deletion log.
      На DuckDB: workaround через soft-delete is_deleted колонку.
      Расписание: ежедневно в 02:00 UTC.
    relation: source('app', 'customers')
    config:
      schema: snapshots
      unique_key: customer_id
      strategy: timestamp
      updated_at: updated_at
      dbt_valid_to_current: "to_date('9999-12-31')"
      tags: [snapshot, daily, dimension, gdpr]
    columns:
      - name: customer_id
        data_tests:
          - not_null
      - name: is_deleted
        description: "Soft delete flag — заменяет hard_deletes для DuckDB"
      - name: dbt_scd_id
        data_tests:
          - unique
          - not_null

На Snowflake / Postgres / BigQuery — тот же snapshot, но hard_deletes: new_record нативно:

# snapshots/customers_snapshot.yml (Snowflake / Postgres)
config:
  schema: snapshots
  unique_key: customer_id
  strategy: timestamp
  updated_at: updated_at
  hard_deletes: new_record
  dbt_valid_to_current: "TO_DATE('9999-12-31')"

Хорошая практика: вынести стратегию hard_deletes в vars или dbt_project.yml, чтобы свитчить между средами:

# dbt_project.yml
snapshots:
  my_project:
    +hard_deletes: "{{ var('hard_deletes_mode', 'ignore') }}"
dbt snapshot --vars '{hard_deletes_mode: invalidate}'

Попробуй сам

В labs-репозитории:

  1. Создайте customers_snapshot с hard_deletes: ignore (или просто без конфига). Удалите одного клиента из source. Запустите dbt snapshot. Проверьте — его строка осталась активной.
  2. Попробуйте поставить hard_deletes: invalidate на DuckDB. Что произойдёт? Если падает — попробуйте new_record. Зафиксируйте версию dbt-duckdb и сообщение об ошибке.
  3. Реализуйте soft-delete workaround. Добавьте колонку is_deleted в источник, обновите её для удалённого клиента. Запустите snapshot — должна появиться новая версия с is_deleted = TRUE.
  4. Реализуйте post-hook workaround. Без is_deleted — добавьте post-hook, который закрывает «пропавшие» customer_id. Проверьте, что dbt_valid_to ставится корректно.

Этот эксперимент важен, потому что на DuckDB вы реально упрётесь в ограничение, и нужно знать workarounds на практике.


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

  1. dbt 1.9+ ввёл hard_deletes: ignore / invalidate / new_record. Раньше — булевый invalidate_hard_deletes.
  2. ignore (default) пропускает удаления; invalidate закрывает SCD2-версии; new_record дополнительно добавляет sentinel-строку с dbt_is_deleted = TRUE.
  3. На DuckDB hard_deletes не реализован (адаптер 1.10.x, 2026). Workarounds: soft-delete колонка is_deleted в source, post-hook, или переезд на Postgres / Snowflake.
  4. Миграция с invalidate_hard_deletes: true -> hard_deletes: invalidate — простая замена ключа в YAML.
  5. invalidate чувствителен к качеству source. Любой пропуск данных создаст ложные «удаления». Защита — source freshness + row count check.
  6. Для compliance / audit предпочитайте new_record — это даёт proof of deletion с timestamp.
Проверка знанийKnowledge check
Compliance команда требует: для GDPR audit нужно знать timestamp каждого удаления клиента. Production warehouse — Snowflake. Какой конфиг snapshot?
ОтветAnswer
Для GDPR-audit идеален `hard_deletes: new_record`. Каждое удаление создаёт явную sentinel-строку с `dbt_is_deleted = TRUE` и `dbt_valid_from = run_started_at` — это **timestamp удаления** (точнее, момент когда snapshot обнаружил удаление).\n\n```yaml\n# snapshots/customers_snapshot.yml\nsnapshots:\n - name: customers_snapshot\n description: "SCD2 + GDPR deletion audit"\n relation: source('app', 'customers')\n config:\n schema: snapshots\n unique_key: customer_id\n strategy: timestamp\n updated_at: updated_at\n hard_deletes: new_record\n dbt_valid_to_current: "TO_DATE('9999-12-31')"\n tags: [snapshot, daily, gdpr]\n```\n\nDownstream запросы:\n\n```sql\n-- Все удаления за прошлый месяц\nSELECT customer_id, dbt_valid_from AS deleted_at\nFROM customers_snapshot\nWHERE dbt_is_deleted = TRUE\n AND dbt_valid_from не меньше '2026-04-01'\n AND dbt_valid_from < '2026-05-01';\n```\n\nДополнительные требования:\n\n1. **Frequency.** GDPR удаление должно быть зафиксировано «своевременно». Если snapshot раз в день — может быть до 24-часовой gap между удалением в source и записью в snapshot. Если регулятор требует hour-precision — частить snapshot или дополнить CDC.\n2. **Backup of snapshot.** Snapshot теперь сам — audit log. Должен быть backup в long-term storage (S3 / Iceberg) с retention policy.\n3. **PII в snapshot.** Snapshot содержит исторические данные удалённых клиентов. По GDPR right-to-be-forgotten вы должны иметь возможность УДАЛИТЬ исторические записи. Это противоречит SCD2 immutability — обсудить с legal. Обычно решается через pseudonymization (заменить PII на hash) старых строк, оставив структуру snapshot.\n4. **Документация.** Snapshot description должно явно указывать что это compliance asset с retention policy.\n\nЧто **не подойдёт**:\n- `ignore` — нет proof of deletion вообще.\n- `invalidate` — «нет активной версии» = «удалён», но без явного timestamp; для audit hard to defend.
Проверка знанийKnowledge check
На DuckDB настроен snapshot с timestamp-стратегией. Source — VIEW которая JOIN-ит app.customers + app.subscriptions. Иногда subscriptions table недоступна (внешняя API). При недоступности subscriptions VIEW возвращает 0 строк. С каким hard_deletes-параметром это безопасно?
ОтветAnswer
Только с `ignore` (default) — и то с оговорками. Любая другая опция или workaround опасны.\n\n**Проблема**: при недоступности subscriptions VIEW = 0 строк. Если бы был `invalidate` (или post-hook эквивалент) — все строки закрылись бы как «удалённые». Через час, когда subscriptions восстановилась, эти удаления остались бы в snapshot как ложные — катастрофа SCD2 истории.\n\n**Правильное решение — НЕ зависеть от workarounds для удалений на ненадёжном source**:\n\n1. **Не делать snapshot напрямую на VIEW.** Сначала материализовать VIEW в таблицу (`stg_customers_enriched`) с retry-логикой при недоступности subscriptions. Snapshot — на эту таблицу.\n2. **Source freshness check перед snapshot:**\n ```yaml\n sources:\n - name: app\n tables:\n - name: subscriptions\n freshness:\n error_after: {count: 1, period: hour}\n ```\n В CI: `dbt source freshness && dbt snapshot`. Если freshness fail — snapshot не запускается.\n3. **Row count sanity check** через pre-hook macro:\n ```sql\n {% if (run_query(\"SELECT COUNT(*) FROM source\")[0][0] | int) менее 100 %}\n {{ exceptions.raise_compiler_error('Source has менее 100 rows, abort') }}\n {% endif %}\n ```\n4. **Soft-delete в source.** Никогда не делать DELETE из app.customers. Только UPDATE `is_deleted = TRUE` под контролем приложения.\n\nЕсли всё-таки нужно отслеживать удаления:\n- На DuckDB через soft-delete (`is_deleted` колонка) — НО только когда уверены в качестве source.\n- Через post-hook — но с pre-hook guards (count check, freshness check).\n\nГлавная мысль: `invalidate` / `new_record` (или их workaround) **усиливают зависимость от качества source**. Любой transient outage становится permanent corruption SCD2. Если source ненадёжен — лучше `ignore` + soft-delete pattern с явным контролем приложения.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. Compliance команда требует GDPR audit: знать timestamp каждого удаления клиента. Production warehouse — Snowflake. Какое значение hard_deletes выбрать?

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

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

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

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