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:
1 parent
650efacc3f
commit
a82c75705d
13 files changed
+140
-141
No files matched your search
@@ -66,15 +66,14 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
собран этот день».
|
||||
|
||||
Ярлыки ODS — `snapshot_date`, `_load_id` приёма, `_load_ts` прибытия — в
|
||||
модель не переезжают. Версию заказа они не задают, а физически выжившие их
|
||||
значения — жребий: у строк с равным `updated_at` деловые поля совпадают
|
||||
побайтово, и какая переживёт схлопывание — произвол (замер —
|
||||
[приём заказов](../orders/ingestion.md), «Что проверено»). Перенесённый
|
||||
ярлык выглядел бы фактом, оставаясь жребием; лживая координата хуже
|
||||
отсутствующей. Отсюда правило слоя: **служебные колонки — координаты
|
||||
собственной загрузки слоя; чужие координаты спрашиваются у их слоя по ключу
|
||||
сущности**. Этим закрыто наследство #85: читатель у чужих ярлыков выше ODS
|
||||
не нашёлся и при названных читателях модели.
|
||||
модель не переезжают. Лгать они перестали: колонкой версии в ODS стала дата
|
||||
слепка, и у выжившей строки она равна последнему слепку, в котором заказ был
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Довод стал проще:
|
||||
читателя у этих колонок здесь нет — модель отвечает на вопрос «что сейчас
|
||||
известно о заказе», а не «когда его показывал источник». Отсюда правило слоя:
|
||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||
спрашиваются у их слоя по ключу сущности**. Этим закрыто наследство #85:
|
||||
читатель у чужих ярлыков выше ODS не нашёлся и при названных читателях модели.
|
||||
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||
@@ -109,8 +108,8 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
||||
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
||||
не противоречит: оно ничего заново не вычисляет.
|
||||
- **`ReplacingMergeTree(updated_at)` ещё раз.** Повторил бы механику ODS
|
||||
вторым разом: физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
|
||||
физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||
и обёртку-представление — а урок версий менти уже отыграл.
|
||||
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
||||
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
||||
|
||||
+18
-27
@@ -41,40 +41,34 @@
|
||||
|
||||
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
||||
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
||||
последний прожитый; заказы пересобирают дышащее окно K и тот же хвост; карта
|
||||
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
|
||||
идентичностей пересчитывается целиком; правила витрин — у
|
||||
[слоя DM](dm.md).
|
||||
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
||||
после падения, а разгону на семь дней хватает одного прогона.
|
||||
|
||||
Окно заказов отсчитывается от **последнего дня, о котором рассказал сам
|
||||
источник**, — от позднейшего дня заказа в ODS. Рядом напрашиваются ещё два
|
||||
способа, и оба отвергнуты по одной причине: они отвечают не на тот вопрос.
|
||||
Окно заказов — **дни, которые нёс последний слепок**. Называет их сам
|
||||
источник: `snapshot_date` выжившей строки равен последнему слепку, в котором
|
||||
заказ был, поэтому оба края окна трансформация читает прямо из ODS
|
||||
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Своего числа окна у
|
||||
стенда нет, и расходиться с миром нечему.
|
||||
|
||||
Позиция мира говорит, какой день прожит, а не какой доехал: прогон дня D везёт
|
||||
слепок дня D − 1, да и позиция ставится раньше трансформации, так что после
|
||||
неудачного приёма она обгоняет хранилище. Слой обязан считать объём по тому,
|
||||
что у него есть, а не по тому, что мир о себе заявляет.
|
||||
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
|
||||
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
|
||||
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
|
||||
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
|
||||
|
||||
У сессий всё наоборот: им отвечает только позиция, потому что вопрос у них
|
||||
другой. Им нужен не день, о котором рассказал источник, а день, который мир
|
||||
**прожил**: события живой день от прожитого не отличают — и посреди дня D, и
|
||||
сразу после его конца позднейший день событий равен D. Соберись сессии посреди дня, визит, начатый в
|
||||
23:50, остался бы обрезанным навсегда: замёрзший день слой не пересобирает.
|
||||
**прожил**: события живой день от прожитого не отличают — и посреди дня D,
|
||||
и сразу после его конца позднейший день событий равен D. Соберись сессии посреди
|
||||
дня, визит, начатый в 23:50, остался бы обрезанным навсегда: замёрзший день слой
|
||||
не пересобирает.
|
||||
Отсюда правило — последний прожитый день равен первому дню событий плюс позиция
|
||||
минус один ([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
||||
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
||||
позиция, а «Готовность источника».
|
||||
|
||||
Ярлыки доставки — `snapshot_date`, `_load_id`, `_load_ts` — не отвечают тоже, и
|
||||
это свойство ODS, а не недосмотр: слой схлопывает доставки, сохраняя деловые
|
||||
версии, поэтому у заказа, который не менялся, ярлык выжившей строки достаётся
|
||||
жребием ([приём заказов](orders/ingestion.md), «Роль ODS»). Замер на стартовом
|
||||
мире: последний слепок нёс все 1662 заказа, а его ярлык сохранили 1188 —
|
||||
отбор «дни, у которых `snapshot_date` равен последнему» потерял бы четверть
|
||||
заказов и молча пропустил бы дальний край окна, где заказы давно замёрзли.
|
||||
К времени создания это не относится: у всех версий заказа оно одно.
|
||||
|
||||
## Готовность источника
|
||||
|
||||
Первым шагом `dds_transform` обязан дождаться, пока хвост топика `hits`
|
||||
@@ -102,14 +96,11 @@
|
||||
## Чего конвейер о себе не знает
|
||||
|
||||
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
||||
четыре вещи, которых она не заметит, и у всех четырёх один адрес — контур DQ.
|
||||
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
|
||||
|
||||
- **Окно K разъехалось с источником.** Число живёт двумя копиями: у генератора
|
||||
в `world.py` и у стенда в `compose.yaml`. Разойдутся — слой начнёт
|
||||
недобирать край окна или переписывать замёрзший день, и оба прогона будут
|
||||
зелёными.
|
||||
- **Пустой свежий день сдвигает край окна.** Если в позднейший день источника
|
||||
не родилось ни одного заказа, окно съезжает на день назад
|
||||
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
|
||||
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
|
||||
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
|
||||
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
|
||||
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
|
||||
объём следующего прогона его не тронет, а слой останется собранным наполовину
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
[мост к склейке](identity.md).
|
||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
||||
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
|
||||
[приём из Kafka в ODS](ingestion.md).
|
||||
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
||||
в наборе слоя: [модель заказа](../dds/order.md).
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
||||
`snapshot_date`;
|
||||
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
||||
партиции;
|
||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||
|
||||
@@ -81,6 +81,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||
|
||||
Одно условие контракта предикатом не проверить, и названо оно всё равно
|
||||
здесь: **одна дата — один слепок**. Вторую выгрузку того же дня приём не
|
||||
отличит от первой, а колонка версии — дата слепка — на ней даёт ничью.
|
||||
Модельный источник выгружает день один раз; настоящему такое условие ставят
|
||||
договором, а не предикатом
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
||||
|
||||
Класс брака выбирается первым совпадением:
|
||||
|
||||
1. `not_an_object`;
|
||||
@@ -94,31 +101,34 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
## Роль ODS
|
||||
|
||||
`ods.order_rep` хранит физически принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
||||
неизменного `created_at`. `ods.order_dist` шардирует по
|
||||
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
||||
день неизменного `created_at`. `ods.order_dist` шардирует по
|
||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||
|
||||
Четыре координаты отвечают на разные вопросы:
|
||||
|
||||
- `updated_at` — какая бизнес-версия заказа новее;
|
||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||
- `updated_at` — когда строку последний раз меняли в источнике;
|
||||
- `_load_id` — какой запуск Airflow принял строку;
|
||||
- `_load_ts` — когда строка приехала в хранилище.
|
||||
|
||||
Версию выбирает только `updated_at`. Заказ, не менявшийся несколько дней,
|
||||
приезжает в нескольких слепках одинаковыми деловыми полями, и какая из этих
|
||||
строк переживёт схлопывание — не обещано. Поэтому `snapshot_date` выжившей
|
||||
строки — один из дней, когда источник показывал эту версию, не обязательно
|
||||
последний; то же с `_load_id` и `_load_ts`. Вопрос «когда заказ наблюдался в
|
||||
последний раз» задаётся сырью STG, пока то живо, — а не одной строке ODS.
|
||||
Версию выбирает `snapshot_date`: выживает строка позднейшего слепка
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому ярлык
|
||||
выжившей строки — правдивый ответ на вопрос «когда заказ видели в последний
|
||||
раз», и по нему трансформация DDS читает объём пересборки. `_load_id` и
|
||||
`_load_ts` выжившей строки говорят про ту же доставку.
|
||||
|
||||
Сама схема версий стоит на допущении об источнике: уточнение заказа поднимает
|
||||
`updated_at`. Генератор моделирует источник с триггерной меткой и допущение
|
||||
выполняет (замер — «Что проверено»); источник, умеющий уточнить строку тихо —
|
||||
ручным `UPDATE` мимо приложения, миграцией, — ломает его, и тогда версии по
|
||||
метке времени мало: нужна сверка самих полей, в терминах dbt snapshots —
|
||||
стратегия `check` вместо `timestamp`.
|
||||
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
||||
показывает состояния старше. Модельный мир её держит — состояние читается на
|
||||
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
||||
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
||||
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
||||
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
||||
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
||||
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
||||
больше не показывает, и там помогает только сверка самих полей, в терминах
|
||||
dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
|
||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||
@@ -201,12 +211,19 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
||||
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
||||
|
||||
Допущение и произвол ярлыков замерены 22 августа 2026 года при исполнении
|
||||
#137: во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
||||
побайтово, по слепкам метка ни разу не убывала, а `ods.order_v` сошёлся
|
||||
построчно с эталоном `argMax` по `updated_at`, посчитанным прямо из сырья.
|
||||
Произвол ярлыков виден въявь: у 86 заказов стартового мира выжила строка
|
||||
серединного слепка — не первого и не последнего.
|
||||
Дисциплина источника замерена 22 августа 2026 года при исполнении #137: во
|
||||
всех 3034 парах строк с равным `updated_at` деловые поля совпали побайтово, по
|
||||
слепкам метка ни разу не убывала, а `ods.order_v` сошёлся построчно с эталоном
|
||||
`argMax` по `updated_at`, посчитанным прямо из сырья. Тем же замером виден
|
||||
произвол ярлыка при версии по деловой метке — довод, на котором стоит
|
||||
[ADR 0014](../../adr/0014-order-version-snapshot-date.md).
|
||||
|
||||
Правдивость ярлыка снята на пересобранном мире 26 августа 2026 года при
|
||||
исполнении #165. После двух слепков `snapshot_date` каждой из 1917 строк
|
||||
`ods.order_v` равен последнему слепку, который нёс её заказ: расхождений ноль
|
||||
при 3356 физически лежащих строках — выбор был не единственным. Деловое
|
||||
содержимое сменой колонки версии не тронулось: те же строки построчно совпали
|
||||
с эталоном `argMax` по `updated_at` из сырья по всем полям.
|
||||
|
||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||
|
||||
@@ -183,8 +183,8 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||
повтор доставки. У `ods.order` версию задаёт `updated_at` источника;
|
||||
`_load_ts` только показывает, когда конкретная строка приехала.
|
||||
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||||
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||||
|
||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||
@@ -224,7 +224,7 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
## Версии заказов
|
||||
|
||||
`ods.order_rep` хранит принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
|
||||
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||
@@ -234,10 +234,11 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||
DDS. `snapshot_date` в нём — дата наблюдения и колонка версии, но не ключ
|
||||
публикации. Полное решение — в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||
[спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md), колонка версии —
|
||||
в [ADR 0014](../adr/0014-order-version-snapshot-date.md); механика приёма —
|
||||
в [спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ
|
||||
живёт дальше — [модель заказа в DDS](dds/order.md).
|
||||
|
||||
## Часовые пояса
|
||||
@@ -669,8 +670,8 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||
через распределённую таблицу вернул одну строку — ту, у которой `updated_at`
|
||||
позже, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||
|
||||
|
||||
Reference in new issue
Block a user