Files
clickstream-data-platform/docs/architecture/etl.md
T
ddadminandClaude Opus 5 a82c75705d feat(ods): версией заказа стала дата слепка, окно DDS — из данных
- Зачем:
  - ярлык snapshot_date выжившей строки доставался жребием, а объём
    пересборки DDS держался на константе окна с двумя несверяемыми копиями
    (ADR 0014, развилка #163).
- Что:
  - ods.order_rep переведена на ReplacingMergeTree(snapshot_date): выживает
    строка позднейшего слепка, ярлык стал правдивым.
  - order_scope.sql выводит окно из дней последнего слепка; ORDER_WINDOW_DAYS
    и параметр window_days сняты со стенда, мёртвая защита краем источника
    убрана.
  - документы и комментарии SQL приведены к ADR 0014: роль ODS, условие
    «одна дата — один слепок», объём прогона и слепые пятна конвейера.
- Проверка:
  - make lint; make rebuild-storage, затем make smoke, make check-clickhouse,
    make check-services — зелёные.
  - после двух слепков snapshot_date всех 1917 строк ods.order_v равен
    последнему слепку заказа, расхождений ноль; деловые поля построчно
    совпали с эталоном argMax по updated_at из сырья.
  - dds_transform без K: первый прогон 06-01…06-07, после нового дня мира
    06-02…06-08, повтор прогона содержимого не изменил.

Closes #165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:28:14 +03:00

13 KiB

Дневной конвейер трансформаций

Как принятые источники превращаются в модель DDS и витрины DM: кто запускает трансформации, когда и в каком объёме. Правила самих сущностей — зерно, движки, что именно пересобирается — живут в доках слоя (карта модели DDS); здесь — только конвейер.

Спека обещала etl_pipeline этапом 5 — он понадобился раньше. Конвейер рождается этапом 4, и временного в нём ничего нет: дневная сборка вперёд останется и после. Этапу 5 достаётся его законный предмет — партиционная переобработка, умение переиграть произвольный прошлый день по команде.

Форма

Дневной конвейер на стенде один — даг etl_pipeline. Своей работы у него нет: он дёргает слойные даги последовательно и ждёт каждый — dds_transform, затем dm_transform. Контур DQ этапа 4 встаёт его же звеном после DM; состав звена — в доке слоя DM.

Слойные даги устроены по образцу orders_ingest: без расписания, один активный прогон, запускаются триггером конвейера или рукой при разборе происшествий. Витрины внутри dm_transform — отдельные задачи.

Три пути запуска

  • Живой день. world_live_day дёргает конвейер с ожиданием — последним шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но зеленеть, не узнав, разложился ли день, работник не должен (ADR 0003). Такой отказ чинится перезапуском etl_pipeline рукой — перезапуску ничего не нужно помнить, см. «Объём прогона».
  • Разгон пачкой. world_next_day конвейер не дёргает: пульт управляет генератором и только им. После разгона менти запускает конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
  • Рождение мира. world_initialize дёргает конвейер последним шагом: make up отдаёт стенд с собранными слоями, а не пустые витрины. Уроки ручного запуска начинаются со следующих дней.

Объём прогона

Параметров у конвейера нет: объём он выводит сам из world_position и состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта идентичностей пересчитывается целиком; правила витрин — у слоя DM. Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и после падения, а разгону на семь дней хватает одного прогона.

Окно заказов — дни, которые нёс последний слепок. Называет их сам источник: snapshot_date выжившей строки равен последнему слепку, в котором заказ был, поэтому оба края окна трансформация читает прямо из ODS (ADR 0014). Своего числа окна у стенда нет, и расходиться с миром нечему.

Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.

У сессий всё наоборот: им отвечает только позиция, потому что вопрос у них другой. Им нужен не день, о котором рассказал источник, а день, который мир прожил: события живой день от прожитого не отличают — и посреди дня D, и сразу после его конца позднейший день событий равен D. Соберись сессии посреди дня, визит, начатый в 23:50, остался бы обрезанным навсегда: замёрзший день слой не пересобирает. Отсюда правило — последний прожитый день равен первому дню событий плюс позиция минус один (sql/dds/session_scope.sql). Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не позиция, а «Готовность источника».

Готовность источника

Первым шагом dds_transform обязан дождаться, пока хвост топика hits дожёван: отставание его потребителей нулевое (system.kafka_consumers). Зелёный приём заказов о событиях не говорит ничего — топики разные и потребители разные. В живом режиме зазор ничтожен, но в пакетном генератор выстреливает дни в топик быстрее, чем чтец их прожёвывает, и сессии собираются по недожёванному дню. DQ-сверка такой день подсветит, но починить его до этапа 5 нечем: профилактика дешевле ремонта, которого ещё нет.

Шага этого в даге ещё нет — он приезжает отдельным тикетом (#156), и до тех пор дыра открыта: сессии считают день прожитым по позиции мира, а хвост потока не стережёт никто. На приёмке отставание смотрят руками.

Идемпотентность

Партиция не заменяется простой вставкой. День собирается в таблицу-двойник — вставка через Distributed раскладывает строки по шардам, — затем REPLACE PARTITION на кластере атомарно подменяет кусок каждого шарда. Эта механика — часть самих трансформаций и строится этапом 4: без неё дневной прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без замены: полная перезапись в ReplacingMergeTree — так живут карта идентичностей и витрина daily_traffic (слой DM).

Чего конвейер о себе не знает

Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.

  • Часть последнего слепка не доехала. Отказ вставки после коммита офсетов уносит прочитанное с собой. Пропади так строки самого старого дня окна — окно, выведенное из ярлыков, молча сузится, и день не пересоберётся (sql/dds/order_scope.sql).
  • Замена партиции прошла на одном шарде из двух. День в слое есть — значит, объём следующего прогона его не тронет, а слой останется собранным наполовину из старого, наполовину из нового.
  • Брак внутри items становится нулями. Строгий приём внутрь позиций не смотрит, разбор по типу молчит, и цена без числа доедет до витрины выручки нулём (sql/dds/order_rebuild.sql).

Общее у них одно, и оно же подсказывает лекарство: ответить может только сверка слоя с источником по дню — счёт заказов и позднейший updated_at с обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает, а проверяет другой. Так две роли остаются различимы, и ни одна не притворяется другой.

Не входит

  • Партиционная переобработка — переиграть произвольный прошлый день или диапазон, включая форму необязательных параметров того же дага, — этап 5 (#7); туда же стык #111 про дату модельного мира и logical_date.
  • Инкрементальный ETL вместо пересборки — техдолг #2.
  • Состав витрин dm_transform и звена DQ — дока слоя DM; имена задач — нарезка этапа 4.

Отклонённые варианты

  • Задачи трансформаций прямо в работниках пульта. Один зелёный прогон — весь день, но пульт — про ось времени: работников два, шаги дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
  • Make-цель или SQL рукой при приёмке. Второй оркестратор рядом с Airflow — против ADR 0013, — а в живом режиме слои копили бы отставание молча.
  • Временный даг с нейтральным именем до этапа 5. Дневная сборка вперёд никуда не денется, переезд был бы переименованием ради выдуманной границы.
  • Диапазон дней параметрами триггера. Ручному запуску менти параметры передавать некому, а перезапуск после падения требовал бы помнить conf упавшего прогона.
  • Принять гонку с хвостом hits. Дыра всплыла бы в DQ-сверке, а чиниться — не раньше этапа 5.

Связанные решения

  • ADR 0003 — зелёный конец графа не переживает отказ выше: отсюда ожидания в триггерах.
  • ADR 0009 — пульт мира: его работник живого дня и дёргает конвейер.
  • ADR 0013 — жизненный цикл мира: почему прикладным процессом владеет Airflow и почему world_initialize кончается собранными слоями.
  • Резолюция — тикет #134 карты #127; что пересобирает каждая сущность — заказ, сессия, карта идентичностей.