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

+76 -61
View File
@@ -13,9 +13,11 @@
повторного расчета. Таблицу наполняет `dm_transform`.
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
представление `_v` с явным списком колонок. Для замены дневных партиций
служат еще две таблицы: `_stage_rep` и `_stage_dist`. Это донор — место
сборки результата перед публикацией. Подробности — в
представление `_v` с явным списком колонок. Для обновления по дням
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
Затем дневные разделы основной таблицы заменяют готовыми разделами
промежуточной. Такие разделы называются партициями. Подробности — в
[справочнике хранилища](storage.md#имена-объектов).
Если запрос только переименовывает или дополняет поля и сохраняет одну
@@ -54,23 +56,25 @@
Дни для пересчета выбирают по тому, как меняется источник:
- События и сессии прожитого дня на стенде больше не меняются. Витрины
по ним собирают диапазон от первого пропущенного дня до последнего
по ним рассчитывают показатели от первого пропущенного дня до последнего
прожитого и заменяют дневные партиции.
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
задает диапазон дат для пересборки; пропущенные дни могут расширить его.
- Новые связи карты идентичностей меняют трактовку прошлых визитов:
сегодняшняя покупка позволяет узнать пользователя и в ранних днях.
- Новая связь в карте идентичностей позволяет определить пользователя
по его прежним визитам: один `client_id` мог встречаться и до покупки,
которая связала его с `user_id`.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
`daily_traffic` каждый раз добавляет всю историю новой версией
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
строки выбирает представление через `FINAL`.
У `purchase_vs_orders` два источника. Левая граница — самая ранняя
из трех дат: начало окна последнего слепка, первый пропущенный день
и первый день с `awaiting_order`. Последний нужен потому, что строка
ожидания уже есть в витрине и поиском пропусков не обнаружится.
Правая граница — более поздняя из конца окна заказов и последнего
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
из трех дат: первая дата оформления заказа в последнем слепке, первый
пропущенный день и первый день с `awaiting_order` — покупкой, для которой
заказ пока не поступил. День ожидания проверяется отдельно: строки за него
уже есть в витрине, поэтому он не считается пропущенным.
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней.
@@ -81,22 +85,25 @@
## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
попадет.
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
и `_load_ts` — идентификатор запуска и время записи. Новая колонка
физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v`
Одна строка — день заказа и категория товара. Витрина читает только оплаченные
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
категорию артикула через `dic.products`. Один заказ с несколькими позициями
категории считается в `orders` один раз. `units` складывает количество единиц,
`revenue` складывает цену позиции × количество. Скидка и доставка
в выручку не входят; итог заказа между категориями не делится. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Одна строка содержит итоги оплаченных заказов за день по категории товара.
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
становятся одной строкой. Категория артикула берется из `dic.products`.
Первый запуск строит всю доступную историю. Следующие берут границы дней из
последнего слепка ODS: только источник знает, какие даты еще может изменить.
`orders` считает разные заказы: несколько позиций одной категории
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
`revenue` — сумма произведений цены на количество. Скидка и доставка
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
с округлением до двух знаков после запятой.
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
берут диапазон дат оформления заказов из последнего слепка ODS.
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
только на вопрос об объеме.
@@ -107,8 +114,9 @@
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
запрос не возвращает строк, и задача завершается ошибкой.
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
очищает донор и одной вставкой собирает весь диапазон в
`dm.revenue_daily_stage_dist`. Вставка ждет доставки на шарды.
очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные
дни и записывает строки через `dm.revenue_daily_stage_dist`.
Вставка ждет доставки строк на шарды.
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
готовой партицией `dm.revenue_daily_stage_rep`.
@@ -116,33 +124,38 @@
Повтор замены не добавляет копий строк. Ограничения при частичном отказе
описаны в [конвейере](etl.md#идемпотентность).
Цель и донор шардируются
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
шесть категорий стартового мира близки по объему. Более сложный составной ключ
распределил бы эту маленькую таблицу без нового урока.
Основная и промежуточная таблицы распределяют строки по
`cityHash64(product_category)`. На каждый день приходится не больше шести
категорий стартового мира. Для такой маленькой таблицы достаточно простого
ключа по категории.
### `dm.purchase_vs_orders_v`
Одна строка — один заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму. Отдельный счет событий позволяет обнаружить повтор покупки
и присвоить класс `duplicate_event`. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: левая
Distributed-таблица без подзапроса вернула бы каждый непарный заказ
по разу с каждого шарда
Одна строка сопоставляет события покупки с заказом магазина. События
из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
по формату источника у покупки один номер и одна заявленная сумма.
Отдельный счет событий позволяет обнаружить повтор покупки
и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
Если для строки нет пары, поля отсутствующей стороны получают `NULL`
благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
нулями или пустыми строками, которые сложнее отличить от данных источника.
Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным.
Партиции витрины разделены по `order_day`, цель и донор шардируются по
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
выбранный диапазон и одной вставкой записывает строки в промежуточную
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v`
@@ -150,8 +163,9 @@ Distributed-таблица без подзапроса вернула бы ка
В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного
известного пользователя, поэтому `known_users <= visitors`.
Несколько кук, связанных с одним пользователем магазина, учитываются
в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
чтение через публичные `_v` с `distributed_product_mode = 'local'`
@@ -176,8 +190,8 @@ Distributed-таблица без подзапроса вернула бы ка
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
перечисляет колонки явно. Порогов и промежуточного состояния нет.
В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
сводки. Даже одно расхождение дает `fail`.
## Звено DQ конвейера
@@ -201,29 +215,30 @@ Distributed-таблица без подзапроса вернула бы ка
сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей.
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
Поэтому потерянная строка остается видна на другой стороне сравнения.
Расхождения считаются по ключам: завышение одного показателя не скроет
занижение другого в общей сумме за день.
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
заменяет проверенные дневные партиции. Затем именованные задачи читают
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Результаты всех пяти проверок записываются в общую
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
не выполнен, прежняя сводка остается на месте. Задача с расхождениями
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
для него строк. Даг отдельно удаляет прежний результат за этот день
из сводки.
## Даг самопроверки