Files
clickstream-data-platform/docs/architecture/orders/ingestion.md
T
ddadmin 75f28fe99f docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
2026-09-13 16:32:45 -04:00

177 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Прием заказов из 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.