- Зачем:
- ярлык 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>
249 lines
21 KiB
Markdown
249 lines
21 KiB
Markdown
# Приём заказов из Kafka в ODS
|
||
|
||
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
||
и запуск загрузки, а затем читает физические версии через явную поверхность
|
||
текущего состояния.
|
||
|
||
## Проблема
|
||
|
||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
||
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
||
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
||
транспортной границе и могла заменить день неполным набором строк.
|
||
|
||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||
|
||
## Цели
|
||
|
||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
||
партиции;
|
||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||
- оставить код проверки коротким и ограничить его контрактом провода.
|
||
|
||
## Не входит
|
||
|
||
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
||
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
||
- удаление заказа по отсутствию в следующем слепке;
|
||
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
||
|
||
## Поток данных
|
||
|
||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
||
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
||
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
||
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
||
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
||
|
||
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
||
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
||
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
|
||
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
|
||
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
|
||
Обе вставки лежат в одном самодостаточном файле
|
||
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
|
||
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
|
||
порядку в одной задаче `parse_batch`.
|
||
|
||
Условия запросов взаимодополняющие: один общий предикат определяет годную
|
||
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
|
||
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
||
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
||
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
|
||
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
|
||
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
|
||
самостоятельно.
|
||
|
||
## Граница строгого приёма
|
||
|
||
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
||
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
|
||
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
|
||
`items`, `snapshot_date`.
|
||
|
||
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
|
||
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту
|
||
форму не входит; времена — строками RFC 3339 в UTC с обязательными
|
||
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только
|
||
как JSON-массив. Каноническая форма и основания выбора зафиксированы в
|
||
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||
|
||
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
||
скалярных значений непосредственно образуют типизированную строку заказа. У
|
||
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||
|
||
Одно условие контракта предикатом не проверить, и названо оно всё равно
|
||
здесь: **одна дата — один слепок**. Вторую выгрузку того же дня приём не
|
||
отличит от первой, а колонка версии — дата слепка — на ней даёт ничью.
|
||
Модельный источник выгружает день один раз; настоящему такое условие ставят
|
||
договором, а не предикатом
|
||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
||
|
||
Класс брака выбирается первым совпадением:
|
||
|
||
1. `not_an_object`;
|
||
2. `keyset_mismatch`;
|
||
3. `field_invalid`.
|
||
|
||
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
||
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
||
без постоянной детализации предиката.
|
||
|
||
## Роль ODS
|
||
|
||
`ods.order_rep` хранит физически принятые версии в
|
||
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
||
день неизменного `created_at`. `ods.order_dist` шардирует по
|
||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||
`FINAL` даёт корректный результат через распределённую таблицу.
|
||
|
||
Четыре координаты отвечают на разные вопросы:
|
||
|
||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||
- `updated_at` — когда строку последний раз меняли в источнике;
|
||
- `_load_id` — какой запуск Airflow принял строку;
|
||
- `_load_ts` — когда строка приехала в хранилище.
|
||
|
||
Версию выбирает `snapshot_date`: выживает строка позднейшего слепка
|
||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому ярлык
|
||
выжившей строки — правдивый ответ на вопрос «когда заказ видели в последний
|
||
раз», и по нему трансформация DDS читает объём пересборки. `_load_id` и
|
||
`_load_ts` выжившей строки говорят про ту же доставку.
|
||
|
||
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
||
показывает состояния старше. Модельный мир её держит — состояние читается на
|
||
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
||
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
||
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
||
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
||
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
||
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
||
больше не показывает, и там помогает только сверка самих полей, в терминах
|
||
dbt snapshots — стратегия `check` вместо `timestamp`.
|
||
|
||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
||
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
||
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
||
заменить, не меняя потребителей.
|
||
|
||
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
||
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
||
какие сущности, связи и производные признаки ему нужны. Судьба служебных
|
||
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
|
||
([модель заказа](../dds/order.md)).
|
||
|
||
## Одно чтение Kafka
|
||
|
||
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
||
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
||
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
||
|
||
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
||
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
||
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
||
или публикация неполного дня.
|
||
|
||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
||
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
||
|
||
## Отклонённые варианты
|
||
|
||
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
|
||
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
|
||
признака полноты партиции, а дата наблюдения становится частью ключа
|
||
сущности.
|
||
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
|
||
технические повторы там, где семантика версии уже известна.
|
||
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
|
||
объёма, который с большим запасом помещается в одну порцию.
|
||
- Два `task_id` или материализованный классификатор: дробят один короткий
|
||
переход слоя, не добавляя транзакционности.
|
||
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
|
||
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
|
||
модель данных.
|
||
|
||
## Риски и проверка
|
||
|
||
- На малой управляемой порции дать по одной строке каждого класса брака и две
|
||
годные версии одного `order_id`. Две цели должны сохранить все непустые
|
||
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
|
||
слияния.
|
||
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
|
||
не должны измениться; версии одного заказа должны остаться на одном шарде и
|
||
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
|
||
`_load_id` и Kafka-координаты показывают повтор задачи.
|
||
|
||
## Что проверено
|
||
|
||
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
|
||
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
|
||
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
|
||
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||
|
||
После исправления
|
||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
|
||
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
|
||
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
|
||
`5490.00 − 823.50 + 299.00 = 4965.50`.
|
||
|
||
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
|
||
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
|
||
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
||
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
||
|
||
Дисциплина источника замерена 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: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
|
||
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
|
||
|
||
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
|
||
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
|
||
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
|
||
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
|
||
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
||
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
|
||
поведение чтения — с исходниками той же версии
|
||
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
|
||
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
||
|
||
## Связанные решения
|
||
|
||
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||
слепка версионным ODS.
|