Timestamp strategy: углублённый разбор
Стратегия timestamp — самая дешёвая и популярная. dbt сравнивает source.updated_at с snapshot.dbt_updated_at: если source-значение больше — фиксируем новую версию.
На бумаге просто. На проде — нюансы, которые ломают snapshot молча: NULL в updated_at, скачки часовых поясов, late-arriving updates, неупорядоченные timestamps. Этот урок front-loaded под edge cases — базовый YAML и критерии выбора колонки уже знаешь из dbt-i/12.
Базовый YAML strategy: timestamp + updated_at: updated_at и критерии выбора колонки (monotonic, not-null, UTC) разбирались в dbt-i/12/04. Здесь — production edge cases.
Recap в одной таблице
| Часть | Что | Где детали |
|---|---|---|
| YAML | strategy: timestamp + updated_at: <column> + unique_key | dbt-i/12/04 |
| Алгоритм | сравнение source.updated_at > snapshot.dbt_updated_at -> новая версия | этот урок (нюанс ниже) |
| Критерии колонки | monotonic, обновляется при любом UPDATE, ms-точность, NOT NULL, UTC | dbt-i/12/04 |
dbt_valid_to нового события | ставится в source.updated_at, а не now() -> honest history | критично — см. ниже |
| Где брать колонку | Postgres triggers, Hibernate/Django ORM, CDC (__op_ts) | dbt-i/12/04 |
Главный нюанс, который часто пропускают: dbt_valid_to закрывающейся версии ставится в source.updated_at нового события, а не в now(). То есть граница «старая/новая» — это бизнес-время изменения, а не момент запуска dbt snapshot. Это даёт honest history даже когда snapshot прогоняется раз в сутки. Все edge cases ниже опираются на эту семантику.
Edge case 1: NULL в updated_at
NULL и трёхзначная логика — почему COALESCE обязателенЕсли в source есть строки с updated_at IS NULL, dbt:
- При первом snapshot run — INSERT с
dbt_updated_at = NULL. Это уже ломает unique-тест наdbt_scd_id, если NULL встречается у двух разных строк. - При следующих runs — JOIN по
source.updated_at > snapshot.dbt_updated_atвсегда FALSE (сравнение с NULL -> NULL -> не TRUE). Строка с NULL никогда не получит новую версию, даже если изменилась.
Решения (по убыванию надёжности):
Решение 1 — fallback в source relation через .sql:
-- snapshots/customers_snapshot.sql
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='effective_updated_at'
)
}}
SELECT
customer_id,
name,
address,
tier,
-- Если updated_at NULL — fallback на created_at
COALESCE(updated_at, created_at) AS effective_updated_at
FROM {{ source('app', 'customers') }}
effective_updated_at — гарантированно not-null. Snapshot работает корректно.
Решение 2 — фильтр в relation, выкидываем строки с NULL:
Подойдёт, если NULL — это «битые» исторические записи, которые не надо отслеживать. Не рекомендую — теряете часть данных.
Решение 3 — починить в источнике. Самое правильное, но не всегда возможное. Trigger BEFORE UPDATE + бэкфилл UPDATE WHERE updated_at IS NULL SET updated_at = COALESCE(created_at, NOW()).
Edge case 2: Gaps и backfill источника
Source-команда сделала backfill: тысячи строк получили UPDATE customers SET updated_at = NOW() WHERE .... Все эти строки изменились в одну секунду в источнике.
При следующем dbt snapshot:
- Все строки попадают в «changed» (updated_at новее snapshot).
- Snapshot фиксирует их новые версии с одинаковым
dbt_updated_at. - История теряется: реальные изменения, которые произошли до backfill, не записаны.
Решения:
- Договориться с source-командой не делать массовых обновлений
updated_at. Если меняют только данные — пустьupdated_atотражает реальное время изменения. - Использовать check-стратегию на критичных колонках в дополнение к timestamp. На middle уровне можно комбинировать: timestamp для скорости, отдельный test на «не должно быть скачков».
- Логировать source.updated_at distribution через source freshness или dbt-expectations. Если за час пришло 10000 изменений вместо обычных 50 — alert.
Edge case 3: Late-arriving updates
Через 3 дня после изменения от source приходит запись: customer_id=42, updated_at='2026-05-15 10:00', tier='premium'. Сегодня 2026-05-18.
Что произойдёт при dbt snapshot:
- Source.updated_at (2026-05-15) сравнивается с snapshot.dbt_updated_at активной строки (например, 2026-05-10). 2026-05-15 > 2026-05-10 -> новая версия фиксируется.
- Новая версия имеет
dbt_valid_from = 2026-05-15(не сегодня). - Старая версия закрывается:
dbt_valid_to = 2026-05-15.
Это корректно для бизнес-логики: tier фактически изменился 15 мая. Запрос «какой tier был у клиента 16 мая?» вернёт premium.
Проблема: если между 15 и 18 мая были транзакции, и downstream-модель уже их обработала с старым tier, то теперь, после snapshot пересчёта, mart-таблица показывает другое. Это race: snapshot вернулся в прошлое.
Mitigation:
- Mart на snapshot — incremental по order_date. Не пересчитывает уже обработанные дни — даже если snapshot изменился, mart остаётся с историческим tier.
- Если нужны корректные attributions — настройте
--full-refreshperiodically на mart или используйтеdbt build --refresh-mode=fullпосле крупных backfills snapshot. - Сохраняйте audit: добавляйте в mart колонку
snapshot_version_at_processing(черезmetaили Jinjarun_started_at).
Late-arriving updates — нормальное поведение dbt snapshot. Но они ретроактивно меняют историю SCD2. Если downstream mart не учитывает это, ваши отчёты могут «дрейфовать» после каждой синхронизации.
Edge case 4: Updated_at прыгает в будущее
Бывает: разработчик в Postgres пишет INSERT INTO customers (..., updated_at) VALUES (..., '2099-01-01'). Бывает по бухам приложения, бывает потому что часы на одном из application-серверов сбились.
После snapshot run эта строка имеет dbt_updated_at = 2099-01-01. Любые реальные изменения до 2099 года будут проигнорированы (source.updated_at < snapshot.dbt_updated_at -> нет нового события).
Solutions:
- Sanity-test source через generic
dbt_utils.expression_is_trueили dbt-expectations:
sources:
- name: app
tables:
- name: customers
columns:
- name: updated_at
data_tests:
- dbt_utils.expression_is_true:
expression: "updated_at <= current_timestamp + interval '1 day'"
config:
severity: error
Любая updated_at в будущем — error в CI, не дойдёт до prod.
- Ограничить в snapshot relation:
SELECT
*,
LEAST(updated_at, CURRENT_TIMESTAMP) AS safe_updated_at
FROM {{ source('app', 'customers') }}
- Чинить в source. Идеально, но требует SLA с командой источника.
Edge case 5: Тайм-зоны
Source пишет updated_at TIMESTAMP WITHOUT TIME ZONE в локальной зоне (Москва, UTC+3). DuckDB / Snowflake читают его как «голый» timestamp без зоны, но интерпретируют как UTC при сравнениях.
Что происходит:
- Source:
UPDATE ... SET updated_at = '2026-05-19 12:00:00'(это 12:00 МСК = 09:00 UTC). - dbt видит
2026-05-19 12:00:00(без TZ) и сравнивает сcurrent_timestamp(которое в Snowflake обычно в UTC). - Если snapshot запустился в 11:00 UTC (14:00 МСК), он подумает, что source.updated_at в будущем относительно текущего момента — это сигнализирует как corruption или ломает freshness.
Решения:
- Стандартизировать source на UTC. Лучшая практика для distributed систем.
- Конвертить в relation:
updated_at AT TIME ZONE 'Europe/Moscow' AT TIME ZONE 'UTC' AS updated_at_utc. - Не использовать
current_timestampдля сравнений — dbt сам генерируетnow()warehouse-specific, синхронизированный с типом колонки.
DuckDB-специфика timestamp strategy
DuckDB обрабатывает timestamps корректно. Что отличает её от Postgres / Snowflake:
TIMESTAMPvsTIMESTAMPTZ. В DuckDB обе работают, но при сравнении смешанных типов происходит implicit cast. Лучше держать единый тип.current_timestampв DuckDB — всегда UTC, независимо от системного часа. Это safer чем Postgres, где зависит от настройки сессии.- Точность. DuckDB поддерживает microseconds. Для high-frequency UPDATE этого хватает.
- Time travel. В DuckDB нет встроенного time travel (Snowflake feature). История доступна только через snapshot.
Полный production пример
# snapshots/customers_snapshot.yml
snapshots:
- name: customers_snapshot
description: |
SCD2-история customers. Используется для атрибуции tier
на момент транзакции. Snapshot прогоняется ежедневно в 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]
columns:
- name: customer_id
description: "Primary key. Бизнес-идентификатор клиента, stable."
data_tests:
- not_null
- name: dbt_scd_id
description: "Хеш версии записи. Уникальный."
data_tests:
- unique
- not_null
- name: dbt_valid_from
description: "Начало периода действия = source.updated_at новой версии"
data_tests:
- not_null
Особенности:
tags: [snapshot, daily, dimension]— для selectors в CI (dbt snapshot --select tag:daily).- Колонки задокументированы и протестированы.
descriptionsnapshot — содержит schedule (часть документации, видна в Explorer).
Попробуй сам
В labs-репозитории создайте snapshot для app.customers. Эксперимент в три шага:
- Базовый snapshot с
updated_at. Сделайте две версии одной строки — проверьте, что snapshot отражает оба состояния с честнымиdbt_valid_from. - Подложите NULL в updated_at. Выполните
UPDATE customers SET updated_at = NULL WHERE customer_id = X. Сноваdbt snapshot. Что произошло с этой строкой? Ничего: она не попала в changed, потому чтоNULL > anything= NULL = не TRUE. - Подложите будущую дату.
UPDATE customers SET updated_at = '2099-01-01' WHERE customer_id = Y. После snapshot эта строка «защищена» от обновлений — никакое реальное изменение её не догонит.
Это упражнение делает edge cases ощутимыми. На прод выкатывать только после такого тестирования.
Ключевые выводы
- Timestamp-стратегия — O(n) сравнение по одной колонке. Дёшево и быстро.
dbt_valid_toставится вsource.updated_atнового события, а не вnow()— honest history по бизнес-времени.- NULL в
updated_at— silent killer. Решение: COALESCE в.sqlrelation или фикс в источнике. - Late-arriving updates корректно ретроактивно меняют SCD2, но могут «дрейфовать» downstream mart-таблицы.
- Будущие даты в
updated_atблокируют дальнейшие обновления — добавляйте sanity-test на source. - UTC везде. Mixed timezone в
updated_atломает сравнения с warehousecurrent_timestamp. - На DuckDB всё работает, но нет time travel — снапшот единственный путь к истории.