Learning Platform
Глоссарий Troubleshooting
Урок 05.02 · 25 мин
Средний
SnapshotsTimestamp strategyupdated_atEdge casesProduction

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.

NOTE

Базовый YAML strategy: timestamp + updated_at: updated_at и критерии выбора колонки (monotonic, not-null, UTC) разбирались в dbt-i/12/04. Здесь — production edge cases.


Recap в одной таблице

ЧастьЧтоГде детали
YAMLstrategy: timestamp + updated_at: <column> + unique_keydbt-i/12/04
Алгоритмсравнение source.updated_at > snapshot.dbt_updated_at -> новая версияэтот урок (нюанс ниже)
Критерии колонкиmonotonic, обновляется при любом UPDATE, ms-точность, NOT NULL, UTCdbt-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:

  1. При первом snapshot run — INSERT с dbt_updated_at = NULL. Это уже ломает unique-тест на dbt_scd_id, если NULL встречается у двух разных строк.
  2. При следующих 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:

  1. Все строки попадают в «changed» (updated_at новее snapshot).
  2. Snapshot фиксирует их новые версии с одинаковым dbt_updated_at.
  3. История теряется: реальные изменения, которые произошли до 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:

  1. Source.updated_at (2026-05-15) сравнивается с snapshot.dbt_updated_at активной строки (например, 2026-05-10). 2026-05-15 > 2026-05-10 -> новая версия фиксируется.
  2. Новая версия имеет dbt_valid_from = 2026-05-15 (не сегодня).
  3. Старая версия закрывается: 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-refresh periodically на mart или используйте dbt build --refresh-mode=full после крупных backfills snapshot.
  • Сохраняйте audit: добавляйте в mart колонку snapshot_version_at_processing (через meta или Jinja run_started_at).
WARNING

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 при сравнениях.

Что происходит:

  1. Source: UPDATE ... SET updated_at = '2026-05-19 12:00:00' (это 12:00 МСК = 09:00 UTC).
  2. dbt видит 2026-05-19 12:00:00 (без TZ) и сравнивает с current_timestamp (которое в Snowflake обычно в UTC).
  3. Если 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:

  1. TIMESTAMP vs TIMESTAMPTZ. В DuckDB обе работают, но при сравнении смешанных типов происходит implicit cast. Лучше держать единый тип.
  2. current_timestamp в DuckDB — всегда UTC, независимо от системного часа. Это safer чем Postgres, где зависит от настройки сессии.
  3. Точность. DuckDB поддерживает microseconds. Для high-frequency UPDATE этого хватает.
  4. 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).
  • Колонки задокументированы и протестированы.
  • description snapshot — содержит schedule (часть документации, видна в Explorer).

Попробуй сам

В labs-репозитории создайте snapshot для app.customers. Эксперимент в три шага:

  1. Базовый snapshot с updated_at. Сделайте две версии одной строки — проверьте, что snapshot отражает оба состояния с честными dbt_valid_from.
  2. Подложите NULL в updated_at. Выполните UPDATE customers SET updated_at = NULL WHERE customer_id = X. Снова dbt snapshot. Что произошло с этой строкой? Ничего: она не попала в changed, потому что NULL > anything = NULL = не TRUE.
  3. Подложите будущую дату. UPDATE customers SET updated_at = '2099-01-01' WHERE customer_id = Y. После snapshot эта строка «защищена» от обновлений — никакое реальное изменение её не догонит.

Это упражнение делает edge cases ощутимыми. На прод выкатывать только после такого тестирования.


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

  1. Timestamp-стратегия — O(n) сравнение по одной колонке. Дёшево и быстро.
  2. dbt_valid_to ставится в source.updated_at нового события, а не в now() — honest history по бизнес-времени.
  3. NULL в updated_at — silent killer. Решение: COALESCE в .sql relation или фикс в источнике.
  4. Late-arriving updates корректно ретроактивно меняют SCD2, но могут «дрейфовать» downstream mart-таблицы.
  5. Будущие даты в updated_at блокируют дальнейшие обновления — добавляйте sanity-test на source.
  6. UTC везде. Mixed timezone в updated_at ломает сравнения с warehouse current_timestamp.
  7. На DuckDB всё работает, но нет time travel — снапшот единственный путь к истории.
Проверка знанийKnowledge check
Snapshot работал корректно полгода. На прошлой неделе source-команда переехала с Postgres на Aurora и сделала data migration. После миграции при dbt snapshot все 50 000 строк получили новые версии (хотя данные не менялись). Почему и как чинить?
ОтветAnswer
Скорее всего, миграция переписала `updated_at` для всех строк (либо trigger `BEFORE UPDATE` обновил их при INSERT в новой БД, либо ETL поставил `NOW()` на момент миграции). Snapshot сравнил новые `updated_at` с прежним `dbt_updated_at` — все строки оказались «изменены». \n\nЧто на самом деле: история не изменилась, но в snapshot появились дубликаты-версии с одинаковыми данными, разделённые синтетическим dbt_valid_from = время миграции. Это **загрязнение** SCD2 — downstream-запросы про tier на любую дату могут получить две одинаковые версии в interval-overlap.\n\nКак чинить:\n\n1. **Немедленно**: остановить snapshot prod-cron, чтобы не накапливать больше дубликатов.\n2. **Очистить snapshot** — `DELETE FROM snapshot WHERE dbt_valid_from = '2026-05-13 02:00:00'` (время миграции). Это удалит все «фантомные» новые версии. Старые версии станут активными снова (если есть один-к-одному match). Это **ручной хирургический фикс**, должен быть протестирован на копии.\n3. **Profilактика**: на source поставить тест `unique` на (customer_id, updated_at) — если миграция / backfill ставит одинаковые updated_at — тест упадёт в CI и предупредит.\n4. **Будущее**: в SLA с source-командой включить «никаких массовых UPDATE updated_at без согласования». Любая миграция должна сохранять оригинальные updated_at.\n\nЭто пример того, как «честная» стратегия timestamp проигрывает грубой migration practice. Аналог check-стратегии бы этого тоже не спас (значения колонок ведь не менялись), но check на конкретные колонки минимум не создал бы новые версии — обнаружил бы что данные тождественны.
Проверка знанийKnowledge check
Junior спрашивает: почему dbt_valid_to ставится в source.updated_at новой версии, а не в run_started_at? Не логичнее ли «закрыли версию в момент когда snapshot её увидел»?
ОтветAnswer
Это design choice dbt с конкретной целью — **honest history по бизнес-времени**. Аргументы:\n\n1. **Корректная атрибуция**. Допустим, tier изменился реально в 10:00, snapshot прогоняется в 18:00. Если бы dbt_valid_to ставился в 18:00, заказ в 14:00 «попал» бы в старый tier (хотя клиент уже был в новом с 10:00). Ставится в 10:00 (source.updated_at новой версии) — заказ корректно атрибутируется к новому tier.\n2. **Стабильность relative-времени**. dbt_valid_from / dbt_valid_to отражают source-события, не snapshot-runs. Если расписание snapshot поменяли (был раз в день, стал раз в час) — историческая разметка не сдвинулась.\n3. **Идемпотентность.** Запустили snapshot два раза подряд — второй ничего не меняет, потому что source.updated_at не изменился.\n\nКогда хочется time запуска? Когда нужно знать «когда dbt узнал про это изменение» — для compliance / debug. Решение: добавить отдельную колонку `dbt_observed_at = run_started_at` через post-hook:\n\n```sql\nALTER TABLE customers_snapshot ADD COLUMN IF NOT EXISTS dbt_observed_at TIMESTAMP;\n-- post-hook на snapshot:\nUPDATE {{ this }} SET dbt_observed_at = '{{ run_started_at }}'\nWHERE dbt_observed_at IS NULL;\n```\n\nТогда есть оба поля: dbt_valid_from (когда реально изменилось) и dbt_observed_at (когда dbt записал). Это hybrid pattern для регуляторных проектов.

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

Результат: 0 из 0
Аналитический
Вопрос 1 из 6. В source app.customers есть колонка updated_at, но 5% строк имеют updated_at IS NULL (легаси записи). Snapshot с strategy=timestamp, updated_at=updated_at. Что произойдёт со строками с NULL?

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

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

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

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