docs(orders): устройство заказов переехало в живой набор architecture/orders
Зачем: собранная спека заказов стала базовой документацией сервиса, а жанр спеки-события ей мал: дата в имени врёт, целиком в контекст агента она не влезает, а трекер с резолюциями долговечным хранилищем не считается. Решение владельца — держать детальное устройство компонента связным набором живых документов (ADR 0011) и совместить переезд с проходом на вычитание (#84). Что: docs/architecture/orders/ — индекс README и файлы по частям устройства: проекция, слепок и доставка, судьба, классы расхождений, опись, мост к склейке, стартовый мир, правила кода; спека приёма переехала в ingestion.md без содержательных правок. Резы вычитания по итогам двух слепых линий: тела разделов о проводе и приёме сведены к указателям на мастер-спеку, исследование формата и ADR (порядок строк слепка — единственное правило, оставшееся на месте); замеры канонического зерна и повторы-пояснения срезаны; списки отклонённых вариантов сохранены как долговечная запись. Датированные файлы удалены, ссылки из мастер-спеки, спеки генератора, ADR 0008/0010, исследования формата и storage.md перенацелены; раздел «Структура» AGENTS.md дополнен правилом подпапки. Проверка: grep по репозиторию не находит ссылок на удалённые файлы; все относительные ссылки внутри набора разрешаются в существующие файлы; впереди холодная сверка «ни одно решение не потеряно» и приёмка #88. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# Заказы бэкенда
|
||||
|
||||
Устройство второго источника стенда: раз в модельный день бэкенд магазина
|
||||
выгружает полный слепок заказов окна изменяемости, и деньги в витринах
|
||||
считаются по нему, а не по трекеру. Набор собран картой #69 (этап 3) и
|
||||
правится по мере постройки.
|
||||
|
||||
Статус: приёмка владельцем — тикет #88; до неё набор — черновик.
|
||||
|
||||
Границы уже решены мастер-спекой [«Боевой реализм стенда
|
||||
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): поля слепка, окно K = 7
|
||||
как константа мира, три статуса, приоритет классов расхождений, правило
|
||||
«поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед
|
||||
которой отвечает каждое решение, — «Чем меряется генератор» в [спеке
|
||||
генератора](../../specs/2026-08-01-generator.md): конструкция внутри
|
||||
оправдана только наблюдаемым эффектом на выходе.
|
||||
|
||||
## Целевая картина одним взглядом
|
||||
|
||||
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
|
||||
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
|
||||
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке —
|
||||
[откуда берётся заказ](projection.md).
|
||||
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых
|
||||
в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий
|
||||
прогон — ночная выгрузка бэкенда за вчера; на проводе — один JSON-документ
|
||||
на заказ — [слепок и его доставка](snapshot.md).
|
||||
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо
|
||||
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||
[судьба заказа](fate.md).
|
||||
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||
заказная и событийная стороны; броски независимы, пересечения выходят
|
||||
арифметикой, приоритет классов работает по-настоящему —
|
||||
[классы расхождений и опоздание](mismatch.md).
|
||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов —
|
||||
[что хранит опись](inventory.md).
|
||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||
[мост к склейке](identity.md).
|
||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
|
||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
||||
[приём из Kafka в ODS](ingestion.md).
|
||||
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
||||
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
||||
мира — [заказы в стартовом мире](start-world.md).
|
||||
|
||||
Правила, обязательные для кода этапа 3, собраны в
|
||||
[правилах кода](code-rules.md).
|
||||
|
||||
## Открытые решения
|
||||
|
||||
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
|
||||
не бросает, переименование ничего не сдвигает (#72).
|
||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
||||
потребуют только числа, не устройство.
|
||||
- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение»
|
||||
и опыты из [«Рисков и проверки»](ingestion.md) — тикет реализации приёма.
|
||||
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
|
||||
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||
механике заказов не привязан.
|
||||
|
||||
## Родословная
|
||||
|
||||
Собрано тикетом #87 по резолюциям развилок карты #69: приём (#70), генератор
|
||||
слепков (#71), расхождения и опоздания (#72), мост к склейке (#73), место в
|
||||
стартовом мире (#74), брак и версии в ODS (#80), форма записи на проводе
|
||||
(#81). Решения о приёме — [ADR 0008](../../adr/0008-order-ingestion.md) и
|
||||
[ADR 0010](../../adr/0010-order-versions-in-ods.md); контракт провода —
|
||||
мастер-спека, раздел 2, и [исследование формата
|
||||
слепка](../../research/2026-08-16-order-snapshot-wire-format.md). Форма
|
||||
набора — [ADR 0011](../../adr/0011-component-docs.md).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Правила кода этапа 3
|
||||
|
||||
Хвосты резолюций, обязательные для реализации:
|
||||
|
||||
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
|
||||
заказах (правило формы, [судьба заказа](fate.md)); моментов всегда два.
|
||||
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
|
||||
целых весов, никаких плавающих распределений; деньги — в целых копейках.
|
||||
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
|
||||
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
|
||||
когорты.
|
||||
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
|
||||
([исследование
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
|
||||
прямых `json.dumps` по коду нет.
|
||||
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Судьба заказа
|
||||
|
||||
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
|
||||
|
||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||
решённая при его рождении и уложенная в окно K целиком.
|
||||
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||
компонент называет часть мира; новые стороны занимают те же позиции, имена —
|
||||
при реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
|
||||
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
|
||||
ручки калибровки, которые крутятся разом.
|
||||
|
||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||
становится функцией доли, и правка одной доли перебрасывает весь подпоток
|
||||
после себя. Изоляцию даёт форма броска, а не число подпотоков.
|
||||
|
||||
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
|
||||
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
|
||||
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
|
||||
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
|
||||
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
|
||||
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
|
||||
отмены (шестое значение занято `awaiting_order`).
|
||||
|
||||
**Форма броска.**
|
||||
|
||||
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
|
||||
строкой, а не оставляется читателю складывать хвост в уме.
|
||||
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
|
||||
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
|
||||
времён аудита не давали точных равенств (урок правки #50).
|
||||
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
|
||||
исходов второй выбрасывается. Где их два по существу, ранний считается
|
||||
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
|
||||
|
||||
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
|
||||
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
|
||||
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
|
||||
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
|
||||
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
|
||||
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
|
||||
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
|
||||
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||
|
||||
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
||||
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
||||
|
||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||
версия; различает их история версий: сырьё STG и физические версии
|
||||
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
|
||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||
он в резерве.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Мост к склейке: человек, `person_id`, `user_id`
|
||||
|
||||
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
|
||||
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
|
||||
|
||||
Причинная модель: план состава порождает **человека**; ему принадлежат одна
|
||||
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
|
||||
может оформить заказ — тогда заказная сторона представляет того же человека
|
||||
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
|
||||
без визитов не порождаются, внутреннее знание наружу не выдаётся.
|
||||
|
||||
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
|
||||
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
|
||||
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
|
||||
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
|
||||
анонимность формата держится формой, а не забывчивостью сериализатора.
|
||||
|
||||
`person_id` — последний обычный бросок потока случайности когорты, после уже
|
||||
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
|
||||
паспорта. Для второй куки повторяется ID её человека. Раздельные прогоны
|
||||
ничего не хранят и не согласуют: генератор событий и команда слепка заново
|
||||
спрашивают один план и получают тот же `person_id`.
|
||||
|
||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
||||
канонический мир по внешнему результату.
|
||||
|
||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
|
||||
человека* — магазин оказался бы замаскированным продолжением трекера;
|
||||
*структурная координата, хеш или глобальная последовательность* — больше
|
||||
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Приём заказов из Kafka в ODS
|
||||
|
||||
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
||||
и запуск загрузки, а затем читает физические версии через явную поверхность
|
||||
текущего состояния.
|
||||
|
||||
## Проблема
|
||||
|
||||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
||||
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
||||
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
||||
транспортной границе и могла заменить день неполным набором строк.
|
||||
|
||||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||||
|
||||
## Цели
|
||||
|
||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
||||
`snapshot_date`;
|
||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||
|
||||
## Не входит
|
||||
|
||||
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
||||
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
||||
- удаление заказа по отсутствию в следующем слепке;
|
||||
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
||||
|
||||
## Поток данных
|
||||
|
||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||
записывает полученную порцию в `stg.orders_raw`. Все строки получают `_load_id`,
|
||||
равный `run_id` Airflow. `_load_ts` вычисляется при этой записи и дальше
|
||||
переносится без пересчёта.
|
||||
|
||||
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
||||
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
||||
пишет годные строки в `ods.order_snapshot`, второй — брак в
|
||||
`ods.order_snapshot_errors`. Транзакции между запросами нет. При частичном сбое
|
||||
Airflow повторяет весь `task_id`; одинаковые исходные строки и служебные метки
|
||||
не вычисляются заново.
|
||||
|
||||
Условия запросов взаимодополняющие: один общий предикат определяет брак, а
|
||||
годная ветвь использует его буквальное отрицание. Все функции предиката
|
||||
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
||||
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
||||
этого не нужен.
|
||||
|
||||
## Граница строгого приёма
|
||||
|
||||
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
||||
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).
|
||||
|
||||
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
||||
скалярных значений непосредственно образуют типизированную строку заказа. У
|
||||
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||
|
||||
Класс брака выбирается первым совпадением:
|
||||
|
||||
1. `not_an_object`;
|
||||
2. `keyset_mismatch`;
|
||||
3. `field_invalid`.
|
||||
|
||||
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
||||
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
||||
без постоянной детализации предиката.
|
||||
|
||||
## Роль ODS
|
||||
|
||||
`ods.order_snapshot_rep` хранит физически принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
||||
неизменного `created_at`. `ods.order_snapshot_dist` шардирует по
|
||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||
|
||||
Четыре координаты отвечают на разные вопросы:
|
||||
|
||||
- `updated_at` — какая бизнес-версия заказа новее;
|
||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||
- `_load_id` — какой запуск Airflow принял строку;
|
||||
- `_load_ts` — когда строка приехала в хранилище.
|
||||
|
||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
||||
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
||||
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
||||
заменить, не меняя потребителей.
|
||||
|
||||
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
||||
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
||||
какие сущности, связи и производные признаки ему нужны. Нужен ли `_load_id`
|
||||
выше ODS, решается вместе с DDS, а не здесь.
|
||||
|
||||
## Одно чтение Kafka
|
||||
|
||||
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
||||
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
||||
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
||||
|
||||
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
||||
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
||||
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
||||
или публикация неполного дня.
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
- Партиционная идемпотентность — `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-координаты показывают повтор задачи.
|
||||
|
||||
## Что проверено
|
||||
|
||||
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),
|
||||
поведение чтения — с исходниками той же версии
|
||||
[`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).
|
||||
|
||||
## Связанные решения
|
||||
|
||||
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||||
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||||
слепка версионным ODS.
|
||||
- Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Что хранит опись
|
||||
|
||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||
и [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
|
||||
> Опись хранит только то, чего движение мира не меняет.
|
||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||
|
||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||
байты») опись начинает сторожить.
|
||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
|
||||
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
|
||||
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
|
||||
последний отправленный слепок несёт день N − 2). Читатель у них придёт
|
||||
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
|
||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
|
||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||
приёма заказов — число заведётся вместе с ней.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Классы расхождений и опоздание
|
||||
|
||||
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
|
||||
Классы и ориентиры долей — [мастер-спека,
|
||||
раздел 4](../../specs/2026-07-30-stand-v2-realism.md); здесь — механика
|
||||
каждого.
|
||||
|
||||
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
|
||||
урезанным во всех своих слепках: первый слепок снимается на границе суток,
|
||||
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
|
||||
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
|
||||
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
|
||||
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
|
||||
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
|
||||
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
|
||||
ровно тем навыком, ради которого позиции разбираются; история рассказывается
|
||||
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
|
||||
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
|
||||
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* —
|
||||
заказ у нас проекция той же корзины.
|
||||
|
||||
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
|
||||
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
|
||||
сохранить другой — обычное дело. Единственный вариант, при котором потеря
|
||||
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
|
||||
|
||||
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
|
||||
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
||||
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
||||
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
||||
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
||||
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
||||
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
||||
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||
потеря перестаёт быть видна со стороны трекера.
|
||||
|
||||
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
|
||||
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
|
||||
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
|
||||
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
|
||||
не расхождение в данных; менти различить не может.
|
||||
|
||||
**Опоздание — заказ прячется от ранних слепков.** `created_at` не
|
||||
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
|
||||
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
|
||||
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
|
||||
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
|
||||
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
|
||||
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
|
||||
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
|
||||
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
|
||||
день создания строки разошёлся бы с днём покупки, чьё равенство держит
|
||||
синхронная модель ([откуда берётся заказ](projection.md)), заказ уехал бы в
|
||||
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
|
||||
сломалась бы та самая сверка, ради которой всё строится.
|
||||
|
||||
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||
решается только у выживших покупок, а назначенное планом не теряется и не
|
||||
опаздывает — дельта и отмена ему разрешены, моста они не рвут.
|
||||
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
|
||||
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
|
||||
однопозиционных заказов больше половины). Отклонено: *один класс на заказ* —
|
||||
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Откуда берётся заказ
|
||||
|
||||
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||||
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
|
||||
|
||||
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
|
||||
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
|
||||
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
|
||||
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
|
||||
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
|
||||
согласованность двух источников не удерживается, а получается по построению.
|
||||
|
||||
Следствия, которые уже решены соседями и здесь только связываются:
|
||||
|
||||
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
|
||||
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||
выбрасыванием события после присвоения номера
|
||||
([классы расхождений](mismatch.md));
|
||||
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
|
||||
читаемый номер «день и порядковый номер покупки» ([спека
|
||||
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
|
||||
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||
|
||||
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
||||
поэтому день рождения заказа и день создания строки совпадают.
|
||||
|
||||
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
||||
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
||||
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
|
||||
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
|
||||
исчезает вместе с уроком «две версии правды».
|
||||
@@ -0,0 +1,62 @@
|
||||
# Слепок и его доставка
|
||||
|
||||
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||||
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
|
||||
|
||||
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
|
||||
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
|
||||
видны глазами как два производителя, каждый со своим топиком. Один прогон с
|
||||
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
|
||||
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
|
||||
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ* —
|
||||
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
|
||||
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||
топиком `hits`* — убивает два режима приёма.
|
||||
|
||||
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
||||
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
||||
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
||||
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
||||
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
||||
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
||||
|
||||
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
|
||||
момента отправки не зависит. Следствия:
|
||||
|
||||
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
|
||||
живого дня отправит следующий прогон;
|
||||
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
|
||||
отдельного механизма самовосстановления нет;
|
||||
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
|
||||
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
|
||||
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
|
||||
пересекаются, проигрышей всегда восемь).
|
||||
|
||||
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
|
||||
слепок — дня 0 — уезжает прогоном дня 1 ([исследование
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||
|
||||
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
|
||||
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого
|
||||
заказа обязан читать из его собственного дня. Вся судьба решается при
|
||||
рождении, поэтому слепок любого дня — чтение готовой судьбы, а не накопление
|
||||
состояния. Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа
|
||||
и правка торгового поведения стали бы одним рычагом; *бросать состояние в
|
||||
подпотоке дня слепка* — траектория заказа зависела бы от того, какие слепки
|
||||
снимали.
|
||||
|
||||
## Запись на проводе
|
||||
|
||||
Контракт провода — на заказ один JSON-документ: деньги строками с двумя
|
||||
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом —
|
||||
целиком описан мастер-спекой (раздел 2); основания, отклонённые варианты и
|
||||
проверка разбора — в [исследовании
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md) (резолюция
|
||||
развилки
|
||||
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
|
||||
|
||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Заказы в стартовом мире
|
||||
|
||||
Резолюция развилки [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8.
|
||||
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||
выгрузку магазина; разные темпы дают всё остальное.
|
||||
|
||||
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`;
|
||||
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два
|
||||
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом
|
||||
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и
|
||||
уедет первым же ходом мира. Особого режима у стартового мира нет: правило
|
||||
отправки живёт в одном месте — в проигрывателе; генератору это решение не
|
||||
стоит ничего.
|
||||
|
||||
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
|
||||
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
|
||||
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
|
||||
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
|
||||
менти — за витринами этапа 4.
|
||||
|
||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||
`dds.identity_map` нет. Опись пар не считает
|
||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||
перестал бы быть чистой функцией зерна.
|
||||
@@ -173,7 +173,7 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||
публикации. Полное решение — в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||
[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md).
|
||||
[спецификации приёма заказов](orders/ingestion.md).
|
||||
|
||||
## Часовые пояса
|
||||
|
||||
@@ -391,7 +391,7 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
||||
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||
[спецификации заказов](../specs/2026-08-16-order-ingestion.md).
|
||||
[спецификации заказов](orders/ingestion.md).
|
||||
|
||||
## Раскладка DDL
|
||||
|
||||
|
||||
Reference in New Issue
Block a user