День в ETL оказался длиной 25 часов: строим границы через часовой пояс

Ежедневная витрина для Берлина потеряла последний час воскресенья. Загрузчик перевёл начало локального дня в UTC и затем прибавил 24 часа. В обычный день это выглядело правильно, но осенью часы переводят назад, и локальный календарный день занимает 25 часов.

Бизнес-день задаётся календарной датой и временной зоной. Поэтому сначала нужно построить начало этой даты и начало следующей даты в нужной зоне, затем каждую границу перевести в UTC. Продолжительность вычисляется уже между UTC-моментами. В Москве сейчас нет сезонного перевода, но международный источник может использовать другой календарь.

Две полуночи вместо фиксированной длительности

Python zoneinfo использует правила IANA. На Windows для такого примера может понадобиться установленный пакет tzdata. Выберем 25 октября 2026 года: день осеннего перехода в Europe/Berlin. События уже содержат однозначные UTC-метки.

from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo

day = date(2026, 10, 25)
zone = ZoneInfo("Europe/Berlin")
start = datetime.combine(day, time.min, tzinfo=zone).astimezone(timezone.utc)
end = datetime.combine(day + timedelta(days=1), time.min, tzinfo=zone).astimezone(timezone.utc)
events = [
    ("before", datetime(2026, 10, 24, 21, 59, tzinfo=timezone.utc)),
    ("first", start),
    ("late", datetime(2026, 10, 25, 22, 30, tzinfo=timezone.utc)),
    ("next", end),
]
selected = [name for name, moment in events if start <= moment < end]
print(start.isoformat(), end.isoformat())
print("hours", (end - start).total_seconds() / 3600, selected)
assert (end - start) == timedelta(hours=25)
assert selected == ["first", "late"]

Начало получится 24 октября в 22:00 UTC, конец — 25 октября в 23:00 UTC. Позднее событие в 22:30 UTC относится к выбранному берлинскому дню. Интервал фиксированной длины 24 часа исключил бы его по ошибке.

Правая граница исключена: событие в начале следующего дня попадёт только в следующий расчёт. Для соседних дат конец одного интервала должен точно совпадать с началом другого. Это простой контроль отсутствия дыр и двойного подсчёта.

Сначала разберитесь с исходным временем

Если источник присылает UTC или timestamp с явным смещением, момент однозначен. Строка 02:30 без зоны в день осеннего перехода может обозначать два разных момента. Нельзя выбрать один автоматически только по названию города: нужен договор источника, смещение или признак повторного часа.

Весной встречается обратная проблема: некоторые локальные часы вообще не существуют. Простое присоединение tzinfo к произвольной местной строке не является проверкой её реальности. Запрашивайте у источника однозначное время события и сохраняйте исходное смещение для разбора.

Как встроить границы в загрузку

Передайте start и end параметрами SQL-запроса к столбцу времени, а результат положите в партицию с исходной бизнес-датой. Для Airflow используйте интервал расписания, если он уже соответствует нужному календарю; не вычисляйте вчера через текущее время worker.

Проверки календаря должны включать обычный день, весенний переход и осенний переход. Сравнивайте состав событий, а не только длительность: 23 или 25 часов сами по себе корректны. Зафиксируйте версию правил временных зон в окружении, если требуется точное воспроизведение исторических расчётов после обновления tzdata.

В программе Data Engineering Fast-Track вы построите расписания Airflow, batch-пайплайны и сценарии backfill. Откройте курс, чтобы связать календарь обработки с повторяемой загрузкой и хранением результатов.

Продолжить на практике

  • Data Engineering Fast‑Track: Kafka → Airflow → Spark ↗

    Практический fast-track по data engineering: за 4–6 недель собираем рабочий конвейер Kafka → Spark → Lakehouse под управлением Airflow. Разберём batch и streaming, окна и watermark, схемы и контракты. Мини‑проекты, Docker Compose, шаблоны DAG и пайплайнов. Курс подойдёт инженерам, аналитикам и тем, кто хочет освоить современные пайплайны данных с уверенным Python.