chore(repo): влиты изменения задачи 155

- Зачем:
  - ветка ETL должна учитывать новую витрину `purchase_vs_orders` из `main`.
- Что:
  - `origin/main` влит без переписывания истории опубликованного PR;
  - `dm_transform` теперь собирает третью витрину внутри общего конвейера.
- Проверка:
  - `make config-test`, `make lint`;
  - `generator`: `make lint`, `make typecheck`, `make test` — 429 тестов;
  - `make rebuild-storage`, `make smoke`, `make check-clickhouse`, `make check-services`;
  - импорт DAG без ошибок, `purchase_vs_orders` заполнена.
This commit is contained in:
ddadmin committed 2026-08-27 12:23:02 +03:00
commit cec11edaf3
15 files changed
+391 -38

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
GoalsReached Array(UInt32),
ParsedParamsKey1 Array(String),
purchaseID Array(String),
purchaseRevenue Array(Float64),
purchaseRevenue Array(Decimal(18, 2)),
purchaseCurrency Array(String),
purchaseCoupon Array(String),
productID Array(String),
+5 -1
View File
@@ -154,7 +154,11 @@ SELECT
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
JSONExtract(raw, 'purchaseRevenue', 'Array(Float64)') AS purchaseRevenue,
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
-- синтаксис подтвержден документацией ClickHouse через Context7 и
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
JSONExtract(raw, 'productID', 'Array(String)') AS productID,
+46 -1
View File
@@ -1,4 +1,4 @@
-- DM: физические дневные витрины и донор партиций выручки.
-- DM: физические дневные витрины и доноры их партиций.
-- Выручка хранится в зерне «день заказа × категория товара». День служит
-- единицей пересборки, поэтому совпадает с ключом партиции.
@@ -48,6 +48,51 @@ ENGINE = Distributed(
cityHash64(product_category)
);
-- Сверка хранит одну строку на заказ. День — единица пересборки: пока рядом
-- живут два источника, строка может перейти из awaiting_order в обычный класс.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
(
order_day Date,
order_id String,
declared_revenue Nullable(Decimal(18, 2)),
items_total Nullable(Decimal(18, 2)),
status LowCardinality(Nullable(String)),
mismatch_class LowCardinality(String),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
-- Ключ совпадает с DDS, поэтому строка заказа на всех слоях попадает на один
-- шард.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_rep',
cityHash64(order_id)
);
-- Донор повторяет структуру и раскладку цели по правилу, разобранному у
-- revenue_daily_stage_rep и в docs/architecture/storage.md.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_stage_rep',
cityHash64(order_id)
);
-- Трафик пересчитывается целиком: новая связь куки с пользователем меняет
-- прошлые дни. Версия запуска позволяет точному представлению скрыть строки
-- прежних пересчетов без TRUNCATE и без партиций.
+16
View File
@@ -15,6 +15,22 @@ SELECT
_load_ts
FROM dm.revenue_daily_dist;
-- Непарные стороны остаются NULL. При join_use_nulls = 0 ClickHouse заполнил
-- бы их нулями и пустыми строками, скрыв отсутствие источника.
-- Служебные колонки называют запуск, который последним заменил день.
CREATE VIEW IF NOT EXISTS dm.purchase_vs_orders_v ON CLUSTER clickstream_cluster
AS
SELECT
order_day,
order_id,
declared_revenue,
items_total,
status,
mismatch_class,
_load_id,
_load_ts
FROM dm.purchase_vs_orders_dist;
-- Полный пересчет пишет новую версию каждого дня. FINAL оставляет текущие
-- счетчики и прячет повторы запусков от потребителя.
CREATE VIEW IF NOT EXISTS dm.daily_traffic_v ON CLUSTER clickstream_cluster
+71
View File
@@ -0,0 +1,71 @@
-- DM: сверка покупок трекера с текущими заказами бэкенда.
--
-- Обе стороны FULL OUTER JOIN — подзапросы. С голой левой Distributed
-- ClickHouse вернул бы непарный заказ по разу с каждого шарда; форма измерена
-- в docs/research/2026-08-22-distributed-join.md.
-- Выбор DROP PARTITION ALL вместо TRUNCATE разобран в соседнем
-- sql/dds/order_rebuild.sql.
ALTER TABLE dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL;
INSERT INTO dm.purchase_vs_orders_stage_dist
(
order_day,
order_id,
declared_revenue,
items_total,
status,
mismatch_class,
_load_id,
_load_ts
)
SELECT
-- У парного заказа день берется у бэкенда; трекер дает его только
-- непарной покупке.
coalesce(order_date, purchase_day),
coalesce(purchase_order_id, backend_order_id),
declared_revenue,
items_total,
status,
multiIf(
backend_order_id IS NULL, 'awaiting_order',
status = 'cancelled', 'cancelled',
purchase_order_id IS NULL, 'lost_event',
purchase_count > 1, 'duplicate_event',
declared_revenue != items_total, 'amount_delta',
'match'
),
{load_id:String},
now64(3, 'UTC')
FROM
(
-- Контракт purchase несет один номер и одну объявленную сумму в массивах.
-- Бизнес-дубль повторяет оба значения, поэтому count хранится отдельно,
-- а сумма берется одна — складывать дубли значило бы выдумать дельту.
SELECT
purchase_id[1] AS purchase_order_id,
any(event_date) AS purchase_day,
any(purchase_revenue[1]) AS declared_revenue,
count() AS purchase_count
FROM dds.event_v
WHERE event_type = 'purchase'
AND event_date BETWEEN {first_day:Date} AND {last_day:Date}
GROUP BY purchase_order_id
) AS purchases
GLOBAL FULL OUTER JOIN
(
SELECT
order_id AS backend_order_id,
order_date,
items_total,
status
FROM dds.order_v
WHERE order_date BETWEEN {first_day:Date} AND {last_day:Date}
) AS orders ON purchase_order_id = backend_order_id
-- NULL обозначает отсутствующую сторону. При join_use_nulls = 0 ClickHouse
-- заполнил бы ее нулем или пустой строкой и скрыл отсутствие источника.
-- Вставка идет через ключ цели: источники разложены по другим ключам.
SETTINGS join_use_nulls = 1,
distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+9
View File
@@ -0,0 +1,9 @@
-- DM: подмена одного дня сверки собранной партицией донора.
--
-- День для PARTITION берет purchase_vs_orders_scope.sql. Выбор Jinja,
-- атомарность и повтор разобраны в sql/dds/order_replace.sql.
--
-- Пустая партиция законна: если источник больше не показывает строк дня,
-- замена убирает этот день из витрины.
ALTER TABLE dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.purchase_vs_orders_stage_rep;
+62
View File
@@ -0,0 +1,62 @@
-- DM: объем пересборки сверки покупок с заказами.
--
-- У источников разные правые границы. Заказы называют изменяемое окно ярлыком
-- последнего принятого слепка. События заканчиваются последним прожитым днем,
-- который выводится из позиции мира. Одна граница другую не подменяет.
--
-- Левая граница возвращает не только пропуски, но и самый ранний
-- awaiting_order. Такой день уже есть в витрине, поэтому обычный поиск дыры
-- его не увидит. Иначе пакетный разгон длиннее K навсегда оставил бы прежний
-- хвост предварительным.
--
-- Файл заканчивается без точки с запятой: почему — sql/dds/order_scope.sql.
WITH
(SELECT min(event_date) FROM dds.event_v) AS first_event_day,
first_event_day + ({position:UInt16} - 1) AS last_lived_day,
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
(
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_start,
(
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_end,
(
SELECT maxOrNull(event_date)
FROM dds.event_v
WHERE event_type = 'purchase' AND event_date <= last_lived_day
) AS last_purchase_day,
(
SELECT minOrNull(day)
FROM (
SELECT order_date AS day
FROM dds.order_v
UNION DISTINCT
SELECT event_date AS day
FROM dds.event_v
WHERE event_type = 'purchase' AND event_date <= last_lived_day
)
WHERE day GLOBAL NOT IN (
SELECT order_day FROM dm.purchase_vs_orders_dist
)
) AS first_missing_day,
(
SELECT minOrNull(order_day)
FROM dm.purchase_vs_orders_dist
WHERE mismatch_class = 'awaiting_order'
) AS first_provisional_day
SELECT
least(
window_start,
coalesce(first_missing_day, window_start),
coalesce(first_provisional_day, window_start)
) AS first_day,
greatest(window_end, last_purchase_day) AS last_day
-- Без принятого слепка неизвестно окно заказов. Без готового DDS или прожитой
-- покупки донор был бы пуст, поэтому подменять партиции еще нельзя.
WHERE (SELECT count() FROM ods.order_dist) > 0
AND (SELECT count() FROM dds.order_v) > 0
AND last_purchase_day IS NOT NULL