Зачем: помочь начинающему инженеру проследить событие и версию заказа от генератора до хранилища, включая ошибки и повторные поступления. Что: обновлены вводная генератора, справочники приема и модели заказа, разделы хранилища и комментарии SQL и дага. Сохранены технические условия; исполняемый код, схемы и поведение стенда не изменены. Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI. SQL без комментариев и Python AST без строк документации совпали с исходными. Генерация данных и сброс состояния не выполнялись. Refs #250
177 lines
14 KiB
Markdown
177 lines
14 KiB
Markdown
# Прием заказов из Kafka в ODS
|
||
|
||
Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
|
||
проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
|
||
читают из ODS последнюю принятую версию каждого заказа.
|
||
|
||
## Поток данных
|
||
|
||
Источник отправляет в топик `orders` дневной слепок: по одному сообщению
|
||
на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
|
||
D−6…D, с учетом [задержек первого появления](fate.md).
|
||
В начале модельного времени окно короче. Выгрузку за D отправляет
|
||
генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
|
||
|
||
После отправки шаговый даг модельного времени запускает
|
||
[`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
|
||
У `orders_ingest` нет расписания; его два шага выполняются по порядку:
|
||
|
||
1. `pull_batch` читает порцию из `stg.orders_raw_kafka` и записывает ее
|
||
через `stg.orders_raw_dist`. Содержимое сообщения остается строкой `raw`,
|
||
рядом сохраняются координаты Kafka, `_load_id` и `_load_ts`.
|
||
2. `parse_batch` читает сохраненные строки с этим `_load_id`. Первый
|
||
`INSERT SELECT` пишет годные записи в `ods.order_dist`, второй —
|
||
остальные в `ods.order_errors_dist`.
|
||
|
||
`_load_id` равен `run_id` Airflow и связывает строки одного запуска.
|
||
`_load_ts` — реальное время записи в STG; при разборе оно переносится
|
||
без пересчета. Запросы лежат в
|
||
[`orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql) и
|
||
[`order_parse.sql`](../../../sql/ods/order_parse.sql).
|
||
`SQLExecuteQueryOperator` передает `run_id` параметром ClickHouse `load_id`.
|
||
|
||
Две вставки разбора используют одно условие годности, записанное макросом
|
||
Jinja внутри `order_parse.sql`. Перед выполнением макрос подставляется
|
||
в оба запроса; ветвь брака берет отрицание этого условия. Оно возвращает
|
||
только истину или ложь и не вызывает исключений на некорректном `raw`.
|
||
Так каждая строка сохраненной порции получает ровно один результат.
|
||
|
||
Транзакции между вставками нет: первая может завершиться, а вторая упасть.
|
||
Повтор `parse_batch` выполняет обе вставки над тем же срезом STG.
|
||
Текущее состояние заказа останется прежним, но таблица ошибок может
|
||
получить повторные строки с теми же `_load_id` и координатами Kafka.
|
||
Обе вставки ждут доставки строк на шарды и используют ключ целевой таблицы;
|
||
настройки объяснены в [справочнике хранилища](../storage.md#раскладка-по-шардам).
|
||
|
||
## Граница строгого приёма
|
||
|
||
Проверяется одна строка `stg.orders_raw_dist`. Корень JSON должен быть
|
||
объектом с точным набором из одиннадцати ключей:
|
||
`order_id`, `user_id`, `status`, `created_at`, `updated_at`,
|
||
`items_total`, `discount`, `delivery`, `total`, `items`, `snapshot_date`.
|
||
|
||
| Поля | Условия приема |
|
||
|---|---|
|
||
| `order_id`, `status` | Строки JSON |
|
||
| `user_id` | Целое число JSON, которое разбирается в `UInt64` |
|
||
| `created_at`, `updated_at` | Строки UTC с миллисекундами, например `2026-06-01T12:34:56.000Z`; проверяются форма и разбор времени |
|
||
| `items_total`, `discount`, `delivery`, `total` | Неотрицательные суммы в рублях, записанные строками с двумя знаками после точки; проверяется разбор в `Decimal(18, 2)` |
|
||
| `snapshot_date` | Строка даты `YYYY-MM-DD`, которая проходит разбор |
|
||
| `items` | Массив JSON; сохраняется целиком в строковой колонке |
|
||
|
||
ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
|
||
статусов, их переходов, полей внутри `items` или равенства денежных сумм.
|
||
Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
|
||
добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
|
||
Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||
|
||
Класс ошибки записывается в `error_class` по первому нарушенному условию:
|
||
|
||
1. `not_an_object` — корень JSON не объект;
|
||
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
|
||
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
|
||
|
||
Имя поля в класс не входит. Для разбора конкретной ошибки в
|
||
`ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
|
||
|
||
## Роль ODS
|
||
|
||
`ods.order_rep` использует `ReplicatedReplacingMergeTree(snapshot_date)`.
|
||
Строки с одним ключом сортировки `order_id` считаются версиями одного заказа;
|
||
остается строка с максимальной `snapshot_date`. Партиция —
|
||
`toDate(created_at)` в UTC, ключ шардирования — `cityHash64(order_id)`.
|
||
Неизменный `created_at` и общий ключ направляют все версии заказа
|
||
в одну партицию и на один шард.
|
||
|
||
| Колонка | На какой вопрос отвечает |
|
||
|---|---|
|
||
| `snapshot_date` | На конец какого модельного дня источник показал это состояние? |
|
||
| `updated_at` | Когда запись заказа последний раз меняли в магазине? |
|
||
| `_load_id` | Какой запуск Airflow принял сообщение? |
|
||
| `_load_ts` | Когда сообщение записали в STG по реальным часам стенда? |
|
||
|
||
Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
|
||
`updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
|
||
строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
|
||
нужна DDS для определения окна пересборки, даже если состояние не изменилось.
|
||
|
||
Источник выпускает **один слепок на дату**. Повторная отправка дает то же
|
||
содержимое, а более поздний слепок не возвращает устаревшее состояние.
|
||
Эти условия не проверяются на одной строке при приеме. Если источник
|
||
пришлет два разных состояния с одной `snapshot_date`, эта колонка
|
||
не позволит определить, какое из них актуально.
|
||
Обоснование версии — [ADR 0014](../../adr/0014-order-version-snapshot-date.md).
|
||
|
||
`ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
|
||
на `order_id`, даже если фоновые слияния еще не убрали старые версии.
|
||
Без `FINAL` физическая таблица может показать несколько строк заказа.
|
||
Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
|
||
Отсутствие заказа в новом слепке не удаляет его из ODS.
|
||
|
||
DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
|
||
позиции. Для своей загрузки DDS записывает собственные служебные метки —
|
||
[модель заказа](../dds/order.md#служебные-колонки).
|
||
|
||
## Одно чтение Kafka
|
||
|
||
Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
|
||
Обычный слепок содержит около полутора тысяч строк, а предел порции
|
||
на стенде — десятки тысяч сообщений. Это допущение о размере данных:
|
||
Kafka не сообщает запросу, что прочитан весь слепок.
|
||
|
||
Короткая порция оставляет непрочитанные сообщения для следующих запусков.
|
||
Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
|
||
по `snapshot_date` могла бы опубликовать неполный набор заказов
|
||
([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
|
||
|
||
Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
|
||
сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
|
||
в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
|
||
часть из них может успеть записаться на шарды. Позиция модельного времени
|
||
при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
|
||
того же дня, но не восстановит старые сообщения других слепков,
|
||
если они попали в неудачную порцию.
|
||
Граница восстановления описана в [ADR 0008](../../adr/0008-order-ingestion.md#следствия).
|
||
|
||
## Что проверено
|
||
|
||
Это результаты прежних опытов на стенде; при редактуре они заново
|
||
не выполнялись.
|
||
|
||
- 18 августа 2026 года (#93, #94) прямое чтение приняло слепок,
|
||
а разбор разделил строки на годные и ошибки без потерь и пересечений.
|
||
Две версии заказа попали на один шард в одну партицию; `ods.order_v`
|
||
вернул позднюю. Повтор разбора с тем же `_load_id` сохранил текущую строку
|
||
и ее `_load_ts`, но повторил ошибки. Подробности —
|
||
[справочник хранилища](../storage.md#что-проверено) и
|
||
[ADR 0008](../../adr/0008-order-ingestion.md#что-проверено).
|
||
- 20 августа после исправления
|
||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||
приняты все 1694 строки слепка дня 7 без ошибок. Итог заказа
|
||
`20260608-0007`: `5490.00 − 823.50 + 299.00 = 4965.50`.
|
||
- 21 августа (#124) пересборка через `SQLExecuteQueryOperator` завершила
|
||
`pull_batch` и `parse_batch`; в `system.query_log` осталось по одному
|
||
завершенному запросу `etl` на забор, годные строки и ошибки.
|
||
- 22 августа (#137) во всех 3034 парах с одинаковым `updated_at`
|
||
деловые поля совпали побайтово; по слепкам время изменения не убывало.
|
||
- 26 августа (#165) после двух слепков физическая таблица содержала
|
||
3356 версий 1917 заказов. В `ods.order_v` у всех заказов была дата
|
||
последнего слепка, а деловые поля совпали с `argMax` по `updated_at`
|
||
из сырых данных.
|
||
|
||
На ClickHouse `26.3.17.56` отдельно проверены одна порция при прямом
|
||
`SELECT` Kafka и выполнение `FINAL` на шардах через Distributed.
|
||
При проектировании Context7 был недоступен; чтение сверяли с исходниками
|
||
той же версии:
|
||
[`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).
|
||
Повторная сверка правил версий с документацией — в
|
||
[справочнике хранилища](../storage.md#актуальные-события).
|
||
|
||
## Связанные решения
|
||
|
||
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||
слепка версионным ODS.
|