docs(ingestion): упрощены объяснения источников и приема данных

Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

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

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

Refs #250
This commit is contained in:
ddadmin committed 2026-09-13 16:32:45 -04:00
1 parent 83434102d5
commit 75f28fe99f
13 files changed
+663 -974

No files matched your search

+135 -204
View File
@@ -1,241 +1,172 @@
# Приём заказов из Kafka в ODS
# Прием заказов из Kafka в ODS
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
и запуск загрузки, а затем читает физические версии через явную поверхность
текущего состояния.
## Проблема
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
## Цели
- сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода.
## Не входит
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
- удаление заказа по отсутствию в следующем слепке;
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
читают из 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 своего слоя.
Источник отправляет в топик `orders` дневной слепок: по одному сообщению
на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
D−6…D, с учетом [задержек первого появления](fate.md).
В начале модельного времени окно короче. Выгрузку за D отправляет
генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
Один следующий `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`.
После отправки шаговый даг модельного времени запускает
[`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
У `orders_ingest` нет расписания; его два шага выполняются по порядку:
Условия запросов взаимодополняющие: один общий предикат определяет годную
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
возвращают результат без исключения, а сам предикат всегда заканчивается в
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно.
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`. Корень должен быть
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
`items`, `snapshot_date`.
Проверяется одна строка `stg.orders_raw_dist`. Корень 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).
| Поля | Условия приема |
|---|---|
| `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; сохраняется целиком в строковой колонке |
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
скалярных значений непосредственно образуют типизированную строку заказа. У
события из 47 полей проверяются только пять опорных; переносить то сокращение на
малый контракт заказа нет причины. Граница строгости заканчивается на форме
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
статусов, их переходов, полей внутри `items` или равенства денежных сумм.
Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс ошибки записывается в `error_class` по первому нарушенному условию:
Класс брака выбирается первым совпадением:
1. `not_an_object` — корень JSON не объект;
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
1. `not_an_object`;
2. `keyset_mismatch`;
3. `field_invalid`.
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
без постоянной детализации предиката.
Имя поля в класс не входит. Для разбора конкретной ошибки в
`ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
## Роль ODS
`ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
день неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу.
`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 по реальным часам стенда? |
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
- `updated_at` — когда строку последний раз меняли в источнике;
- `_load_id` — какой запуск Airflow принял строку;
- `_load_ts` — когда строка приехала в хранилище.
Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
`updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
нужна DDS для определения окна пересборки, даже если состояние не изменилось.
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
той физической строке, которую сохранило слияние, и описывают её доставку.
Источник выпускает **один слепок на дату**. Повторная отправка дает то же
содержимое, а более поздний слепок не возвращает устаревшее состояние.
Эти условия не проверяются на одной строке при приеме. Если источник
пришлет два разных состояния с одной `snapshot_date`, эта колонка
не позволит определить, какое из них актуально.
Обоснование версии — [ADR 0014](../../adr/0014-order-version-snapshot-date.md).
Схема версий стоит на дисциплине источника: слепок с поздней датой не
показывает состояния старше. Модельный мир её держит — состояние читается на
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
`ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
на `order_id`, даже если фоновые слияния еще не убрали старые версии.
Без `FINAL` физическая таблица может показать несколько строк заказа.
Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
Отсутствие заказа в новом слепке не удаляет его из ODS.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
может быть простым представлением над `_dist FINAL`; способ выбора можно
заменить, не меняя потребителей.
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
какие сущности, связи и производные признаки ему нужны. Судьба служебных
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
([модель заказа](../dds/order.md)).
DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
позиции. Для своей загрузки DDS записывает собственные служебные метки —
[модель заказа](../dds/order.md#служебные-колонки).
## Одно чтение Kafka
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
Обычный слепок содержит около полутора тысяч строк, а предел порции
на стенде — десятки тысяч сообщений. Это допущение о размере данных:
Kafka не сообщает запросу, что прочитан весь слепок.
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
или публикация неполного дня.
Короткая порция оставляет непрочитанные сообщения для следующих запусков.
Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
по `snapshot_date` могла бы опубликовать неполный набор заказов
([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([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-координаты показывают повтор задачи.
Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
часть из них может успеть записаться на шарды. Позиция модельного времени
при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
того же дня, но не восстановит старые сообщения других слепков,
если они попали в неудачную порцию.
Граница восстановления описана в [ADR 0008](../../adr/0008-order-ingestion.md#следствия).
## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
одну партицию и на один шард, а `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`.
- 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`
из сырых данных.
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `etl` для забора в STG, записи годных строк и записи брака.
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
побайтово, а по слепкам метка ни разу не убывала.
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
заказа; деловые поля совпали с эталоном `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),
поведение чтения — с исходниками той же версии
На 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#актуальные-события).
## Связанные решения