# Дневной конвейер трансформаций Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет результат. Здесь описаны запуск, выбор дней и публикация данных. Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md). ## Форма `etl_pipeline` последовательно запускает `dds_transform`, `dm_transform` и `dq_check`. Каждый следующий даг начинается после успеха предыдущего. Для этого `TriggerDagRunOperator` ждет завершения созданного им запуска; его ошибка завершает ошибкой и соответствующую задачу конвейера. Поведение проверено через Context7 по документации Airflow 3.x (3.1.6). У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора можно запустить даг слоя вручную. У каждого разрешен один активный запуск: два запуска одного дага не должны одновременно заполнять общий донор. Внутри дага модели собраны в отдельные задачи и группы задач. ## Три пути запуска ```text world_initialize ─┐ world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check запуск рукой ─────┘ world_next_day ─────> источники без ETL ``` - `world_live_day` запускает конвейер после успешной генерации, приема заказов и обновления `world_position`. Если трансформация упала, день уже прожит и позиция не откатывается. После исправления причины запустите `etl_pipeline` вручную. - `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер не запускает. После него запустите `etl_pipeline` сами. - `world_initialize` запускает конвейер последним шагом: при успешном первом `make up` начальные данные уже собраны в DDS и DM. ## Объём прогона Объем прогона — дни, которые обрабатывает один запуск. Даты передавать не нужно: даги определяют их по состоянию таблиц и `world_position`. Например, после генерации семи дней без ETL один запуск соберет все семь. Сессии собираются от первого отсутствующего дня до последнего прожитого. `world_position` хранит номер следующего дня для генерации, поэтому последний прожитый день равен `min(EventDate) + world_position - 1`. Начало календаря берется из ODS; события ранних дней там не удаляются по TTL. Один `max(EventDate)` не годится: события за день есть и во время его генерации, когда последние визиты еще не закончились. Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql). Для заказов диапазон задает последний принятый слепок ODS. Среди строк с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя даты оформления заказа. Эти дни еще могут измениться. Если в DDS отсутствует более ранний день, который есть в ODS, левая граница расширяется до него. Все дни отрезка пересобираются. Позиция мира для выбора дней заказов не подходит: с событиями дня D приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS; их наличие еще не доказывает полноту слепка. Карта идентичностей пересчитывается целиком. Витрины выбирают дни по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки). Поиск отсутствующих дней проверяет только наличие даты: частично заполненный день требует отдельной проверки. ## Готовность источника Перед сборкой DDS задача `wait_for_source` последовательно ждет двух условий: 1. Группа `clickstream_hits` дочитала оба раздела топика `hits`. Подтвержденные смещения сравниваются с верхними смещениями Kafka. Группа и смещения должны существовать, суммарное отставание — равняться нулю. 2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды. Опросы идут с паузой в три секунды; на обе стадии задан общий срок 300 секунд. Журнал показывает смещения и отставание Kafka, затем число файлов и `last_exception` очереди на каждой ноде. Если готовность не подтверждена за срок ожидания, задача завершается ошибкой. Одного нулевого отставания Kafka мало: сообщение может быть подтверждено, пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest` тоже не заменяет эти проверки: у заказов другой топик и потребитель. Эти условия проверяют доставку на момент опроса. Они не останавливают поступление новых событий и не сверяют содержимое таблиц. Поэтому `world_position` отдельно ограничивает сборку сессий прожитыми днями, а качество результата проверяет `dq_check`. ## Идемпотентность Идемпотентность означает, что повторная обработка тех же исходных данных не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts` при новой сборке меняются. Для дневных таблиц порядок такой: `scope` выбирает даты, `rebuild` очищает донор и собирает в него весь диапазон, затем отдельные задачи `replace` публикуют дни через `REPLACE PARTITION`. Донор — служебная таблица с той же структурой и раскладкой по шардам, что у цели. Запись в `_stage_dist` с `distributed_foreground_insert = 1` дожидается доставки строк перед заменой. Замена выполняется на локальных `_rep` с `ON CLUSTER`. Она атомарна на каждом шарде: читатель видит старую или новую партицию. Единой транзакции для двух шардов и всех дней нет. Если замена прошла только на одном шарде, другой может сохранить прежние данные. Повтор задачи `replace` с тем же готовым донором повторит замену без добавления копий. Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить все дни неудачного запуска: если дата уже есть в целевой таблице, поиск пропусков ее не выберет. В частности, изменение SQL сессий само по себе не заставляет пересчитать заполненные дни. Повторная сборка выбранного дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md). Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`. Публичные представления читают текущие строки через `FINAL`. Старые версии могут физически оставаться до фонового слияния; повторяемость относится к результату чтения, а не к числу строк на диске. ## Чего конвейер о себе не знает Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ. - **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов уносит прочитанное с собой. Пропади так строки самого старого дня окна — окно, выведенное из ярлыков, молча сузится, и день не пересоберётся ([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)). - **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит, объём следующего прогона его не тронет, а слой останется собранным наполовину из старого, наполовину из нового. - **Брак внутри `items` становится нулями.** Строгий приём внутрь позиций не смотрит, разбор по типу молчит, и цена без числа доедет до витрины выручки нулём ([`sql/dds/order_rebuild.sql`](../../sql/dds/order_rebuild.sql)). Общее у них одно, и оно же подсказывает лекарство: ответить может только сверка слоя с источником по дню — счёт заказов и позднейший `updated_at` с обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает, а проверяет другой. Так две роли остаются различимы, и ни одна не притворяется другой. ## Не входит - Партиционная переобработка — переиграть произвольный прошлый день или диапазон, включая форму необязательных параметров того же дага. - Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной лабы на эту тему — #149. - Состав витрин `dm_transform` и проверок `dq_check` — [дока слоя DM](dm.md). ## Отклонённые варианты - **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон — весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги дублировались бы, а этапу 5 пришлось бы выдирать их обратно. - **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом режиме слои копили бы отставание молча. - **Временный даг с нейтральным именем до этапа 5.** Дневная сборка вперёд никуда не денется, переезд был бы переименованием ради выдуманной границы. - **Диапазон дней параметрами триггера.** Ручному запуску менти параметры передавать некому, а перезапуск после падения требовал бы помнить conf упавшего прогона. - **Принять гонку с хвостом `hits`.** Дыра всплыла бы в DQ-сверке, а чиниться — не раньше этапа 5. ## Связанные решения - [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не переживает отказ выше: отсюда ожидания в триггерах. - [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый даг живого дня и дёргает конвейер. - [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему прикладным процессом владеет Airflow и почему `world_initialize` кончается собранными слоями. - Резолюция — тикет #134 карты #127; что пересобирает каждая сущность — [заказ](dds/order.md), [сессия](dds/session.md), [карта идентичностей](dds/identity.md).