docs(warehouse): объяснены расчет строк и замена дневных разделов

Зачем: выражения «собираются в донор» и «публикуются дни» требовали
знать устройство конвейера до чтения объяснения.

Что: описаны назначение промежуточных таблиц и последовательность
расчета, записи и замены разделов. Перечитаны остальные объяснения:
выбор дней, разворачивание позиций заказа, проверка сессий и повтор запуска.

Проверка: make lint, git diff --check, локальные ссылки и якоря.
SQL без комментариев и AST обоих дагов без строк документации не изменились.

Refs: #249
This commit is contained in:
ddadmin committed 2026-09-13 15:16:26 -04:00
1 parent 5f317ba8d2
commit 086007fbd4
12 files changed
+216 -166

No files matched your search

+19 -22
View File
@@ -1,8 +1,12 @@
"""Трансформации DDS: заказы, сессии и карта идентичностей. """Трансформации DDS: заказы, сессии и карта идентичностей.
Заказы и сессии собираются в донор, затем публикуются заменой дневных партиций. Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
Карта идентичностей пересчитывается целиком после заказов и записывается новой в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
версией; публичное представление выбирает текущие строки через FINAL. разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Карта идентичностей пересчитывается целиком после заказов. Новые строки
добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
версию каждой строки.
Даг запускается без расписания и параметров. Даты он выбирает по состоянию Даг запускается без расписания и параметров. Даты он выбирает по состоянию
таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
@@ -252,16 +256,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены. """Подготовить параметры отдельной задачи replace для каждого дня.
Шаг существует ради ограничения Airflow: размножать задачу можно expand_kwargs получает список словарей и создает по одному экземпляру
только по целому возврату другой задачи — ни по ключу словаря, ни по задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
карте поверх него. Отсюда отдельный возврат под один список. параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -271,8 +270,8 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
schedule=None, schedule=None,
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы # Одновременные запуски используют одни промежуточные таблицы.
# чужую сборку между вставкой и заменой партиций. # Второй мог бы очистить результат первого до замены партиций.
max_active_runs=1, max_active_runs=1,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dds"], tags=["dds"],
@@ -301,8 +300,7 @@ def dds_transform():
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION) hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query) row = hook.get_first(query)
# Пустой ответ означает пустой источник: собирать нечего, и # Без заказов ODS расчет невозможен: задача сообщает об ошибке.
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
if row is None: if row is None:
raise AirflowException( raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; " "в ods.order нет ни одного заказа: собирать нечего; "
@@ -337,9 +335,9 @@ def dds_transform():
do_xcom_push=False, do_xcom_push=False,
) )
# Замена идёт по партиции за раз — другой формы у операции нет. День # На каждый день создается отдельная задача замены партиции.
# становится отдельной задачей: в интерфейсе видно, сколько дней несёт # Неудачную замену можно повторить, пока промежуточная таблица
# прогон, а повтор после отказа стоит одного дня, а не всей пересборки. # сохраняет рассчитанные строки этого запуска.
replace = SQLExecuteQueryOperator.partial( replace = SQLExecuteQueryOperator.partial(
task_id="replace", task_id="replace",
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
@@ -365,9 +363,8 @@ def dds_transform():
"проверьте, дошёл ли поток до хранилища" "проверьте, дошёл ли поток до хранилища"
) )
first_day, last_day = row first_day, last_day = row
# Пропуск, а не отказ: слой полон по последний прожитый день, а # Для каждого прожитого дня уже есть строки сессий.
# сегодняшний ещё идёт. Это обычное состояние повторного прогона, # Новых дней для расчета нет: группа задач будет пропущена.
# и в интерфейсе оно видно пропущенной группой.
if first_day is None: if first_day is None:
raise AirflowSkipException( raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, " f"сессии собраны по {last_day} включительно, "
+12 -8
View File
@@ -1,9 +1,12 @@
"""Трансформации слоя DM: сборка стендовых витрин. """Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым Даты для расчета выручки берутся из последнего слепка заказов и списка
хвостом событий. Обе заменяют дневные партиции. Трафик наследует пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни
ретроспективность карты идентичностей и пересчитывает всю историю новой событий и дни, где заказ еще не найден. Запросы записывают результаты
версией. Объем каждого прогона выводится из состояния слоев. в промежуточные таблицы, затем заменяют дневные разделы основных таблиц.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
""" """
from __future__ import annotations from __future__ import annotations
@@ -62,10 +65,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены. """Подготовить параметры отдельной задачи replace для каждого дня.
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же expand_kwargs получает список словарей и создает по одному экземпляру
прием разобран в ``dds_transform.py`` на замене партиций заказов. задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -141,7 +145,7 @@ def dm_transform():
@task_group(group_id="purchase_vs_orders") @task_group(group_id="purchase_vs_orders")
def purchase_vs_orders(): def purchase_vs_orders():
"""Сверка: окно заказов, прожитый хвост и предварительные дни.""" """Сверка: изменяемые заказы, прожитые покупки и дни ожидания заказа."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
+25 -20
View File
@@ -17,10 +17,11 @@
`EventDate` источника, дата по часовому поясу счетчика. У всех событий `EventDate` источника, дата по часовому поясу счетчика. У всех событий
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно. одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
Оба окна нарезки используют порядок `(event_time, event_id)`: при равном SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
времени события идут по идентификатору. Одинаковый порядок нужен, чтобы начало нового визита, вторая по этим отметкам присваивает событиям номер
признак начала визита и накопленный номер визита относились к одним визита. Обе используют порядок `(event_time, event_id)`: при равном времени
и тем же событиям. события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор ## Идентификатор
@@ -73,24 +74,28 @@
Три SQL-файла выполняются последовательно в группе `session` дага Три SQL-файла выполняются последовательно в группе `session` дага
`dds_transform`: `dds_transform`:
1. `session_scope.sql` находит первый день событий, которого еще нет 1. `session_scope.sql` находит первую дату событий, за которую еще нет
в таблице сессий. Последний день для сборки — первый день событий ODS сессий. Последний день для расчета — первый день событий ODS
плюс `world_position` минус один. Так в расчет попадают только полностью плюс `world_position` минус один; `world_position` — номер следующего
дня для генерации. Так в расчет попадают только полностью
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает; прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
пропускается. пропускается.
2. `session_rebuild.sql` очищает `dds.session_stage_rep` и собирает весь 2. `session_rebuild.sql` очищает промежуточную таблицу
выбранный диапазон в `dds.session_stage_dist`. Это донор — служебная `dds.session_stage_rep`. Затем рассчитывает сессии выбранных дней
таблица для подготовки результата до публикации. Вставка ждет доставки и записывает строки через `dds.session_stage_dist`. Пока идет расчет,
в основной таблице остаются прежние данные. Вставка ждет доставки
строк на шарды благодаря `distributed_foreground_insert = 1`. строк на шарды благодаря `distributed_foreground_insert = 1`.
3. `session_replace.sql` заменяет каждый день в `dds.session_rep` партицией 3. `session_replace.sql` заменяет в `dds.session_rep` раздел за каждый
донора. Для каждого дня Airflow создает отдельную задачу `replace`. выбранный день готовым разделом `dds.session_stage_rep`. Для каждого
дня Airflow создает отдельную задачу `replace`.
В обычном ходе сессии прожитого дня больше не меняются. Если пропущен ранний Когда все события завершенного дня приняты в ODS, их набор на стенде
больше не меняется. Поэтому готовые сессии обычно не пересчитываются. Если пропущен ранний
день, диапазон включает и все последующие прожитые дни, даже уже собранные. день, диапазон включает и все последующие прожитые дни, даже уже собранные.
Поиск по наличию даты не обнаружит неполный день, у которого часть строк Если за день сохранилась хотя бы одна сессия, поиск пропущенных дней
сохранилась. Условия повторного запуска и границы атомарности замены не обнаружит потерю остальных. Условия повторного запуска и замены
описаны в [справочнике конвейера](../etl.md#идемпотентность). разделов описаны в [справочнике конвейера](../etl.md#идемпотентность).
Сессий еще идущего дня в модели нет; его события доступны через Сессий еще идущего дня в модели нет; его события доступны через
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий `dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
@@ -103,10 +108,10 @@
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v` из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
скрывает `VisitID`. скрывает `VisitID`.
Нулевое число расхождений подтверждает совпадение счета, но не всех Нулевое число расхождений подтверждает только число сессий каждого
границ и атрибутов сессий. Две ошибки внутри одного посетителя и дня посетителя за день. Например, ошибочное объединение двух визитов
могут сохранить число визитов. Поэтому в заданиях вы дополнительно и разделение другого на два сохранят их общее число. Поэтому в заданиях
сопоставляете границы сессий по исходным событиям и проверяете страницы, вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город. UTM, устройство и город.
## Читатели ## Читатели
+76 -61
View File
@@ -13,9 +13,11 @@
повторного расчета. Таблицу наполняет `dm_transform`. повторного расчета. Таблицу наполняет `dm_transform`.
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
представление `_v` с явным списком колонок. Для замены дневных партиций представление `_v` с явным списком колонок. Для обновления по дням
служат еще две таблицы: `_stage_rep` и `_stage_dist`. Это донор — место нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
сборки результата перед публикацией. Подробности — в В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
Затем дневные разделы основной таблицы заменяют готовыми разделами
промежуточной. Такие разделы называются партициями. Подробности — в
[справочнике хранилища](storage.md#имена-объектов). [справочнике хранилища](storage.md#имена-объектов).
Если запрос только переименовывает или дополняет поля и сохраняет одну Если запрос только переименовывает или дополняет поля и сохраняет одну
@@ -54,23 +56,25 @@
Дни для пересчета выбирают по тому, как меняется источник: Дни для пересчета выбирают по тому, как меняется источник:
- События и сессии прожитого дня на стенде больше не меняются. Витрины - События и сессии прожитого дня на стенде больше не меняются. Витрины
по ним собирают диапазон от первого пропущенного дня до последнего по ним рассчитывают показатели от первого пропущенного дня до последнего
прожитого и заменяют дневные партиции. прожитого и заменяют дневные партиции.
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS - Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
задает диапазон дат для пересборки; пропущенные дни могут расширить его. задает диапазон дат для пересборки; пропущенные дни могут расширить его.
- Новые связи карты идентичностей меняют трактовку прошлых визитов: - Новая связь в карте идентичностей позволяет определить пользователя
сегодняшняя покупка позволяет узнать пользователя и в ранних днях. по его прежним визитам: один `client_id` мог встречаться и до покупки,
которая связала его с `user_id`.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком. Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
`daily_traffic` каждый раз добавляет всю историю новой версией `daily_traffic` каждый раз добавляет всю историю новой версией
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
строки выбирает представление через `FINAL`. строки выбирает представление через `FINAL`.
У `purchase_vs_orders` два источника. Левая граница — самая ранняя У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
из трех дат: начало окна последнего слепка, первый пропущенный день из трех дат: первая дата оформления заказа в последнем слепке, первый
и первый день с `awaiting_order`. Последний нужен потому, что строка пропущенный день и первый день с `awaiting_order` — покупкой, для которой
ожидания уже есть в витрине и поиском пропусков не обнаружится. заказ пока не поступил. День ожидания проверяется отдельно: строки за него
Правая граница — более поздняя из конца окна заказов и последнего уже есть в витрине, поэтому он не считается пропущенным.
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
прожитого дня с покупкой. Один запуск пересобирает весь отрезок, прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней. даже если с предыдущего запуска прошло больше семи модельных дней.
@@ -81,22 +85,25 @@
## Витрины стенда ## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не и `_load_ts` — идентификатор запуска и время записи. Новая колонка
попадет. физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v` ### `dm.revenue_daily_v`
Одна строка — день заказа и категория товара. Витрина читает только оплаченные Одна строка содержит итоги оплаченных заказов за день по категории товара.
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
категорию артикула через `dic.products`. Один заказ с несколькими позициями и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
категории считается в `orders` один раз. `units` складывает количество единиц, становятся одной строкой. Категория артикула берется из `dic.products`.
`revenue` складывает цену позиции × количество. Скидка и доставка
в выручку не входят; итог заказа между категориями не делится. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Первый запуск строит всю доступную историю. Следующие берут границы дней из `orders` считает разные заказы: несколько позиций одной категории
последнего слепка ODS: только источник знает, какие даты еще может изменить. в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
`revenue` — сумма произведений цены на количество. Скидка и доставка
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
с округлением до двух знаков после запятой.
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
берут диапазон дат оформления заказов из последнего слепка ODS.
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине, Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
только на вопрос об объеме. только на вопрос об объеме.
@@ -107,8 +114,9 @@
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
запрос не возвращает строк, и задача завершается ошибкой. запрос не возвращает строк, и задача завершается ошибкой.
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql) 2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
очищает донор и одной вставкой собирает весь диапазон в очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные
`dm.revenue_daily_stage_dist`. Вставка ждет доставки на шарды. дни и записывает строки через `dm.revenue_daily_stage_dist`.
Вставка ждет доставки строк на шарды.
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql) 3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date` заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
готовой партицией `dm.revenue_daily_stage_rep`. готовой партицией `dm.revenue_daily_stage_rep`.
@@ -116,33 +124,38 @@
Повтор замены не добавляет копий строк. Ограничения при частичном отказе Повтор замены не добавляет копий строк. Ограничения при частичном отказе
описаны в [конвейере](etl.md#идемпотентность). описаны в [конвейере](etl.md#идемпотентность).
Цель и донор шардируются Основная и промежуточная таблицы распределяют строки по
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а `cityHash64(product_category)`. На каждый день приходится не больше шести
шесть категорий стартового мира близки по объему. Более сложный составной ключ категорий стартового мира. Для такой маленькой таблицы достаточно простого
распределил бы эту маленькую таблицу без нового урока. ключа по категории.
### `dm.purchase_vs_orders_v` ### `dm.purchase_vs_orders_v`
Одна строка — один заказ. Покупки из `dds.event_v` сначала сворачиваются по Одна строка сопоставляет события покупки с заказом магазина. События
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
сумму. Отдельный счет событий позволяет обнаружить повтор покупки по формату источника у покупки один номер и одна заявленная сумма.
и присвоить класс `duplicate_event`. Заказы приходят из `dds.order_v`. Обе стороны записаны Отдельный счет событий позволяет обнаружить повтор покупки
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: левая и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Distributed-таблица без подзапроса вернула бы каждый непарный заказ
по разу с каждого шарда Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)). ([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При Если для строки нет пары, поля отсутствующей стороны получают `NULL`
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total` нулями или пустыми строками, которые сложнее отличить от данных источника.
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку: Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`, `awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без `duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным. события остается отмененным, а не потерянным.
Партиции витрины разделены по `order_day`, цель и донор шардируются по Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
заменяется по дням. Первичная сборка охватывает всю историю обоих источников; выбранный диапазон и одной вставкой записывает строки в промежуточную
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше. следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v` ### `dm.daily_traffic_v`
@@ -150,8 +163,9 @@ Distributed-таблица без подзапроса вернула бы ка
В строке один день. `visitors` считает разные `client_id` из В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения `dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине. с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного Несколько кук, связанных с одним пользователем магазина, учитываются
известного пользователя, поэтому `known_users <= visitors`. в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3 Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
чтение через публичные `_v` с `distributed_product_mode = 'local'` чтение через публичные `_v` с `distributed_product_mode = 'local'`
@@ -176,8 +190,8 @@ Distributed-таблица без подзапроса вернула бы ка
- `failed_rows` — число отсутствующих или различающихся ключей; - `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`. - `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
перечисляет колонки явно. Порогов и промежуточного состояния нет. сводки. Даже одно расхождение дает `fail`.
## Звено DQ конвейера ## Звено DQ конвейера
@@ -201,29 +215,30 @@ Distributed-таблица без подзапроса вернула бы ка
сравниваются с прямыми агрегатами `dds.session_v` и сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`. `dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут Поэтому потерянная строка остается видна на другой стороне сравнения.
скомпенсировать друг друга общим итогом: `failed_rows` считается после Расхождения считаются по ключам: завышение одного показателя не скроет
сопоставления деловых ключей. занижение другого в общей сумме за день.
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая `classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
проверка доставки и обработки эталонных данных стенда. Она не проверяет проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор; у настоящего магазина такого эталона нет. сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг файл JSON в Python. Результаты всех пяти проверок записываются в общую
заменяет проверенные дневные партиции. Затем именованные задачи читают промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраниться до красного завершения, а ошибка сборки не затирает прежний сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
результат. Красная задача печатает не больше двадцати нарушенных ключей за Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки не выполнен, прежняя сводка остается на месте. Задача с расхождениями
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date, заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`. business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре. Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
Такой день удаляется из сводки отдельно: старая красная строка не должна для него строк. Даг отдельно удаляет прежний результат за этот день
превращаться в историю попыток. из сводки.
## Даг самопроверки ## Даг самопроверки
+39 -22
View File
@@ -1,21 +1,23 @@
# Дневной конвейер трансформаций # Дневной конвейер трансформаций
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
результат. Здесь описаны запуск, выбор дней и публикация данных. результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md). Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
## Форма ## Форма
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform` `etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего. и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
Для этого `TriggerDagRunOperator` ждет завершения созданного им запуска; Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
его ошибка завершает ошибкой и соответствующую задачу конвейера. Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6). Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
можно запустить даг слоя вручную. У каждого разрешен один активный запуск: можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
два запуска одного дага не должны одновременно заполнять общий донор. два запуска одного дага используют одни промежуточные таблицы и могли бы
Внутри дага модели собраны в отдельные задачи и группы задач. стереть результаты друг друга. Расчет каждой модели выполняется отдельной
задачей или группой задач.
## Три пути запуска ## Три пути запуска
@@ -40,9 +42,11 @@ world_next_day ─────> источники без ETL
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
не нужно: даги определяют их по состоянию таблиц и `world_position`. не нужно: даги определяют их по состоянию таблиц и `world_position`.
Например, после генерации семи дней без ETL один запуск соберет все семь. Например, после генерации семи дней без ETL один запуск рассчитает сессии
за все семь дней.
Сессии собираются от первого отсутствующего дня до последнего прожитого. Для расчета сессий выбираются дни от первого, за который в DDS еще нет
сессий, до последнего прожитого.
`world_position` хранит номер следующего дня для генерации, поэтому `world_position` хранит номер следующего дня для генерации, поэтому
последний прожитый день равен `min(EventDate) + world_position - 1`. последний прожитый день равен `min(EventDate) + world_position - 1`.
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL. Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
@@ -52,9 +56,10 @@ world_next_day ─────> источники без ETL
Для заказов диапазон задает последний принятый слепок ODS. Среди строк Для заказов диапазон задает последний принятый слепок ODS. Среди строк
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
даты оформления заказа. Эти дни еще могут измениться. Если в DDS даты оформления заказа. Состояние заказов за эти дни еще может измениться.
отсутствует более ранний день, который есть в ODS, левая граница Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
расширяется до него. Все дни отрезка пересобираются. начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Позиция мира для выбора дней заказов не подходит: с событиями дня D Позиция мира для выбора дней заказов не подходит: с событиями дня D
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS; приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
@@ -62,8 +67,8 @@ world_next_day ─────> источники без ETL
Карта идентичностей пересчитывается целиком. Витрины выбирают дни Карта идентичностей пересчитывается целиком. Витрины выбирают дни
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки). по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
Поиск отсутствующих дней проверяет только наличие даты: частично Если за день сохранилась хотя бы одна строка, запрос не считает его
заполненный день требует отдельной проверки. пропущенным. Полноту данных проверяют отдельно.
## Готовность источника ## Готовность источника
@@ -96,17 +101,29 @@ world_next_day ─────> источники без ETL
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts` не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
при новой сборке меняются. при новой сборке меняются.
Для дневных таблиц порядок такой: `scope` выбирает даты, `rebuild` очищает Результат дневного расчета сначала записывается в промежуточную таблицу.
донор и собирает в него весь диапазон, затем отдельные задачи `replace` Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
публикуют дни через `REPLACE PARTITION`. Донор — служебная таблица с той же еще рассчитываются. Например, для сессий это `dds.session_stage_rep`,
структурой и раскладкой по шардам, что у цели. Запись в `_stage_dist` а основная таблица — `dds.session_rep`.
с `distributed_foreground_insert = 1` дожидается доставки строк перед заменой.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. Она атомарна Задачи выполняются в таком порядке:
на каждом шарде: читатель видит старую или новую партицию. Единой
транзакции для двух шардов и всех дней нет. Если замена прошла только 1. `scope` выбирает даты для расчета.
на одном шарде, другой может сохранить прежние данные. Повтор задачи 2. `rebuild` очищает промежуточную таблицу, рассчитывает строки за выбранные
`replace` с тем же готовым донором повторит замену без добавления копий. дни и записывает их через `_stage_dist`. Настройка
`distributed_foreground_insert = 1` заставляет вставку дождаться
доставки строк на шарды.
3. `replace` заменяет в основной таблице раздел за каждый выбранный день
готовым разделом промежуточной таблицы. Раздел таблицы называется
партицией; для замены используется `REPLACE PARTITION`.
Структура и распределение строк по шардам у двух таблиц должны совпадать.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. На каждом шарде
читатель видит либо старый, либо новый раздел целиком — это атомарность
замены. Общей транзакции для двух шардов и всех дней нет: при сбое один
шард может уже содержать новый результат, а другой — прежний. Повторить
`replace` можно, пока промежуточная таблица хранит тот же результат;
копии строк в основной таблице от этого не появятся.
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
+16 -12
View File
@@ -95,20 +95,24 @@ DDS: представление читает события ODS.
Само совпадение ключей еще не гарантирует локального выполнения запроса Само совпадение ключей еще не гарантирует локального выполнения запроса
через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v). через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
Для дневной пересборки DDS и DM используют донор — служебную таблицу, Для дневной пересборки DDS и DM результат сначала записывают
в которой готовят результат перед заменой партиций. Например, в промежуточную таблицу. Основная таблица в это время остается доступна
`dds.order_stage_rep` объявлена через `AS dds.order_rep`, а запись для чтения с прежними данными. Например, заказы записываются через
в нее идет через `dds.order_stage_dist` с тем же `cityHash64(order_id)`. `dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
У цели и донора должны совпадать структура, ключи партиции и сортировки, заменяют разделы основной `dds.order_rep`.
первичный ключ и политика хранения. Эти требования сверены через Context7
с [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition) У промежуточной и основной таблиц должны совпадать структура, ключи
для используемой схемы на ClickHouse 26.3. Поэтому донор объявлен через партиции и сортировки, первичный ключ и политика хранения. Поэтому
`AS` цели с совпадающими ключами. DDL показан в `dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими
ключами. Требования сверены через Context7 с
[документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
для схемы на ClickHouse 26.3. DDL показан в
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql). [`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
`REPLACE PARTITION` копирует готовую партицию между локальными таблицами Промежуточная таблица должна также распределять строки по тому же ключу,
на каждом шарде; заново распределять строки по ключу цели эта операция здесь — `cityHash64(order_id)`. `REPLACE PARTITION` копирует раздел между
не будет. Поэтому донор должен сразу иметь ту же раскладку, что и цель. локальными таблицами каждого шарда и не перераспределяет строки.
Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде.
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
доставки данных на шарды. доставки данных на шарды.
+8 -5
View File
@@ -1,16 +1,19 @@
-- DDS: сборка сессий выбранных дней в донор dds.session_stage_dist. -- DDS: расчет сессий за дни от first_day до last_day включительно.
-- Донор очищается целиком; одна вставка собирает весь диапазон. -- Сначала очищаем промежуточную таблицу session_stage_rep, затем записываем
-- в нее рассчитанные строки через session_stage_dist. Основная session_rep
-- остается прежней до выполнения session_replace.sql.
-- --
-- Первое окно помечает событие единицей, если с предыдущего события того же -- Первое окно помечает событие единицей, если с предыдущего события того же
-- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует -- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует
-- эти единицы: получается номер визита внутри посетителя и дня. -- эти единицы: получается номер визита для каждого посетителя за день.
-- PARTITION BY (client_id, event_date) разделяет визиты по модельной полночи. -- PARTITION BY (client_id, event_date) считает каждый модельный день отдельно:
-- события разных дней не попадут в одну сессию даже при короткой паузе.
-- Учитываются все типы событий. -- Учитываются все типы событий.
-- --
-- У первого события lagInFrame возвращает его собственное время: разница -- У первого события lagInFrame возвращает его собственное время: разница
-- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю. -- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю.
-- Оба окна используют (event_time, event_id), чтобы при равном времени -- Оба окна используют (event_time, event_id), чтобы при равном времени
-- признак начала и накопленный номер визита относились к одним событиям. -- отметка начала и номер визита относились к одним событиям.
-- Порог 1800 секунд — правило визита; он не передается параметром. -- Порог 1800 секунд — правило визита; он не передается параметром.
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL; ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
+4 -3
View File
@@ -1,7 +1,8 @@
-- DDS: замена одного дня в session_rep готовой партицией session_stage_rep. -- DDS: заменяем раздел session_rep за выбранный день готовым разделом
-- промежуточной session_stage_rep. Раздел таблицы называется партицией.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда. -- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- Замена атомарна на одном шарде, общей транзакции для кластера нет. -- На одном шарде раздел меняется целиком; общей транзакции для кластера нет.
-- Повтор с тем же донором не добавляет копий строк. -- Повтор с теми же строками session_stage_rep не добавляет копий в session_rep.
-- --
-- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в -- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в
-- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего -- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего
+5 -4
View File
@@ -1,5 +1,5 @@
-- DDS: дни для сборки сессий, от first_day до last_day включительно. -- DDS: дни для сборки сессий, от first_day до last_day включительно.
-- Пустой ответ означает пустой ODS; NULL в first_day — отсутствие работы. -- Без событий ODS строк в ответе нет; NULL в first_day — дней для расчета нет.
-- --
-- position — номер следующего дня для генерации. Последний прожитый день -- position — номер следующего дня для генерации. Последний прожитый день
-- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта -- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта
@@ -13,9 +13,10 @@
WITH WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day, (SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day, first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Ищем первый день ODS, которого нет в DDS: максимум собранного дня -- Ищем первую дату событий ODS, за которую еще нет сессий DDS.
-- пропустил бы более ранний пробел. Частично заполненный день так не найти. -- Поиск после max(session_date) пропустил бы более раннюю потерянную дату.
-- GLOBAL сравнивает со списком дней всего DDS, а не одного шарда. -- Если за день есть хотя бы одна сессия, он уже не считается пропущенным.
-- GLOBAL берет даты сессий со всех шардов.
-- minOrNull возвращает NULL, если пропусков нет. -- minOrNull возвращает NULL, если пропусков нет.
( (
SELECT minOrNull(day) SELECT minOrNull(day)
+6 -4
View File
@@ -1,7 +1,9 @@
-- DM: сборка выручки за выбранный диапазон в донор. -- DM: расчет выручки за дни от first_day до last_day включительно.
-- DROP PARTITION ALL очищает донор на каждом узле. Вставка ниже дает -- DROP PARTITION ALL очищает промежуточную revenue_daily_stage_rep.
-- одну строку на день заказа и категорию товара: только оплаченные заказы. -- Затем INSERT записывает по одной строке итогов на день и категорию товара.
-- ARRAY JOIN разворачивает согласованные позиции трех массивов вместе. -- Основная revenue_daily_rep остается прежней до revenue_daily_replace.sql.
-- В расчет входят только оплаченные заказы. ARRAY JOIN превращает артикул,
-- количество и цену с одним индексом в массивах в одну строку позиции заказа.
-- Причина выбора DROP вместо TRUNCATE — в sql/dds/order_rebuild.sql. -- Причина выбора DROP вместо TRUNCATE — в sql/dds/order_rebuild.sql.
ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL; DROP PARTITION ALL;
+4 -3
View File
@@ -1,8 +1,9 @@
-- DM: замена дня выручки готовой партицией донора. -- DM: заменяем раздел revenue_daily_rep за выбранный день готовым разделом
-- промежуточной revenue_daily_stage_rep. Раздел таблицы называется партицией.
-- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse -- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse
-- в ALTER ... PARTITION здесь не поддерживается. -- в ALTER ... PARTITION здесь не поддерживается.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда. -- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- Замена атомарна на одном шарде, общей транзакции для кластера нет. -- На одном шарде раздел меняется целиком; общей транзакции для кластера нет.
-- Повтор с тем же донором не добавляет копий строк. -- Повтор с теми же строками промежуточной таблицы не добавляет копий в основной.
ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep;
+2 -2
View File
@@ -1,6 +1,6 @@
-- DM: диапазон пересборки выручки, first_day и last_day включительно. -- DM: диапазон пересборки выручки, first_day и last_day включительно.
-- Последний слепок ODS задает даты оформления еще изменяемых заказов. -- Последний слепок ODS задает даты оформления заказов, которые еще могут
-- Агрегат читает DDS; ODS нужен только для границ диапазона. -- измениться. Саму выручку считаем по DDS; ODS нужен только для выбора дат.
-- Левая граница расширяется до первого дня с оплаченными заказами DDS, -- Левая граница расширяется до первого дня с оплаченными заказами DDS,
-- которого нет в витрине. Так первый запуск охватывает историю выручки. -- которого нет в витрине. Так первый запуск охватывает историю выручки.
-- По наличию даты нельзя обнаружить частично заполненный день. -- По наличию даты нельзя обнаружить частично заполненный день.