feat(dds): добавлена модель заказа и пересборка окна заменой партиций

Зачем:
- Дать слою первую физическую сущность и показать идемпотентность, устроенную
  иначе, чем в приёме: заменой партиции, а не колонкой версии.
- Родить механику дневной пересборки на кластере — образец для сессий и витрин.

Что:
- Добавлены dds.order_rep/_dist, двойник-донор dds.order_stage_rep/_dist и
  публичный договор dds.order_v с разбором позиций в три массива.
- Добавлены запросы объёма прогона, сборки в двойник и замены партиции по дню.
- Добавлен даг dds_transform: группа задач order, замена — размноженной задачей.
- Окно изменяемости K пришло на сторону стенда якорем compose; владелец числа
  остаётся у генератора.
- Объём считается от позднейшего дня заказа и первого недостающего дня: ярлыки
  доставки ODS для этого непригодны, а максимум собранного дня прячет дыры.
- Записано, чего конвейер о себе не знает, и адресовано контуру DQ.

Проверка:
- make lint
- make smoke, make check-clickhouse, make check-services
- Прогон по стартовому миру: 1917 заказов, счёт равен ods.order_v, каждый заказ
  на шарде своего cityHash64(order_id), сумма позиций равна items_total.
- Повтор без нового слепка: содержимое то же, _load_id новый; после слепка
  пересобрано окно, день за окном не тронут.
- Снесённая руками партиция 2026-06-01 восстановлена побайтово.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Opus 5 committed 2026-08-26 05:20:39 -04:00
1 parent 96829718f9
commit 1e8b83ffbb
9 files changed
+555 -6

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
-- DDS: физическая модель заказа и донор её партиций.
--
-- Зерно — заказ в текущем состоянии, ключ order_id. Историю версий слой не
-- хранит: у неё нет читателя ни в одной витрине, а ODS её и не обещает
-- (docs/architecture/dds/order.md, «Зерно»).
--
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок
-- сущности. В ODS повтор гасит колонка версии: ReplacingMergeTree(updated_at)
-- оставляет позднюю строку. Здесь колонки версии нет вовсе — повтор гасит
-- замена партиции целиком: день собирается заново и подменяется одним
-- атомарным движением. Поэтому каждая строка таблицы — ровно один заказ, а
-- чтению не нужен FINAL.
-- Локальная таблица шарда.
--
-- Партиция — order_date, день заказа в поясе счётчика. Партиция дневная не
-- ради размера куска, а потому что день — единица пересборки: пока заказ
-- внутри окна изменяемости, его день «дышит» и собирается заново, за окном
-- замерзает. Ключ партиции и есть контракт трансформации с хранилищем.
--
-- Ключ сортировки — order_id: единственный устойчивый ключ сущности, он же
-- ключ шардирования ниже. Колонки версии в движке нет: см. шапку файла.
CREATE TABLE IF NOT EXISTS dds.order_rep ON CLUSTER clickstream_cluster
(
order_id String,
user_id UInt64,
status LowCardinality(String),
-- День заказа посчитан один раз при загрузке, по образцу EventDate у
-- события: суточные срезы группируются по готовой дате и пояса не
-- упоминают (docs/architecture/storage.md, «Часовые пояса»).
order_date Date,
-- Времена лежат местными: DDS обслуживает человека с дашбордом, и пояс
-- пересчитан один раз здесь, а не в каждом отчёте. Линза названа в типе,
-- поэтому пояс сервера на эти колонки не влияет.
created_at DateTime64(3, 'Europe/Samara'),
updated_at DateTime64(3, 'Europe/Samara'),
items_total Decimal(18, 2),
discount Decimal(18, 2),
delivery Decimal(18, 2),
total Decimal(18, 2),
-- Позиции — три массива одной длины: артикул, количество, цена. Родная
-- для колоночного хранилища денормализация: позиция не живёт без заказа и
-- своего ключа не имеет, а разворачивает массивы читатель — ARRAY JOIN в
-- витрине выручки. Форма записи выбрана по дому: широкое событие хранит
-- повторяющиеся группы Метрики такими же параллельными колонками
-- (productID, productPrice, productQuantity в ods.event_rep), а не Nested.
item_sku Array(String),
item_quantity Array(UInt32),
item_price Array(Decimal(18, 2)),
-- Координаты собственной загрузки слоя: каким запуском и когда собран
-- день. При замене партиции они одинаковы у всех строк дня. Ярлыки ODS
-- сюда не переезжают — почему, разобрано в доке модели.
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_date
ORDER BY order_id;
-- Лицо загрузки и диагностики. Ключ шардирования — cityHash64(order_id), тот
-- же, что у ods.order_dist: заказ остаётся на одном шарде на всём пути через
-- слои, и соединения по зерну заказа обходятся без GLOBAL
-- (docs/architecture/storage.md, «Раскладка по шардам»).
CREATE TABLE IF NOT EXISTS dds.order_dist ON CLUSTER clickstream_cluster
AS dds.order_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'order_rep', cityHash64(order_id));
-- Донор партиций: таблица-двойник, в которой день собирается перед подменой.
--
-- Двойник нужен потому, что REPLACE PARTITION берёт готовый кусок из другой
-- таблицы, а не из результата запроса. Собрать день прямо в цель нельзя:
-- вставка добавила бы строки к уже лежащим, и повтор прогона удвоил бы день.
--
-- Форма двойника задана правилом донора: структура, ключ партиции, ключ
-- сортировки, первичный ключ и политика хранения обязаны совпадать с целью —
-- это требование самой операции (проверено по документации ClickHouse,
-- sql-reference/statements/alter/partition). Отсюда AS dds.order_rep вместо
-- повторённого списка колонок: расхождение становится невозможным по
-- построению и не держится на внимательности того, кто правит.
--
-- Своё имя в keeper двойник получает от макроса {table}, поэтому две таблицы
-- не делят путь репликации.
CREATE TABLE IF NOT EXISTS dds.order_stage_rep ON CLUSTER clickstream_cluster
AS dds.order_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_date
ORDER BY order_id;
-- Двойник шардируется тем же ключом, что цель, и это не украшение: REPLACE
-- PARTITION идёт по локальным таблицам, шард за шардом. Ляг строка заказа в
-- доноре на другой шард, чем её место в цели, — подмена перенесла бы её не
-- туда, и заказ пропал бы с одного шарда и задвоился на другом.
CREATE TABLE IF NOT EXISTS dds.order_stage_dist ON CLUSTER clickstream_cluster
AS dds.order_stage_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'order_stage_rep', cityHash64(order_id));
+34
View File
@@ -65,3 +65,37 @@ SELECT
productQuantity AS product_quantity,
productEventType AS product_event_type
FROM ods.event_v;
-- DDS: публичный договор заказа на языке бизнес-модели.
--
-- В отличие от события, у заказа под представлением лежит физическая модель:
-- разбор позиций и пересчёт пояса стоят денег, и платить их при каждом чтении
-- значило бы собирать модель заново на лету (docs/architecture/dds/order.md,
-- «Отклонённые варианты»). Представление здесь ничего не вычисляет — оно
-- только называет колонки, которые слой обещает читателю.
--
-- FINAL нет и не будет: строка на заказ одна по построению, повтор гасит
-- замена партиции, а не колонка версии.
--
-- Служебные колонки перечислены наравне с деловыми — это координаты
-- собственной загрузки слоя, и расследование происшествия начинается с них:
-- по _load_id видно, какой запуск собрал день.
CREATE VIEW IF NOT EXISTS dds.order_v ON CLUSTER clickstream_cluster
AS
SELECT
order_id,
user_id,
status,
order_date,
created_at,
updated_at,
items_total,
discount,
delivery,
total,
item_sku,
item_quantity,
item_price,
_load_id,
_load_ts
FROM dds.order_dist;
+89
View File
@@ -0,0 +1,89 @@
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций.
--
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
-- удвоение в цель.
--
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
--
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE, и это не вкусовщина:
-- TRUNCATE — отдельное право, роли etl оно не выдано, а снятие всех партиций
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
-- операции, для которой уже есть разрешённая форма, значило бы платить
-- доступом за синоним.
ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.order_stage_dist
(
order_id,
user_id,
status,
order_date,
created_at,
updated_at,
items_total,
discount,
delivery,
total,
item_sku,
item_quantity,
item_price,
_load_id,
_load_ts
)
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе —
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на
-- живом слепке, промежуточный toDecimal64 не нужен.
--
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
-- генератора такого не бывает, и сумма «цена × количество» сходится с
-- items_total у всех заказов, но сходится она по свойству источника, а не
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
-- контур DQ этапа 4; пока она делается руками при приёмке.
WITH JSONExtract(
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
) AS lines
SELECT
order_id,
user_id,
status,
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает
-- человека с дашбордом, и пояс называется здесь в последний раз.
toDate(toTimeZone(created_at, 'Europe/Samara')),
toTimeZone(created_at, 'Europe/Samara'),
toTimeZone(updated_at, 'Europe/Samara'),
items_total,
discount,
delivery,
total,
arrayMap(line -> line.sku, lines),
arrayMap(line -> line.qty, lines),
arrayMap(line -> line.price, lines),
-- Координаты запуска, собравшего день: у всех строк дня они одни.
{load_id:String},
now64(3, 'UTC')
FROM ods.order_v
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
BETWEEN {first_day:Date} AND {last_day:Date}
-- Вторая граница не меняет отбор, а открывает источнику отсечение
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
-- поэтому день слева берётся с запасом.
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
-- «Раскладка по шардам», капкан #137).
SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+28
View File
@@ -0,0 +1,28 @@
-- DDS: подмена одного дня заказов собранной партицией двойника.
--
-- Это и есть идемпотентность слоя. Повтор прогона безопасен, а «дыхание»
-- статусов внутри окна выражается сменой всей партиции, а не правкой строк.
--
-- Замена идёт по локальным таблицам, шард за шардом: ON CLUSTER рассылает её
-- на обе ноды, а по Distributed то же самое не работает — операции с
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что
-- проверено»). Правильность пошардовой замены держится на том, что двойник
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
--
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
--
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
-- источнике больше нет.
--
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
-- сервер параметров не подставляет — замерено, отвечает синтаксической
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
-- ClickHouse на запрос объёма, а не снаружи стенда.
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep;
+65
View File
@@ -0,0 +1,65 @@
-- DDS: объём пересборки заказов — с какого дня по какой собирает этот запуск.
--
-- Параметров у дага нет: объём он выводит сам из состояния слоёв
-- (docs/architecture/etl.md, «Объём прогона»). Ответ — две даты, границы
-- непрерывного отрезка; список дней между ними разворачивает даг. Пустой
-- источник даёт пустой ответ, и даг на нём останавливается с внятной ошибкой
-- вместо зелёного прогона, собравшего ничего.
--
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
-- мультизапрос, который ClickHouse не принимает.
--
-- Объём складывается из двух частей. Первая — дышащее окно: K последних дней
-- источника, состояние которых слепок ещё может изменить. Вторая — хвост от
-- первого дня, которого в слое нет: первая сборка застаёт слой пустым и
-- собирает весь стартовый мир, а после разгона мира пачкой слой догоняет
-- уехавший вперёд источник.
--
-- **Хвост считается от первого недостающего дня, а не от последнего
-- собранного.** Разница не косметическая: замены партиций идут отдельными
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа в
-- ODS; она того стоит.
--
-- Край окна берётся из делового поля, а не из ярлыков доставки: время
-- создания заказа одинаково у всех его версий, а `snapshot_date` и `_load_id`
-- выжившей строки достаются жребием (docs/architecture/etl.md, «Объём
-- прогона», и docs/architecture/orders/ingestion.md, «Роль ODS»).
--
-- Известная неточность края: если в самый свежий день источника не родилось
-- ни одного заказа, окно съезжает на день назад — лишний замёрзший день
-- пересоберётся впустую, а пустой день не тронется. Данные от этого не
-- страдают, меняется только ярлык загрузки лишнего дня. Точный ответ здесь
-- дала бы дата слепка, но она и есть тот самый жребий.
WITH
-- FINAL не нужен: время рождения заказа версии не различают.
(SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist) AS last_source_day,
(SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist) AS first_source_day,
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
-- заказы легли на соседний шард, показался бы недостающим.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
(
SELECT minOrNull(day)
FROM (
SELECT DISTINCT toDate(toTimeZone(created_at, 'Europe/Samara')) AS day
FROM ods.order_dist
)
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
) AS first_missing_day,
last_source_day - ({window_days:UInt16} - 1) AS window_start
SELECT
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
-- greatest держит левый край не раньше первого дня, известного источнику:
-- у молодого мира окно шире самого мира.
greatest(
least(window_start, coalesce(first_missing_day, window_start)),
first_source_day
) AS first_day,
last_source_day AS last_day
WHERE (SELECT count() FROM ods.order_dist) > 0