feat(orders): добавить приём слепков в STG

Зачем:
- связать проигрывание модельных дней с пакетным приёмом заказов
- показать на одном стенде различие потокового push и пакетного pull

Что:
- добавлен топик, Kafka-чтец и реплицированное сырьё заказов
- добавлен даг orders_ingest с одним прямым чтением и идентификатором загрузки
- работники мира отправляют слепок, ждут приём и только затем двигают позицию
- решения, границы отказа и проверки отражены в ADR и архитектурных документах

Проверка:
- make lint
- make config-test
- make smoke
- make check-clickhouse
- make check-services
This commit is contained in:
2026-08-18 21:33:13 +03:00
parent e67ee27a17
commit 6bd9190142
10 changed files with 330 additions and 54 deletions
+58 -24
View File
@@ -1,13 +1,15 @@
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
Дата: 12 августа 2026 года. Статус: частично заменено
[ADR 0010](0010-order-versions-in-ods.md). Реализация — отдельным тикетом.
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
`dds.order` с `argMax`. Действующая форма STG → ODS описана в
[спецификации приёма заказов](../architecture/orders/ingestion.md).
`dds.order` с `argMax` — это ADR 0010. Отдельно от него, решением #93 от
17 августа 2026 года, производитель и потребитель слепка разъехались по разным
дагам; раздел «Решение» описывает построенное. Действующая форма STG → ODS
описана в [спецификации приёма заказов](../architecture/orders/ingestion.md).
## Решение
@@ -15,13 +17,18 @@
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
день генератором, поэтому переливается ровно то, что он положил в топик.
Уточнение со сдвигом отправки, решённым позже (#71, [слепок и его
доставка](../architecture/orders/snapshot.md)): даг, играющий день D, в
штатном прогоне кладёт и забирает слепок дня D−1 — слепок предыдущего дня, а
не сыгранного. После падения между шагами в топике может ждать и хвост
прежних слепков; забор принимает всё приехавшее.
заменяет партицию дня в `ods.order_snapshot`.
Производитель и потребитель слепка — разные даги. Работник пульта мира играет
день и отправляет слепок, а забирает его отдельный даг `orders_ingest`; зовёт
забор сам работник ждущим `TriggerDagRunOperator` и зеленеет только вслед за
ним. Связывает две половины не общий даг, а этот дождавшийся успех: зелёный
работник значит, что забор отработал. Уточнение со сдвигом
отправки, решённым позже (#71, [слепок и его
доставка](../architecture/orders/snapshot.md)): работник, играющий день D, в
штатном прогоне кладёт слепок дня D−1 — слепок предыдущего дня, а не
сыгранного. После падения между шагами в топике может ждать и хвост прежних
слепков; забор принимает всё приехавшее.
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
@@ -47,8 +54,8 @@
партиции** `snapshot_date`, ровно как обещает раздел 2 мастер-спеки;
- `dds.order` — дедуп до последней версии через `argMax`, без изменений.
Сенсора дневного батча нет. Ждать нечего: производитель и потребитель слепка
живут в одном даге.
Сенсора дневного батча нет. Ждать нечего: забор дёргает сам отправитель
слепка, когда отправил.
## Почему
@@ -112,7 +119,7 @@
**Что отвергнуто ещё.** Сенсор дневного батча из раздела 9 мастер-спеки: у
топика нет сигнала «всё», и сенсор ловил бы момент, которого не существует, —
а раз генератор и переливка в одном даге, ждать нечего по построению. Лаба,
а раз забор зовёт сам отправитель слепка, ждать нечего по построению. Лаба,
поднимающая типизированного чтеца во второй группе потребителей ради того же
сравнения: она понадобилась бы, останься сравнение невыполненным, но push
против pull даёт его живым и постоянным.
@@ -124,10 +131,13 @@
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
читает тот, кого спросили.
Гарантий приёма у заказов не больше, чем у событий, но последствия мягче.
Офсеты коммитятся при чтении, вставка идёт следом — упавшая вставка теряет
пачку. У потока такая потеря невосстановима, у слепка её лечит следующий день:
окно изменяемости K = 7 привезёт те же заказы заново.
Гарантий приёма у заказов не больше, чем у событий, и граница проходит по
живому. Успешное чтение короткой порции оставляет непрочитанный хвост в
топике — он дождётся следующего забора. А отказ после чтения теряет саму
порцию: офсеты закоммичены в момент чтения, часть строк могла лечь на шарды,
и повторное чтение вернёт ноль. Позицию мира это не двигает, поэтому день
сыграется и уедет заново; в сырье он тогда окажется частичным дублем, а
старый хвост, ушедший той же порцией, не вернётся.
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
@@ -158,10 +168,34 @@ ClickHouse `26.3.17.56`.
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
поддержки, ни запрета.
Осталось проверить при исполнении, и это работа тикета реализации: что второй
прогон подряд возвращает пусто, то есть офсеты действительно закоммичены; что
одного чтения хватает на весь слепок дня; что виртуальные колонки доставки
(`_topic`, `_partition`, `_offset`, `_timestamp_ms`) доступны при прямом чтении
— на них стоят служебные колонки сырья, см. [доку
хранилища](../architecture/storage.md); что повторная заливка дня даёт в
`ods.order_snapshot` тот же счёт, а не удвоенный.
Забор проверен на живом стенде 18 августа 2026 года при исполнении #93
ClickHouse `26.3.17.56`, Airflow 3.3.0. Три вопроса, оставленные этим ADR
реализации, закрыты; заодно снят отказной путь. Обе настройки прямого чтения
доезжают до запроса, объявленные в конце `INSERT ... SELECT`: `system.query_log`
показывает у каждой вставки единицу.
- **Виртуальные колонки доставки при прямом чтении доступны все четыре.**
`_topic`, `_partition`, `_offset` и `_timestamp_ms` читаются тем же
выражением, что в матвью приёма событий, и метаданные в `stg.orders_raw`
заполнены: 1694 строки слепка дня 7 приехали с `orders`, партицией 0,
сплошными офсетами 0…1693 и непустой меткой брокера. Отдельного механизма
пуллу не понадобилось.
- **Одного чтения хватает и на слепок, и на разгон.** Пять заборов подряд
взяли 1694, 1730, 3446, 1694 и 5097 строк — каждый раз ровно столько,
сколько напечатал генератор. Числа сверх слепка объясняются сами: 3446 — это
свежий слепок плюс хвост, оставшийся от упавшего прогона, а 5097 — три
слепка разгона `days = 3`, уехавшие одной порцией.
- **Офсеты действительно коммитятся.** Второй забор подряд по пустому топику
вернул ноль строк, а офсеты следующего продолжились с 1694 — то есть
`kafka_commit_on_select` работает, и слепок не забирается заново.
- **Упавший забор не двигает мир.** Чтец снесли, и прогон работника покраснел
на триггере забора: `remember_played` ушла в `upstream_failed`, позиция
осталась прежней, а после починки тот же день сыгран заново, и ждавший хвост
уехал одной порцией вместе со свежим слепком. Чтения в этом опыте не было
вовсе — падать было нечему, — поэтому он показывает только несдвиг позиции.
Судьба уже прочитанной порции другая, и она описана в «Следствиях».
Осталось проверить при исполнении #94, на стороне ODS: что повторный разбор
того же `_load_id` не двоит версии заказа и не меняет `_load_ts` — критерии
целиком в [спецификации приёма заказов](../architecture/orders/ingestion.md),
раздел «Риски и проверка».
+8 -2
View File
@@ -8,8 +8,10 @@
**Работники** — без расписания и без паузы, запускаются руками:
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1) даёт
разгон вперёд одним нажимом;
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1, потолок
7) даёт разгон вперёд одним нажимом. Потолок — это и есть названное желание
«уедь на неделю сейчас», и ровно тот разгон, который забор заказов забирает
одним чтением ([ADR 0008](0008-order-ingestion.md));
- `world_live_day` — один день в темпе живого режима.
**Выключатель** — без своей работы: `world_live` несёт расписание около двадцати
@@ -99,6 +101,10 @@
которого стенд не поднимает; заводить службу ради одного ожидания дороже
занятого слота.
Приросший к работникам забор заказов (#93, [ADR 0008](0008-order-ingestion.md))
делает из двух занятых слотов три: пока идёт забор, ждут выключатель и сам
работник, а третий слот занимает задача `orders_ingest`.
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
+3 -2
View File
@@ -57,8 +57,9 @@
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Проверки приёма**разовая приёмка допущения «один запуск — одно чтение»
и опыты из [«Рисков и проверки»](ingestion.md) — тикет реализации приёма.
- **Проверки приёма**опыты из [«Рисков и проверки»](ingestion.md) про брак
и версии в ODS — тикет перехода STG → ODS (#94). Допущение «один запуск —
одно чтение» принято живым прогоном при исполнении #93.
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4.
+16 -6
View File
@@ -34,9 +34,10 @@
## Поток данных
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
записывает полученную порцию в `stg.orders_raw`. Все строки получают `_load_id`,
равный `run_id` Airflow. `_load_ts` вычисляется при этой записи и дальше
переносится без пересчёта.
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
@@ -119,6 +120,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
или публикация неполного дня.
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
## Отклонённые варианты
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
@@ -137,9 +145,6 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Риски и проверка
- На стандартном мире сверить число отправленных заказов с числом строк,
принятых одним прямым чтением. Это разовая приёмка допущения, не постоянный
сторож.
- На малой управляемой порции дать по одной строке каждого класса брака и две
годные версии одного `order_id`. Две цели должны сохранить все непустые
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
@@ -151,6 +156,11 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Что проверено
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
+19 -8
View File
@@ -9,8 +9,10 @@
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` лежит вся цепочка
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце.
Этап 3 добавил вход второго источника: топик `orders`, свой чтец и своё сырьё,
которое наполняет даг `orders_ingest`, а не матвью. Дальше по тексту устройство
описано так, как оно проектируется; построенное от заложенного отличает карта
таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -248,6 +250,11 @@ UTC+4), и пересчёт идёт один раз при наполнении
## Приём: поток и его свойства
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md).
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
таблица ошибок ODS.
@@ -414,7 +421,7 @@ kafka_offset)`: смотрят такую таблицу от класса, а
| Файл | Что в нём |
|---|---|
| `00-databases.sql` | базы слоёв |
| `10-stg-tables.sql` | Kafka-таблица, локальная и распределённая таблицы сырья |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
| `20-ods-tables.sql` | типизированное событие и таблица ошибок |
| `30-ods-views.sql` | актуальные события и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
@@ -430,10 +437,12 @@ ODS. Второе: матвью приёма создаётся последне
отладке.
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
топика с одной партицией. Переключателей тут два, и путать их не надо: брокер
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топики
`hits` с двумя партициями и `orders` с одной ([ADR
0008](../adr/0008-order-ingestion.md)), — затем `clickhouse-init` дожидается его
завершения и применяет файлы с ноды 1: всё `ON CLUSTER`, кроме чтеца заказов —
он объявлен только на этой ноде. Порядок страхует `hits` от автосоздания с
одной партицией. Переключателей тут два, и путать их не надо: брокер
автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не
просит — оба конца измерены, см. «Что проверено». То есть стенд держится на
умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и
@@ -460,13 +469,15 @@ ODS. Второе: матвью приёма создаётся последне
## Карта таблиц
Ниже — то, что закладывает этап 2; всё перечисленное лежит в `sql/ddl/`.
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`.
| Слой | Объект | Что это |
|---|---|---|
| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
| STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
| STG | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
| STG | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
| ODS | `ods.event_rep` / `_dist` | типизированное широкое событие |
| ODS | `ods.event_v` | актуальная версия события с полями источника |
| ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |