feat(ods): версией заказа стала дата слепка, окно DDS — из данных

- Зачем:
  - ярлык snapshot_date выжившей строки доставался жребием, а объём
    пересборки DDS держался на константе окна с двумя несверяемыми копиями
    (ADR 0014, развилка #163).
- Что:
  - ods.order_rep переведена на ReplacingMergeTree(snapshot_date): выживает
    строка позднейшего слепка, ярлык стал правдивым.
  - order_scope.sql выводит окно из дней последнего слепка; ORDER_WINDOW_DAYS
    и параметр window_days сняты со стенда, мёртвая защита краем источника
    убрана.
  - документы и комментарии SQL приведены к ADR 0014: роль ODS, условие
    «одна дата — один слепок», объём прогона и слепые пятна конвейера.
- Проверка:
  - make lint; make rebuild-storage, затем make smoke, make check-clickhouse,
    make check-services — зелёные.
  - после двух слепков snapshot_date всех 1917 строк ods.order_v равен
    последнему слепку заказа, расхождений ноль; деловые поля построчно
    совпали с эталоном argMax по updated_at из сырья.
  - dds_transform без K: первый прогон 06-01…06-07, после нового дня мира
    06-02…06-08, повтор прогона содержимого не изменил.

Closes #165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Opus 5 committed 2026-08-26 17:28:14 +03:00
1 parent 650efacc3f
commit a82c75705d
13 files changed
+140 -141

No files matched your search

+12 -10
View File
@@ -156,17 +156,19 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6
-- Слепок привозит состояние заказов окна изменяемости, а не поток изменений,
-- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка
-- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка
-- версии updated_at (время последнего изменения строки в источнике). Порядок
-- версий решает источник, а не хранилище.
-- версии snapshot_date — день, состояние которого снял источник. У полного
-- слепка версия и есть слепок: истинно то, что источник сказал последним
-- (ADR 0014).
--
-- Четыре координаты отвечают на разные вопросы, и путать их нельзя:
-- updated_at — какая бизнес-версия новее; snapshot_date — в слепке какого
-- модельного дня источник показал строку; _load_id — какой запуск Airflow её
-- принял; _load_ts — когда она приехала в хранилище. Ключом сущности не
-- становится ни одна из трёх последних: они про наблюдение и загрузку. Версию
-- выбирает только updated_at, поэтому у выжившей после схлопывания строки
-- snapshot_date — один из дней, когда источник показывал эту версию, не
-- обязательно последний (docs/architecture/orders/ingestion.md).
-- snapshot_date — в слепке какого модельного дня источник показал строку;
-- updated_at — когда строку последний раз меняли в источнике; _load_id —
-- какой запуск Airflow её принял; _load_ts — когда она приехала в хранилище.
-- Ключом сущности не становится ни одна: заказ один, а дней наблюдения и
-- версий у него много. Схлопывание оставляет строку позднейшего слепка,
-- поэтому snapshot_date выжившей — последний день, когда источник показывал
-- заказ, и по нему DDS читает объём пересборки
-- (docs/architecture/orders/ingestion.md).
--
-- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он
-- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и
@@ -194,7 +196,7 @@ CREATE TABLE IF NOT EXISTS ods.order_rep ON CLUSTER clickstream_cluster
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', updated_at)
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', snapshot_date)
PARTITION BY toDate(created_at)
ORDER BY order_id;
+4 -3
View File
@@ -11,9 +11,10 @@ FROM ods.event_dist FINAL;
-- ODS: каноническое чтение текущего состояния заказов.
--
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица
-- хранит все приехавшие версии заказа, а FINAL оставляет ту, у которой
-- updated_at позже. Читать заказы голым SELECT по _dist значит зависеть от
-- того, сколько фоновых слияний успело пройти.
-- хранит все приехавшие версии заказа, а FINAL оставляет приехавшую
-- позднейшим слепком — колонка версии здесь snapshot_date. Читать заказы
-- голым SELECT по _dist значит зависеть от того, сколько фоновых слияний
-- успело пройти.
--
-- Поля остаются языком источника, обогащения нет: DDS читает это
-- представление и отдельно решает, какой будет его модель (ADR 0010).
+2 -2
View File
@@ -1,8 +1,8 @@
-- DDS: физические модели слоя и доноры их партиций.
--
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих
-- сущностей файла. В ODS повтор гасит колонка версии:
-- ReplacingMergeTree(updated_at) оставляет позднюю строку. Здесь колонки
-- сущностей файла. В ODS повтор гасит колонка версии: ReplacingMergeTree по
-- snapshot_date оставляет строку позднейшего слепка. Здесь колонки
-- версии нет вовсе — повтор гасит замена партиции целиком: день собирается
-- заново и подменяется одним атомарным движением. Поэтому строка таблицы
-- ровно одна на своё зерно, а чтению не нужен FINAL.
+26 -28
View File
@@ -10,11 +10,11 @@
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
-- мультизапрос, который ClickHouse не принимает.
--
-- Объём складывается из двух частей. Первая — дышащее окно: K последних дней
-- источника, состояние которых слепок ещё может изменить. Вторая — хвост от
-- первого дня, которого в слое нет: первая сборка застаёт слой пустым и
-- собирает весь стартовый мир, а после разгона мира пачкой слой догоняет
-- уехавший вперёд источник.
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
-- догоняет уехавший вперёд источник.
--
-- **Хвост считается от первого недостающего дня, а не от последнего
-- собранного.** Разница не косметическая: замены партиций идут отдельными
@@ -24,22 +24,26 @@
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
-- в ODS.
--
-- Край окна берётся из делового поля, а не из ярлыков доставки: время
-- создания заказа одинаково у всех его версий, а `snapshot_date` и `_load_id`
-- выжившей строки достаются жребием (docs/architecture/etl.md, «Объём
-- прогона», и docs/architecture/orders/ingestion.md, «Роль ODS»).
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
-- копии его константы на стороне стенда больше нет.
--
-- Известная неточность края: если в самый свежий день источника не родилось
-- ни одного заказа, окно съезжает на день назад — лишний замёрзший день
-- пересоберётся впустую, а пустой день не тронется. Данные от этого не
-- страдают, меняется только ярлык загрузки лишнего дня. Точный ответ здесь
-- дала бы дата слепка, но она и есть тот самый жребий.
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
-- оставляет как раз их.
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,
(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,
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
-- заказы легли на соседний шард, показался бы недостающим.
@@ -51,15 +55,9 @@ WITH
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
) AS first_missing_day
SELECT
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
-- greatest держит левый край не раньше первого дня, известного источнику:
-- у молодого мира окно шире самого мира.
greatest(
least(window_start, coalesce(first_missing_day, window_start)),
first_source_day
) AS first_day,
last_source_day AS last_day
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day
WHERE (SELECT count() FROM ods.order_dist) > 0