Files
clickstream-data-platform/docs/adr/0008-order-ingestion.md
T
ddadmin 6bd9190142 feat(orders): добавить приём слепков в STG
Зачем:
- связать проигрывание модельных дней с пакетным приёмом заказов
- показать на одном стенде различие потокового push и пакетного pull

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

Проверка:
- make lint
- make config-test
- make smoke
- make check-clickhouse
- make check-services
2026-08-18 21:33:13 +03:00

202 lines
19 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.
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
Дата: 12 августа 2026 года. Статус: частично заменено
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
`dds.order` с `argMax` — это ADR 0010. Отдельно от него, решением #93 от
17 августа 2026 года, производитель и потребитель слепка разъехались по разным
дагам; раздел «Решение» описывает построенное. Действующая форма STG → ODS
описана в [спецификации приёма заказов](../architecture/orders/ingestion.md).
## Решение
Топик `orders` читает Kafka-таблица формата `RawBLOB` — та же форма чтеца, что
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
заменяет партицию дня в `ods.order_snapshot`.
Производитель и потребитель слепка — разные даги. Работник пульта мира играет
день и отправляет слепок, а забирает его отдельный даг `orders_ingest`; зовёт
забор сам работник ждущим `TriggerDagRunOperator` и зеленеет только вслед за
ним. Связывает две половины не общий даг, а этот дождавшийся успех: зелёный
работник значит, что забор отработал. Уточнение со сдвигом
отправки, решённым позже (#71, [слепок и его
доставка](../architecture/orders/snapshot.md)): работник, играющий день D, в
штатном прогоне кладёт слепок дня D−1 — слепок предыдущего дня, а не
сыгранного. После падения между шагами в топике может ждать и хвост прежних
слепков; забор принимает всё приехавшее.
Прямое чтение из 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` всегда
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
читает тот, кого спросили.
Гарантий приёма у заказов не больше, чем у событий, и граница проходит по
живому. Успешное чтение короткой порции оставляет непрочитанный хвост в
топике — он дождётся следующего забора. А отказ после чтения теряет саму
порцию: офсеты закоммичены в момент чтения, часть строк могла лечь на шарды,
и повторное чтение вернёт ноль. Позицию мира это не двигает, поэтому день
сыграется и уедет заново; в сырье он тогда окажется частичным дублем, а
старый хвост, ушедший той же порцией, не вернётся.
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
меняется. `etl_pipeline` остаётся за этапом 5.
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
партиция), раздел 9 (сенсор снят, даг переехал на этап 3), раздел 12
(сравнение приёмов переформулировано).
## Что проверено
Документация ClickHouse проверена через MCP Context7 12 августа 2026 года.
Предел порции одного опроса Kafka дополнительно снят 16 августа на локальном
ClickHouse `26.3.17.56`.
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
начиная с версии 21.12 и открывается настройкой
`stream_like_engine_allow_direct_select`.
- При привязанной матвью прямое чтение остаётся запрещённым и с этой
настройкой. Отсюда вывод, что два способа приёма взаимоисключающи.
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
камень, который молчит на первом прогоне и вылезает на втором.
- Прямое чтение возвращает одну порцию, полученную одним опросом Kafka. При
настройках стенда её предел — 65 409 сообщений, поэтому слепок порядка
полутора тысяч строк помещается с запасом.
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
поддержки, ни запрета.
Забор проверен на живом стенде 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),
раздел «Риски и проверка».