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

+25 -20
View File
@@ -17,10 +17,11 @@
`EventDate` источника, дата по часовому поясу счетчика. У всех событий
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
Оба окна нарезки используют порядок `(event_time, event_id)`: при равном
времени события идут по идентификатору. Одинаковый порядок нужен, чтобы
признак начала визита и накопленный номер визита относились к одним
и тем же событиям.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор
@@ -73,24 +74,28 @@
Три SQL-файла выполняются последовательно в группе `session` дага
`dds_transform`:
1. `session_scope.sql` находит первый день событий, которого еще нет
в таблице сессий. Последний день для сборки — первый день событий ODS
плюс `world_position` минус один. Так в расчет попадают только полностью
1. `session_scope.sql` находит первую дату событий, за которую еще нет
сессий. Последний день для расчета — первый день событий ODS
плюс `world_position` минус один; `world_position` — номер следующего
дня для генерации. Так в расчет попадают только полностью
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
пропускается.
2. `session_rebuild.sql` очищает `dds.session_stage_rep` и собирает весь
выбранный диапазон в `dds.session_stage_dist`. Это донор — служебная
таблица для подготовки результата до публикации. Вставка ждет доставки
2. `session_rebuild.sql` очищает промежуточную таблицу
`dds.session_stage_rep`. Затем рассчитывает сессии выбранных дней
и записывает строки через `dds.session_stage_dist`. Пока идет расчет,
в основной таблице остаются прежние данные. Вставка ждет доставки
строк на шарды благодаря `distributed_foreground_insert = 1`.
3. `session_replace.sql` заменяет каждый день в `dds.session_rep` партицией
донора. Для каждого дня Airflow создает отдельную задачу `replace`.
3. `session_replace.sql` заменяет в `dds.session_rep` раздел за каждый
выбранный день готовым разделом `dds.session_stage_rep`. Для каждого
дня Airflow создает отдельную задачу `replace`.
В обычном ходе сессии прожитого дня больше не меняются. Если пропущен ранний
Когда все события завершенного дня приняты в ODS, их набор на стенде
больше не меняется. Поэтому готовые сессии обычно не пересчитываются. Если пропущен ранний
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
Поиск по наличию даты не обнаружит неполный день, у которого часть строк
сохранилась. Условия повторного запуска и границы атомарности замены
описаны в [справочнике конвейера](../etl.md#идемпотентность).
Если за день сохранилась хотя бы одна сессия, поиск пропущенных дней
не обнаружит потерю остальных. Условия повторного запуска и замены
разделов описаны в [справочнике конвейера](../etl.md#идемпотентность).
Сессий еще идущего дня в модели нет; его события доступны через
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
@@ -103,10 +108,10 @@
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
скрывает `VisitID`.
Нулевое число расхождений подтверждает совпадение счета, но не всех
границ и атрибутов сессий. Две ошибки внутри одного посетителя и дня
могут сохранить число визитов. Поэтому в заданиях вы дополнительно
сопоставляете границы сессий по исходным событиям и проверяете страницы,
Нулевое число расхождений подтверждает только число сессий каждого
посетителя за день. Например, ошибочное объединение двух визитов
и разделение другого на два сохранят их общее число. Поэтому в заданиях
вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели
+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`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
для него строк. Даг отдельно удаляет прежний результат за этот день
из сводки.
## Даг самопроверки
+39 -22
View File
@@ -1,21 +1,23 @@
# Дневной конвейер трансформаций
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
результат. Здесь описаны запуск, выбор дней и публикация данных.
результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
## Форма
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
Для этого `TriggerDagRunOperator` ждет завершения созданного им запуска;
его ошибка завершает ошибкой и соответствующую задачу конвейера.
Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
два запуска одного дага не должны одновременно заполнять общий донор.
Внутри дага модели собраны в отдельные задачи и группы задач.
два запуска одного дага используют одни промежуточные таблицы и могли бы
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
задачей или группой задач.
## Три пути запуска
@@ -40,9 +42,11 @@ world_next_day ─────> источники без ETL
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
не нужно: даги определяют их по состоянию таблиц и `world_position`.
Например, после генерации семи дней без ETL один запуск соберет все семь.
Например, после генерации семи дней без ETL один запуск рассчитает сессии
за все семь дней.
Сессии собираются от первого отсутствующего дня до последнего прожитого.
Для расчета сессий выбираются дни от первого, за который в DDS еще нет
сессий, до последнего прожитого.
`world_position` хранит номер следующего дня для генерации, поэтому
последний прожитый день равен `min(EventDate) + world_position - 1`.
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
@@ -52,9 +56,10 @@ world_next_day ─────> источники без ETL
Для заказов диапазон задает последний принятый слепок ODS. Среди строк
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
даты оформления заказа. Эти дни еще могут измениться. Если в DDS
отсутствует более ранний день, который есть в ODS, левая граница
расширяется до него. Все дни отрезка пересобираются.
даты оформления заказа. Состояние заказов за эти дни еще может измениться.
Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Позиция мира для выбора дней заказов не подходит: с событиями дня D
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
@@ -62,8 +67,8 @@ world_next_day ─────> источники без ETL
Карта идентичностей пересчитывается целиком. Витрины выбирают дни
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
Поиск отсутствующих дней проверяет только наличие даты: частично
заполненный день требует отдельной проверки.
Если за день сохранилась хотя бы одна строка, запрос не считает его
пропущенным. Полноту данных проверяют отдельно.
## Готовность источника
@@ -96,17 +101,29 @@ world_next_day ─────> источники без ETL
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
при новой сборке меняются.
Для дневных таблиц порядок такой: `scope` выбирает даты, `rebuild` очищает
донор и собирает в него весь диапазон, затем отдельные задачи `replace`
публикуют дни через `REPLACE PARTITION`. Донор — служебная таблица с той же
структурой и раскладкой по шардам, что у цели. Запись в `_stage_dist`
с `distributed_foreground_insert = 1` дожидается доставки строк перед заменой.
Результат дневного расчета сначала записывается в промежуточную таблицу.
Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
еще рассчитываются. Например, для сессий это `dds.session_stage_rep`,
а основная таблица — `dds.session_rep`.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. Она атомарна
на каждом шарде: читатель видит старую или новую партицию. Единой
транзакции для двух шардов и всех дней нет. Если замена прошла только
на одном шарде, другой может сохранить прежние данные. Повтор задачи
`replace` с тем же готовым донором повторит замену без добавления копий.
Задачи выполняются в таком порядке:
1. `scope` выбирает даты для расчета.
2. `rebuild` очищает промежуточную таблицу, рассчитывает строки за выбранные
дни и записывает их через `_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).
Для дневной пересборки DDS и DM используют донор — служебную таблицу,
в которой готовят результат перед заменой партиций. Например,
`dds.order_stage_rep` объявлена через `AS dds.order_rep`, а запись
в нее идет через `dds.order_stage_dist` с тем же `cityHash64(order_id)`.
У цели и донора должны совпадать структура, ключи партиции и сортировки,
первичный ключ и политика хранения. Эти требования сверены через Context7
с [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
для используемой схемы на ClickHouse 26.3. Поэтому донор объявлен через
`AS` цели с совпадающими ключами. DDL показан в
Для дневной пересборки DDS и DM результат сначала записывают
в промежуточную таблицу. Основная таблица в это время остается доступна
для чтения с прежними данными. Например, заказы записываются через
`dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
заменяют разделы основной `dds.order_rep`.
У промежуточной и основной таблиц должны совпадать структура, ключи
партиции и сортировки, первичный ключ и политика хранения. Поэтому
`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).
`REPLACE PARTITION` копирует готовую партицию между локальными таблицами
на каждом шарде; заново распределять строки по ключу цели эта операция
не будет. Поэтому донор должен сразу иметь ту же раскладку, что и цель.
Промежуточная таблица должна также распределять строки по тому же ключу,
здесь — `cityHash64(order_id)`. `REPLACE PARTITION` копирует раздел между
локальными таблицами каждого шарда и не перераспределяет строки.
Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде.
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
доставки данных на шарды.