docs(adr): решён приём заказов — пакетный забор слепка

- Зачем:
  - развилка этапа 3 стояла нерешённой прямо в разделе 7 мастер-спеки: нужен
    ли слепку слой сырья и как заказы попадают из топика в хранилище (#70).
- Что:
  - заведён ADR 0008 — байтовый чтец без матвью, слой сырья у заказов
    остаётся, в ods.order_snapshot пишет шаг Airflow заменой партиции; топик
    orders в одну партицию, чтец на clickhouse-01 без ON CLUSTER.
  - мастер-спека приведена в соответствие, разделы 6, 7, 9, 11, 12: сравнение
    двух приёмов переписано на «поток против слепка», сенсор дневного батча
    снят, первый даг переехал с этапа 5 на этап 3.
  - в CONTEXT.md заведены «слепок», «окно изменяемости», «пакетный забор».
- Проверка:
  - решение сверено по документации ClickHouse через MCP Context7 12 августа
    2026 года; что осталось замерить на стенде — списком в конце ADR 0008.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 21:57:37 +03:00
co-authored by Claude Opus 5
parent a3d852cad9
commit 1fb219f836
3 changed files with 212 additions and 29 deletions
+18
View File
@@ -147,3 +147,21 @@ _Избегать_: описание схемы, документация кон
**Проигрыватель**:
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Один заказ
приезжает в стольких слепках, сколько дней окна он прожил.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
+152
View File
@@ -0,0 +1,152 @@
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
Дата: 12 августа 2026 года. Статус: принято. Реализация — отдельным тикетом.
## Решение
Топик `orders` читает Kafka-таблица формата `RawBLOB` — та же форма чтеца, что
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
день генератором, поэтому переливается ровно то, что он положил в топик.
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
а `kafka_commit_on_select = 1` на самой таблице заставляет запрос коммитить
офсеты. По умолчанию прямое чтение их не коммитит — без этой настройки каждый
прогон забирает один и тот же слепок заново.
Матвью к чтецу не привязана не по вкусу, а по устройству: при привязанной
матвью прямое чтение остаётся запрещённым независимо от первой настройки. Два
способа приёма взаимоисключающи по построению, и выбрать половину каждого
нельзя.
У топика **одна партиция**, а чтец объявлен **только на `clickhouse-01` и без
`ON CLUSTER`**. Пишет пакетный шаг по-прежнему в распределённую таблицу, так
что раскладка по шардам не меняется: ключ у сырья прежний, `cityHash64` сырой
строки.
Слои от этого получают разные роли, и каждая своя:
- `stg.orders_raw` — байты как приехали, срок жизни и метаданные доставки как
у сырья событий; повторная заливка дня честно удваивает строки, как и там;
- `ods.order_snapshot` — типизированный слепок дня, идемпотентный **заменой
партиции** `snapshot_date`, ровно как обещает раздел 2 мастер-спеки;
- `dds.order` — дедуп до последней версии через `argMax`, без изменений.
Сенсора дневного батча нет. Ждать нечего: производитель и потребитель слепка
живут в одном даге.
## Почему
**Слепок — не поток, и приём обязан это признать.** События приезжают
непрерывно, и матвью, тянущая их на лету, — честная форма для непрерывного.
Заказы приезжают раз в модельный день целой выгрузкой окна изменяемости; у
такой доставки есть начало и конец, и забирать её уместно по команде, а не
подписью на бесконечность. Стенд от этого получает не два оттенка одного
приёма, а два разных режима — push и pull, — и каждый стоит там, где ему место
по природе источника. Именно это сравнение раздел 12 мастер-спеки и заказывал
опорной точкой; прежняя его формулировка противопоставляла байтового чтеца
типизированному, то есть две формы одного и того же приёма, и переписана.
**Почему не типизированный чтец прямо в `ods.order_snapshot`.** Он давал бы
строгий приём средствами движка и живое сравнение двух чтецов даром, но ODS
перестал бы быть надстройкой над STG и стал бы вторым входом с шины. Ровно эту
схему ADR 0005 отверг для событий, и повод здесь тот же: на стенде слои и есть
предмет изучения.
**Почему не матвью из сырья в ODS, как у событий.** Тогда `ods.order_snapshot`
получил бы те же свойства, что `stg.orders_raw`: «как приехало», без дедупа,
дубли при повторной заливке законны. Два слоя подряд с одной ролью — один
лишний. Хуже того, обещание идемпотентности из раздела 2 повисло бы ни на чём:
матвью партиций не заменяет, а у ODS, в отличие от сырья, срока жизни нет, и
удвоение слепка жило бы вечно. При пакетном шаге каждый слой отрабатывает
своё, а замену партиции делает тот, кто данные и принёс.
**Почему чтец остался байтовым.** Пулл снял возражение про второй вход с шины,
и типизированный чтец снова стал допустим — но тогда между двумя приёмами
стенда менялись бы сразу две переменные, и сравнение перестало бы читаться.
Меняется одна: push против pull. Вдобавок `RawBLOB` сохраняет за слепком то же,
что даёт событиям, — колонку, которую менти открывает в обычном клиенте и
читает глазами, и возможность переразобрать сырьё, не переигрывая день.
Свойства этого формата уже сняты живыми запросами при исполнении #37 и #43;
менять его здесь значило бы платить второй раз за уже купленное.
**Почему одна партиция и один чтец.** Вторая партиция ничего не покупает:
слепок дня — порядка полутора тысяч строк, параллелизм не нужен. А урок «какая
нода читала топик» при пулле мёртв в любом случае — читает та нода, которую
спросили. То есть вторая партиция продаёт единственный настоящий риск схемы:
половина слепка застревает у ноды, к которой запрос не пришёл. Одна партиция
риск смягчает, но не снимает — брокер отдаст её любому из двух потребителей;
снимает его единственный чтец. У Airflow подготовлено одно подключение, к
`clickhouse-01`, — там чтецу и место.
Объявление без `ON CLUSTER` — не оговорка к конвенции, а её первое осознанное
исключение: у пулла один тянущий по определению. Заодно это контрпример к
рефлексу «везде `ON CLUSTER`»: приставка не ритуал, а решение.
**Почему не `Distributed` поверх Kafka.** Один запрос к распределённой таблице
над двумя чтецами осушил бы обе ноды разом и снял бы вопрос о партициях.
Механически это, скорее всего, работает — `Distributed` просто просит каждый
шард выполнить локальное чтение по имени таблицы, — но документация о такой
связке молчит: ни поддержки, ни запрета. Цена молчания высока. Офсеты
коммитятся на каждом шарде в момент чтения, а вставка идёт следом на
инициаторе: упала вставка — потеряны обе половины, а не одна. Недоступный шард
даёт худший из возможных исходов — тихо приехавшую половину слепка. Чинить
такое пришлось бы чтением исходников. И учебная цена своя: менти обязан
расшифровать конструкцию, которой нет ни в документации, ни в бою, и получает
за это трюк. При одной партиции и одном чтеце она не нужна вовсе.
**Что отвергнуто ещё.** Сенсор дневного батча из раздела 9 мастер-спеки: у
топика нет сигнала «всё», и сенсор ловил бы момент, которого не существует, —
а раз генератор и переливка в одном даге, ждать нечего по построению. Лаба,
поднимающая типизированного чтеца во второй группе потребителей ради того же
сравнения: она понадобилась бы, останься сравнение невыполненным, но push
против pull даёт его живым и постоянным.
## Следствия
Урок про виртуальные колонки — «какая нода читала топик, меняется между
прогонами» — остаётся целиком за событиями. У заказов `consumer_host` всегда
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
читает тот, кого спросили.
Гарантий приёма у заказов не больше, чем у событий, но последствия мягче.
Офсеты коммитятся при чтении, вставка идёт следом — упавшая вставка теряет
пачку. У потока такая потеря невосстановима, у слепка её лечит следующий день:
окно изменяемости K = 7 привезёт те же заказы заново.
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
меняется. `etl_pipeline` остаётся за этапом 5.
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
партиция), раздел 9 (сенсор снят, даг переехал на этап 3), раздел 12
(сравнение приёмов переформулировано).
## Что проверено
По документации ClickHouse через MCP Context7, 12 августа 2026 года.
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
начиная с версии 21.12 и открывается настройкой
`stream_like_engine_allow_direct_select`.
- При привязанной матвью прямое чтение остаётся запрещённым и с этой
настройкой. Отсюда вывод, что два способа приёма взаимоисключающи.
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
камень, который молчит на первом прогоне и вылезает на втором.
- Сколько строк отдаёт одно чтение, задаёт `kafka_max_block_size`. При слепке
порядка полутора тысяч строк это один блок с запасом.
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
поддержки, ни запрета.
Осталось проверить при исполнении, и это работа тикета реализации: что второй
прогон подряд возвращает пусто, то есть офсеты действительно закоммичены; что
одного чтения хватает на весь слепок дня; что виртуальные колонки доставки
(`_topic`, `_partition`, `_offset`, `_timestamp_ms`) доступны при прямом чтении
— на них стоят служебные колонки сырья, см. [доку
хранилища](../architecture/storage.md); что повторная заливка дня даёт в
`ods.order_snapshot` тот же счёт, а не удвоенный.
+42 -29
View File
@@ -307,12 +307,17 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: Kafka-таблицы и MV на обеих нодах, одна consumer group,
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
Раскладку в обоих случаях решает ключ:
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
список и доводы — в [доке хранилища](../architecture/storage.md). Урок:
«какая нода читала топик — меняется между прогонами, куда легли данные — нет».
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
«какая нода читала топик — меняется между прогонами, куда легли данные —
нет» живёт на событиях: при пакетном заборе читает та нода, которую
спросили.
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
@@ -382,8 +387,9 @@ README.
| Слой | Объект | Что это |
|---|---|---|
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, не решена (ниже) | сырые строки, Kafka Engine на обеих нодах |
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
@@ -397,14 +403,16 @@ README.
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
см. [доку хранилища](../architecture/storage.md).
Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран
приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md));
заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой
сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух
чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои
перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов;
как учебное сравнение двух способов приёма это записано и в опорных точках
раздела 12.
Заказы принимаются **пакетным забором** ([ADR
0008](../adr/0008-order-ingestion.md)): чтец топика байтовый, как у событий, но
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
он же вставляет прочитанное в `stg.orders_raw`, разбирает в типизированный
слепок и заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает
модельный день генератором, поэтому переливается ровно то, что он положил в
топик. Слой сырья у заказов остаётся: без него `ods.order_snapshot` повторил бы
его роль, а обещание идемпотентности повисло бы — матвью партиций не заменяет.
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
из опорных точек раздела 12 переформулировано под них.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у
@@ -495,7 +503,7 @@ v2 стартует пустым, поэтому объём ниже — это
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
@@ -537,10 +545,11 @@ v2 стартует пустым, поэтому объём ниже — это
целиком). В конце этапа фиксируется маленький стартовый мир для
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
отдельный этап 7).
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь и первый
настоящий даг — проигрыш модельного дня плюс переливка слепка ([ADR
0008](../adr/0008-order-ingestion.md)).
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
батча заказов — сенсор/Datasets).
5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
на amd64 и arm64.
@@ -603,9 +612,13 @@ Kafka Engine на двух нодах снят с этого списка при
- Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md).
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся
под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры
проверить через Context7.
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и пишутся под API
третьей версии (Datasets → Assets) — актуальные операторы проверить через
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
0008](../adr/0008-order-ingestion.md)).
- Пакетный забор слепка из Kafka: коммит офсетов прямым чтением, хватает ли
одного чтения на слепок дня, доступны ли при нём виртуальные колонки
доставки — список и ответы в [ADR 0008](../adr/0008-order-ingestion.md).
- Генератор: рабочее решение — Python с производительной архитектурой
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
распараллеливание по модельным дням). Читаемость генератора для менти —
@@ -653,14 +666,14 @@ v2, этап 0).
синтетическая постановка — осознанный приём;
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
`count()`, а в бою — нет; частый вопрос на собеседованиях;
- два способа принять топик, рядом на одном стенде: сырьё байтами с разбором
функциями (`hits`, [ADR 0005](../adr/0005-event-ingestion.md)) против
типизированного чтеца с `kafka_handle_error_mode` — сравнение цены и
наблюдаемости как задание. **Развилка этапа 3, не решена**: типизированный
чтец идёт заказам только вместе с ответом на вопрос, нужен ли им слой сырья.
Нужен — и чтецов на один топик станет два, а эту схему ADR 0005 отверг; не
нужен — и слои перестают быть единообразными. Разбирать грилингом, когда
дойдём до заказов;
- два способа принять топик, рядом на одном стенде: **поток против слепка,
push против pull**. События тянет матвью на лету ([ADR
0005](../adr/0005-event-ingestion.md)), слепок заказов забирает пакетный шаг
по команде дага ([ADR 0008](../adr/0008-order-ingestion.md)); чтец в обоих
случаях байтовый, так что различает их ровно режим. Сравнение цены,
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
отвергнут обоими ADR, и остаётся материалом для рассказа;
- матвью как рабочий механизм, а не диковина: их видно на приёме и на сборке
ODS, а пакетная работа начинается выше. Отдельным заданием — как читать из ODS
последние версии, через `FINAL` или оконной функцией: что нагляднее, решаем на