Lookback и late-arriving data
В уроке про append мы упомянули late-arriving events — события, которые приходят задним числом. На production-проекте это не edge case, а ежедневная реальность: Fivetran иногда задерживает sync на 24 часа, Kafka consumer retry’ит batch’и через час после originals, мобильное приложение копит события офлайн и присылает через сутки.
Если ваша incremental-модель не учитывает late-arriving data, она тихо теряет данные. Этот урок — про паттерн lookback, который решает проблему, и про trade-offs его использования.
Что такое late-arriving data
Late-arriving event — это запись с event_timestamp в прошлом, но loaded_at (timestamp поступления в warehouse) в настоящем.
Событие случилось 5 дней назад, но в warehouse прилетело только сегодня. Incremental без lookback его пропустит.
Это silent data loss. Никакая dbt-ошибка не выскочит, downstream-метрики просто будут заниженными на 1-5%. На больших масштабах это превращается в потерянный миллион долларов revenue, которую вы не учли.
Lookback pattern — recompute window
Lookback — это расширение окна incremental-фильтра, чтобы охватить возможные late-arrivals:
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='event_id'
) }}
select
event_id,
user_id,
event_type,
event_timestamp,
loaded_at
from {{ source('events', 'raw_events') }}
{% if is_incremental() %}
where event_timestamp > (
(select max(event_timestamp) from {{ this }}) - interval '3 days'
)
{% endif %}
Ключевая строка — - interval '3 days'. Это lookback window. Вместо event_timestamp > max(event_timestamp), мы говорим event_timestamp > max - 3 дня.
Что это даёт:
- На каждом run перечитываются последние 3 дня source-данных (а не только новые с last run).
- Если в эти 3 дня прилетел late-event с timestamp 2 дня назад — он будет в дельте.
- MERGE по unique_key либо INSERT (если event_id новый), либо UPDATE (если уже есть).
Late-events ловятся, дубли не создаются, спасибо MERGE.
Lookback с append — особый случай
Если стратегия append, MERGE/dedup нет. Lookback с append даёт дубли: ивенты за последние 3 дня перечитываются и инсертятся повторно.
Решение — append + dedup на следующем слое:
-- staging: append с lookback (приймет дубли)
{% if is_incremental() %}
where event_timestamp > (
(select max(event_timestamp) from {{ this }}) - interval '3 days'
)
{% endif %}
-- intermediate: dedup через qualify
select *
from {{ ref('stg_events') }}
qualify row_number() over (
partition by event_id
order by loaded_at desc
) = 1
Staging имеет дубли, intermediate их убирает. Это eventually consistent: после каждого run intermediate показывает правильные данные. Производительность хуже append без lookback, но всё ещё лучше merge.
Какое окно lookback
Окно — это компромисс между полнотой и компьютом:
Выбор окна зависит от источника и приемлемого latency. Меньше — быстрее, но риск пропусков. Больше — медленнее, но безопаснее.
Эмпирические rules of thumb:
- Real-time event streams (Kafka, Kinesis): 1-2 дня.
- Fivetran/Airbyte EL-инструменты: 3-7 дней.
- Mobile/IoT events с offline queueing: 7-14 дней.
- Legacy ETL pipelines (раз в день, иногда падают): 14-30 дней.
Late-arriving facts vs late-arriving dimensions
Data Modeling: late-arriving facts — теоретическая базаВ Kimball-терминологии есть два типа late-arrivals:
- Late-arriving facts — событие пришло задним числом. Решается lookback в fact-таблице.
- Late-arriving dimensions — dimension-запись обновилась задним числом (например, customer изменил адрес ретроактивно). Решается через SCD2 snapshots (модуль 04) с правильной обработкой.
Этот урок про facts. Dimensions — в модуле snapshots.
Тестирование late-arriving handling
Как проверить, что lookback работает?
Тест 1: имитация late-event
- Запустите
dbt runна свежей модели. Зафиксируйте row count в target (N). - Вручную вставьте в source раздачу с
event_timestamp = 2 дня назад(внутри lookback window):INSERT INTO source.raw_events VALUES ('test-event-1', 'user-42', 'click', now() - interval '2 days', now()); - Запустите
dbt runснова. - Проверьте row count в target — должен быть N+1.
- Проверьте, что эта event_id присутствует:
select * from target where event_id = 'test-event-1'.
Тест 2: имитация out-of-window event
- Снова вставьте раздачу, но с
event_timestamp = 30 дней назад(вне lookback 7 дней):INSERT INTO source.raw_events VALUES ('test-event-2', 'user-42', 'click', now() - interval '30 days', now()); - Запустите
dbt run. - Эта event НЕ должна появиться в target — она вне lookback.
- Это expected behavior — lookback не gracefully handle очень старые late events, и это компромисс.
Тест 3: dbt unit test (модуль 08)
Лучший способ — dbt unit tests с fixtures, которые включают late-arriving rows. Разберём в модуле 08.
Backfill для events вне lookback
Если событие пришло с timestamp 30 дней назад, а lookback window 7 дней — оно потеряется. Что делать?
Решение 1: широкое lookback. Если ваши источники могут опаздывать на 30 дней, поставьте lookback 35-40 дней. Компьют больше, но safety.
Решение 2: periodic full-refresh. Раз в месяц запускайте dbt run --full-refresh для критичных моделей. Это пересчитает с нуля, поймает все late events за весь период. Минус — full-refresh дорогой и долгий.
Решение 3: hybrid — обычно lookback 7 дней, раз в неделю —full-refresh. Это даёт лучшее обоих миров: ежедневная latency хорошая, недельный full-refresh ловит outliers.
Решение 4: out-of-window alerts. Detect события с timestamp вне lookback при загрузке, отправлять в dead-letter table. Дальше — manual или scheduled backfill.
DuckDB-специфика
- Lookback работает на DuckDB идентично Snowflake/BQ.
- На DuckDB обычно нет partitioning, поэтому lookback с большим окном (30 дней) сканирует много source — может быть медленным.
- На MotherDuck (DuckDB cloud) производительность сопоставима с Snowflake-small warehouse.
Production gotchas
1. Lookback без unique_key с merge
{% if is_incremental() %} с lookback читает overlapping window. Если используете append, получаете дубли (мы об этом говорили). Если merge без unique_key — config error. Lookback требует merge с правильным unique_key.
2. Lookback window меньше чем delay источника
EL-инструмент задерживает sync на 5 дней (плохая интеграция), а lookback 3 дня. Каждый день теряется по 1-2 дня данных. Diagnose: replicate-lag metric источника. Если > lookback — увеличить window.
3. Lookback на маленькой таблице — overhead
На source с 100K строк в день lookback 7 дней — это 700K чтений на каждый run. Если таблица растёт быстро (миллион строк/день), 7M чтений — это уже 5-10 секунд. На большом числе incremental-моделей в проекте — суммарная задержка.
4. Lookback несовместим с partition pruning
Если incremental_predicates узкое окно (1 день), а WHERE-фильтр в SELECT — широкое (7 дней), perf-выигрыша от predicates нет — warehouse всё равно сканирует 7 дней. Lookback и incremental_predicates должны быть согласованы по окну.
5. Lookback с changing schema
Если в source поменялась колонка (добавили referrer_url), lookback переиспользует старые строки за 7 дней. У старых строк referrer_url = NULL. В target — смесь старого и нового. Решение — on_schema_change='append_new_columns' + явная обработка NULL’ов в новой колонке.
Production-обычай: lookback в YAML, не в SQL
В крупных проектах хорошо иметь lookback как параметр модели в _models.yml:
- name: fct_events
config:
materialized: incremental
incremental_strategy: merge
unique_key: event_id
pre_hook: "{% set lookback_days = var('events_lookback_days', 7) %}"
И в SQL:
{% if is_incremental() %}
where event_timestamp > (
(select max(event_timestamp) from {{ this }})
- interval '{{ var("events_lookback_days", 7) }} days'
)
{% endif %}
Это даёт возможность через --vars events_lookback_days: 30 запустить с расширенным lookback для backfill, не меняя код.
Попробуй сам
- На вашей самой критичной incremental-модели измерьте late-arriving rate: запустите backfill с 30-дневным lookback и сравните row counts. Разница — это events, которые регулярно теряются.
- Выберите правильное lookback окно на основе source-delay metrics (Fivetran sync history, Kafka consumer lag).
- Реализуйте dbt unit test, который проверяет late-arriving handling: fixture с event timestamp 2 дня назад, проверка что он в output.
- Добавьте var-based lookback в config — для гибкости при backfill.