Learning Platform
Глоссарий Troubleshooting
Урок 05.05 · 22 мин
Средний
Snapshotsdbt_valid_to_currentsnapshot_meta_column_namesBIProduction

dbt_valid_to_current и snapshot_meta_column_names

В этом уроке — две конфигурации snapshot из dbt 1.9+, которые делают snapshot дружественнее к downstream: dbt_valid_to_current (избавление от NULL в активных строках) и snapshot_meta_column_names (кастомные имена SCD2-колонок).

Если первая часть модуля была про точность истории, эта — про эргономику для тех, кто потом эту историю использует: BI-аналитиков, ML-инженеров, авторов dashboard.


Проблема NULL в dbt_valid_to

По умолчанию активная (текущая) строка snapshot имеет dbt_valid_to = NULL. Это формально корректно — конец интервала не определён, snapshot SCD2 ещё не зафиксировал следующее изменение.

Но downstream-запросы должны это учитывать. Типичный JOIN orders с customers_snapshot:

-- BAD: NULL ломает BETWEEN
SELECT o.*, s.tier
FROM orders o
LEFT JOIN customers_snapshot s
  ON o.customer_id = s.customer_id
  AND o.order_date BETWEEN s.dbt_valid_from AND s.dbt_valid_to;

order_date BETWEEN dbt_valid_from AND NULL — это >= dbt_valid_from AND <= NULL. Сравнение с NULL -> NULL -> не TRUE -> строка исключается. Активные клиенты теряются из результата.

NULL ломает BETWEEN

Решения трёх уровней:

  1. COALESCE везде в downstream. Каждый JOIN на snapshot должен заворачивать dbt_valid_to в COALESCE(valid_to, '9999-12-31'). Один пропуск = бага в проде.
  2. VIEW над snapshot с готовым COALESCE. Создать dim_customers_view который делает CHECK / COALESCE автоматически.
  3. dbt_valid_to_current в snapshot config. Самое чистое. Active row сразу записывается с sentinel-датой, NULL не появляется.

Конфиг dbt_valid_to_current

snapshots:
  - name: customers_snapshot
    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')"

dbt_valid_to_current принимает SQL expression, который вычислит sentinel-значение в момент INSERT. Чаще всего — конкретная далёкая дата:

ЗначениеТипProsCons
"to_date('9999-12-31')"DATEОчевидное «бесконечно далёкое будущее». Прост в BI.Только дата, без времени.
"to_timestamp('9999-12-31 23:59:59')"TIMESTAMPСовместим с timestamp-колонками.Тяжелее для глаз в raw query.
"'2999-12-31'::timestamp"TIMESTAMPМенее экстремальная sentinel — некоторые BI глупят с 9999.Слабее «бесконечно».
"'infinity'::timestamp"TIMESTAMP (Postgres only)Honestly infinite.Не работает на Snowflake / BigQuery / DuckDB.

Стандартный выбор в 2026: to_date('9999-12-31') для DATE-колонок, to_timestamp('9999-12-31 23:59:59') для TIMESTAMP. Поведение в DuckDB / Postgres / Snowflake / BigQuery согласовано.

WARNING

Тип dbt_valid_to_current должен совпадать с типом dbt_valid_to колонки snapshot (DATE vs TIMESTAMP). Mixed типы вызовут implicit cast при INSERT и могут потерять precision. Стандартизируйтесь на одном.


Поведение dbt при включении dbt_valid_to_current

Без конфига:

  • Новая активная строка: INSERT (..., dbt_valid_to) VALUES (..., NULL).
  • При закрытии: UPDATE SET dbt_valid_to = source.updated_at WHERE dbt_valid_to IS NULL.

С конфигом dbt_valid_to_current: "to_date('9999-12-31')":

  • Новая активная строка: INSERT (..., dbt_valid_to) VALUES (..., to_date('9999-12-31')).
  • При закрытии: UPDATE SET dbt_valid_to = source.updated_at WHERE dbt_valid_to = to_date('9999-12-31').

То есть dbt подменяет NULL на sentinel-значение и в проверке «активная ли строка».


Миграция существующего snapshot

Если snapshot уже год работает с dbt_valid_to = NULL для активных, и вы хотите перейти на dbt_valid_to_current — нужна одноразовая миграция.

Шаг 1. Добавить конфиг в YAML. Шаг 2. Сделать UPDATE на старые активные строки:

-- Один раз, вручную / через макрос:
UPDATE snapshots.customers_snapshot
SET dbt_valid_to = '9999-12-31'::date
WHERE dbt_valid_to IS NULL;

Шаг 3. Проверить тестами:

columns:
  - name: dbt_valid_to
    data_tests:
      - not_null
      - dbt_utils.expression_is_true:
          expression: "dbt_valid_to = to_date('9999-12-31') OR dbt_valid_to < current_date"

Шаг 4. Запустить dbt snapshot. Новые INSERT уже с sentinel — old & new должны быть однородными.

Это безболезненная миграция: ни одна строка не теряется, downstream-запросы можно постепенно переписать.


snapshot_meta_column_names

Data Vault: HASH_DIFF — откуда берётся этот паттерн

dbt по умолчанию называет meta-колонки snapshot так: dbt_scd_id, dbt_updated_at, dbt_valid_from, dbt_valid_to. Если переезжаете с другого SCD2-стека (Data Vault, ETL tool, hand-rolled snapshot), хочется сохранить старые имена.

dbt 1.9+ позволяет кастомизировать через snapshot_meta_column_names:

snapshots:
  - name: customers_snapshot
    relation: source('app', 'customers')
    config:
      schema: snapshots
      unique_key: customer_id
      strategy: timestamp
      updated_at: updated_at
      snapshot_meta_column_names:
        dbt_valid_from: valid_from_ts
        dbt_valid_to: valid_to_ts
        dbt_scd_id: hash_diff
        dbt_updated_at: source_updated_at
        dbt_is_deleted: is_deleted_flag

Теперь snapshot создаёт колонки valid_from_ts, valid_to_ts, hash_diff, source_updated_at, is_deleted_flag — без префикса dbt_.

Это полезно когда:

  • Миграция legacy SCD2. Старая команда писала snapshot руками, downstream привык к именам. Переход на dbt не должен ломать BI-отчёты.
  • Соответствие naming convention. Корпоративный стандарт требует _ts суффиксы или camelCase. snapshot вписывается в convention.
  • Data Vault интеграция. Колонки соответствуют HASH_DIFF, LOAD_DTS, LOAD_END_DTS терминологии Data Vault.
NOTE

Кастомные имена применяются только к новым snapshot. Существующие snapshot нужно либо мигрировать вручную (ALTER TABLE RENAME COLUMN), либо запустить --full-refresh (потеря истории).


Влияние на тесты

Стандартные тесты ссылаются на колонки по имени:

columns:
  - name: dbt_scd_id
    data_tests:
      - unique
      - not_null

При кастомных именах — обновите references:

columns:
  - name: hash_diff
    data_tests:
      - unique
      - not_null
  - name: valid_from_ts
    data_tests:
      - not_null

Также macro dbt_utils.snapshot_active_records и подобные могут принимать имена аргументами. Проверьте, что ваш пакет поддерживает кастомные имена.


Влияние на BI и data exploration

Sentinel-дата 9999-12-31 визуально странна. BI-инструменты (Tableau, Power BI, Looker) по-разному её отображают:

  • Tableau показывает как «12/31/9999» — обычно понимается аналитиками.
  • Power BI иногда конвертит в ##### (overflow в date pickers). Решение — фильтровать sentinel в BI semantic layer.
  • Looker работает прозрачно.
  • Custom dashboards (Superset, Metabase) — обычно ок, но проверьте edge cases.

Полезный паттерн — экспортировать через mart-модель, где sentinel заменяется на NULL или is_current_flag:

-- models/marts/dim_customers_current.sql
SELECT
    customer_id,
    name,
    tier,
    email,
    valid_from_ts,
    CASE
        WHEN valid_to_ts = to_date('9999-12-31') THEN NULL
        ELSE valid_to_ts
    END AS valid_to_ts,
    (valid_to_ts = to_date('9999-12-31')) AS is_current
FROM {{ ref('customers_snapshot') }}

Тогда BI потребляет mart, а не raw snapshot — и видит знакомый NULL / flag.


Edge case: dbt_valid_to_current при invalidate / new_record

Когда hard_deletes: invalidate, dbt при удалении ставит dbt_valid_to = run_started_at. Это не sentinel — это реальное время удаления. Sentinel остаётся только у живых активных строк.

Когда hard_deletes: new_record, sentinel-строка с dbt_is_deleted = TRUE тоже имеет dbt_valid_to = sentinel (потому что это «новая активная» запись со специальным флагом).

-- После последовательности: customer_id=42 был активным, потом удалён
SELECT customer_id, dbt_valid_from, dbt_valid_to, dbt_is_deleted
FROM customers_snapshot
WHERE customer_id = 42
ORDER BY dbt_valid_from;

-- 42 | 2026-01-15 | 2026-05-19 (run_started_at) | FALSE  ← закрытая обычная
-- 42 | 2026-05-19 | 9999-12-31 (sentinel)       | TRUE   ← sentinel «удалён»

Downstream фильтр «активные сейчас» учитывает оба:

WHERE dbt_valid_to = to_date('9999-12-31')
  AND COALESCE(dbt_is_deleted, FALSE) = FALSE

Полный production пример с sentinel + кастомные имена

# snapshots/customers_snapshot.yml
snapshots:
  - name: customers_snapshot
    description: |
      SCD2-история customers. Sentinel-дата для активных. Имена колонок — Data Vault convention.
      Используется как dimension в financial reporting (compliance).
    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')"
      snapshot_meta_column_names:
        dbt_valid_from: load_dts
        dbt_valid_to: load_end_dts
        dbt_scd_id: hash_diff
        dbt_updated_at: source_modified_at
      tags: [snapshot, daily, dimension, compliance]
    columns:
      - name: customer_id
        data_tests:
          - not_null
      - name: hash_diff
        data_tests:
          - unique
          - not_null
      - name: load_dts
        data_tests:
          - not_null
      - name: load_end_dts
        data_tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "load_end_dts = to_date('9999-12-31') OR load_end_dts >= load_dts"

Попробуй сам

В labs:

  1. Создайте snapshot с dbt_valid_to_current на customers. Запустите dbt snapshot. Проверьте, что активные строки имеют sentinel-дату вместо NULL.
  2. Напишите JOIN orders с snapshot без COALESCE. Проверьте, что результат корректен (живые клиенты не теряются).
  3. Уберите dbt_valid_to_current из конфига. Сделайте dbt snapshot --full-refresh. Снова запустите тот же JOIN — теперь активные клиенты исчезли (NULL в BETWEEN). Восстановите конфиг и заполните UPDATE для существующих NULL.
  4. Попробуйте snapshot_meta_column_names с кастомными именами. Сделайте dbt snapshot --full-refresh (для существующего без full-refresh имена меняются только в новом snapshot). Проверьте, что в warehouse колонки теперь с новыми именами.
  5. Сделайте mart-модель dim_customers_current которая переводит sentinel обратно в NULL для BI.

Это упражнение демонстрирует, как small config-changes делают snapshot эргономичнее для всех downstream-consumer’ов.


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

  1. NULL в dbt_valid_to ломает BETWEEN в downstream JOIN. Решение: COALESCE везде, view-обёртка, или dbt_valid_to_current config.
  2. dbt_valid_to_current: "to_date('9999-12-31')" — sentinel-дата вместо NULL. Чище, меньше bug surface.
  3. Тип sentinel должен совпадать с типом dbt_valid_to колонки (DATE vs TIMESTAMP).
  4. Миграция существующего snapshot на sentinel — одноразовый UPDATE без потери истории.
  5. snapshot_meta_column_names позволяет переименовать dbt_scd_id / dbt_valid_from / etc — полезно для legacy миграций и Data Vault convention.
  6. Sentinel-дата может смущать BI-инструменты. Рекомендуется mart-обёртка, которая возвращает NULL и is_current flag для downstream.
  7. hard_deletes: invalidate и new_record корректно работают со sentinel.
Проверка знанийKnowledge check
BI-аналитик говорит: 'я не могу использовать customers_snapshot напрямую в Power BI — везде показывает 12/31/9999 в date filters, юзеры путаются'. Какое чистое решение?
ОтветAnswer
Это типичный случай: snapshot работает корректно для dbt, но для BI sentinel-дата визуально мусор. Решения по убыванию инвазивности:\n\n**1. Mart-обёртка над snapshot (рекомендую):**\n```sql\n-- models/marts/dim_customers.sql\nSELECT\n customer_id,\n name,\n tier,\n dbt_valid_from AS effective_from,\n CASE\n WHEN dbt_valid_to = to_date('9999-12-31') THEN NULL\n ELSE dbt_valid_to\n END AS effective_to,\n (dbt_valid_to = to_date('9999-12-31')) AS is_current\nFROM {{ ref('customers_snapshot') }}\n```\nBI коннектится к dim_customers, видит привычный NULL + is_current flag. Snapshot не трогаем.\n\n**2. View с COALESCE наоборот:**\nЕсли вообще не нужны исторические записи в BI — view только активных:\n```sql\n-- models/marts/dim_customers_current.sql\nSELECT * FROM {{ ref('customers_snapshot') }}\nWHERE dbt_valid_to = to_date('9999-12-31')\n```\nBI видит current state, без всяких 9999-12-31.\n\n**3. Убрать dbt_valid_to_current из snapshot и вернуть NULL:**\nНе рекомендую, потому что теряется удобство для других downstream и нужно COALESCE везде в SQL. Mart-обёртка лучше.\n\n**4. Изменить sentinel на менее экстремальный:**\n```yaml\ndbt_valid_to_current: "to_date('2099-12-31')"\n```\n2099-12-31 BI отображает как нормальную дату, но семантически это всё ещё sentinel. Промежуточное решение.\n\nЛучшая практика на проектах с многими BI-юзерами: **layer separation**. Snapshot — raw history, mart — clean for BI. BI **никогда не коннектится напрямую к snapshot**. Тогда любые внутренние конвенции snapshot (sentinel, кастомные имена, hard_deletes flags) не утекают в BI.
Проверка знанийKnowledge check
Junior хочет добавить `snapshot_meta_column_names` для существующего snapshot, который работает год. Сказали 'просто измени YAML, должно подхватить'. Это сработает?
ОтветAnswer
Нет, это **не сработает без --full-refresh**, и есть нюансы.\n\nЧто произойдёт при простом изменении YAML и `dbt snapshot` (без full-refresh):\n\n1. dbt смотрит на schema **существующей** snapshot-таблицы. Видит `dbt_valid_from`, `dbt_valid_to`, `dbt_scd_id`, `dbt_updated_at`.\n2. Compile-time dbt генерирует SQL с **новыми** именами (`load_dts`, `load_end_dts`, etc).\n3. При INSERT возникает column not found error: `load_dts does not exist in table customers_snapshot`.\n4. Run падает. История не теряется, но snapshot не обновляется до фикса.\n\nКак правильно мигрировать с кастомными именами:\n\n**Вариант A — Full-refresh (теряется история):**\n```bash\ndbt snapshot --full-refresh\n```\ndbt пересоздаёт таблицу с новыми именами. Текущее состояние source — единственная строка для каждого customer. Вся история до этого момента — пропала. Подходит, если history not critical.\n\n**Вариант B — Ручной ALTER TABLE (сохраняется история):**\n```sql\n-- Один раз, вручную\nALTER TABLE snapshots.customers_snapshot RENAME COLUMN dbt_valid_from TO load_dts;\nALTER TABLE snapshots.customers_snapshot RENAME COLUMN dbt_valid_to TO load_end_dts;\nALTER TABLE snapshots.customers_snapshot RENAME COLUMN dbt_scd_id TO hash_diff;\nALTER TABLE snapshots.customers_snapshot RENAME COLUMN dbt_updated_at TO source_modified_at;\n```\nЗатем изменить YAML с `snapshot_meta_column_names`. `dbt snapshot` — INSERT с новыми именами, всё совпадает.\n\nНе все warehouses поддерживают RENAME COLUMN в одной транзакции — на DuckDB сейчас работает, на Snowflake тоже, BigQuery требует пересоздания (table copy).\n\n**Вариант C — двойная запись через миграционный pre-hook:**\nКомпромисс: добавить **алиасы**-колонки, чтобы оба имени работали временно, постепенно убрать старые. Сложно, редко нужно.\n\nЛучшая практика на новом snapshot — **сразу выбрать соглашение имён**. Менять на проде — нелёгкая операция.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. Аналитик пишет JOIN: 'FROM orders o JOIN customers_snapshot s ON o.customer_id=s.customer_id AND o.order_date BETWEEN s.dbt_valid_from AND s.dbt_valid_to'. Запрос пропускает активных клиентов (с dbt_valid_to=NULL). Какое лучшее решение в snapshot config?

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

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

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

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