diff --git a/AGENTS.md b/AGENTS.md index b812a73..5e9f478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,4 +158,7 @@ Airflow) и названия из кода. Если для понятия ес - Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через дефис (`storage.md`). Здесь живут рабочие справочники по зонам ответственности: не событие истории и не решение, а текущее устройство — - один файл на зону, правится по мере постройки. + один файл на зону, правится по мере постройки. Крупный компонент живёт + подпапкой: индекс `README.md` и файл на каждую часть устройства + (`architecture/orders/`), имена по тому же правилу + ([ADR 0011](docs/adr/0011-component-docs.md)). diff --git a/docs/adr/0008-order-ingestion.md b/docs/adr/0008-order-ingestion.md index 20c1fce..2f960bc 100644 --- a/docs/adr/0008-order-ingestion.md +++ b/docs/adr/0008-order-ingestion.md @@ -7,7 +7,7 @@ партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема `dds.order` с `argMax`. Действующая форма STG → ODS описана в -[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md). +[спецификации приёма заказов](../architecture/orders/ingestion.md). ## Решение @@ -17,8 +17,8 @@ прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный день генератором, поэтому переливается ровно то, что он положил в топик. -Уточнение со сдвигом отправки, решённым позже (#71, [спека -заказов](../specs/2026-08-16-orders.md), раздел 2): даг, играющий день D, в +Уточнение со сдвигом отправки, решённым позже (#71, [слепок и его +доставка](../architecture/orders/snapshot.md)): даг, играющий день D, в штатном прогоне кладёт и забирает слепок дня D−1 — слепок предыдущего дня, а не сыгранного. После падения между шагами в топике может ждать и хвост прежних слепков; забор принимает всё приехавшее. diff --git a/docs/adr/0010-order-versions-in-ods.md b/docs/adr/0010-order-versions-in-ods.md index 6668cae..86d6b70 100644 --- a/docs/adr/0010-order-versions-in-ods.md +++ b/docs/adr/0010-order-versions-in-ods.md @@ -34,4 +34,4 @@ Малый объём позволяет оставить одно чтение на запуск как проверяемое эксплуатационное допущение, а не границу полноты. Полный контракт разбора, граница брака и поведение повторов заданы в -[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md). +[спецификации приёма заказов](../architecture/orders/ingestion.md). diff --git a/docs/adr/0011-component-docs.md b/docs/adr/0011-component-docs.md new file mode 100644 index 0000000..bc08c7f --- /dev/null +++ b/docs/adr/0011-component-docs.md @@ -0,0 +1,42 @@ +# ADR 0011. Устройство компонента — связный набор живых документов + +Дата: 16 августа 2026 года. Статус: принято. + +## Решение + +Детальное устройство крупного компонента живёт в `docs/architecture/` +подпапкой: индекс `README.md` — целевая картина и указатели — и отдельный +файл на каждую часть устройства. Имена — слаги без дат; набор правится по +мере постройки, как и остальные справочники этой папки. + +Первый такой набор — [заказы бэкенда](../architecture/orders/README.md), +собранный из спеки этапа 3 и спеки приёма. Датированные файлы +`2026-08-16-orders.md` и `2026-08-16-order-ingestion.md` удалены, ссылки на +них перенацелены; историю держит git. + +## Почему + +**Спека и базовая документация — разные жанры, а файл был один.** Спека — +событие: проект изменения с датой в имени, замерзающий после приёмки. Но +собранная спека заказов сразу стала и детальным устройством сервиса — тем +документом, по которому этап 3 будут строить и с которым потом сверяться. +Живому устройству дата в имени врёт, а замерзать ему нельзя. + +**Один большой документ плохо читается обоими читателями.** Агент, строящий +судьбу заказа, вынужден везти в контексте формат провода и приём; человек +листает пятьсот строк ради одного раздела. Индекс с файлами по частям даёт +обоим одно и то же: загружается только нужная часть, а карта целого — один +экран. + +**Долговечно только то, что в git.** Резолюции развилок живут в трекере, а +трекер долговечным хранилищем не считается. Поэтому решения — вместе с +отклонёнными вариантами при каждом правиле — лежат в файлах набора; ссылки +на тикеты остаются вежливостью, не записью. + +## Следствия + +- `docs/specs/` остаётся событиям: мастер-спека и спека генератора живут как + есть; переводить ли их в живую форму — отдельное решение, когда встанет. +- Раздел «Структура» в AGENTS.md обновлён тем же коммитом. +- Ссылки из ADR 0008 и 0010, мастер-спеки, спеки генератора, исследования + формата и доки хранилища перенацелены на набор. diff --git a/docs/architecture/orders/README.md b/docs/architecture/orders/README.md new file mode 100644 index 0000000..7f3f50f --- /dev/null +++ b/docs/architecture/orders/README.md @@ -0,0 +1,75 @@ +# Заказы бэкенда + +Устройство второго источника стенда: раз в модельный день бэкенд магазина +выгружает полный слепок заказов окна изменяемости, и деньги в витринах +считаются по нему, а не по трекеру. Набор собран картой #69 (этап 3) и +правится по мере постройки. + +Границы уже решены мастер-спекой [«Боевой реализм стенда +(v2)»](../../specs/2026-07-30-stand-v2-realism.md): поля слепка, окно K = 7 +как константа мира, три статуса, приоритет классов расхождений, правило +«поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед +которой отвечает каждое решение, — «Чем меряется генератор» в [спеке +генератора](../../specs/2026-08-01-generator.md): конструкция внутри +оправдана только наблюдаемым эффектом на выходе. + +## Целевая картина одним взглядом + +- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже + посчитала корзину, цены, купон и номер заказа; заказная половина навешивает + судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке — + [откуда берётся заказ](snapshot.md). +- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых + в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий + прогон — ночная выгрузка бэкенда за вчера; на проводе — один JSON-документ + на заказ — [слепок и его доставка](snapshot.md). +- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо + не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` — + [судьба заказа](fate.md). +- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — + заказная и событийная стороны; броски независимы, пересечения выходят + арифметикой, приоритет классов работает по-настоящему — + [классы расхождений и опоздание](fate.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). diff --git a/docs/architecture/orders/code-rules.md b/docs/architecture/orders/code-rules.md new file mode 100644 index 0000000..f92ac95 --- /dev/null +++ b/docs/architecture/orders/code-rules.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` наравне с прочими). diff --git a/docs/architecture/orders/fate.md b/docs/architecture/orders/fate.md new file mode 100644 index 0000000..c5534b5 --- /dev/null +++ b/docs/architecture/orders/fate.md @@ -0,0 +1,140 @@ +# Судьба заказа и расхождения + +Резолюция развилки [«Расхождения 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, +он в резерве. + +## Классы расхождений и опоздание + +Классы и ориентиры долей — [мастер-спека, +раздел 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`* — подделка аудита источника: +день создания строки разошёлся бы с днём покупки, чьё равенство держит +синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в +чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D — +сломалась бы та самая сверка, ради которой всё строится. + +**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет +мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль +решается только у выживших покупок, а назначенное планом не теряется и не +опаздывает — дельта и отмена ему разрешены, моста они не рвут. +Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа +(часть заказов забирают победители по приоритету, часть у класса C недоступна — +однопозиционных заказов больше половины). Отклонено: *один класс на заказ* — +приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую. diff --git a/docs/architecture/orders/identity.md b/docs/architecture/orders/identity.md new file mode 100644 index 0000000..b56f24a --- /dev/null +++ b/docs/architecture/orders/identity.md @@ -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 +человека* — магазин оказался бы замаскированным продолжением трекера; +*структурная координата, хеш или глобальная последовательность* — больше +механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает. diff --git a/docs/specs/2026-08-16-order-ingestion.md b/docs/architecture/orders/ingestion.md similarity index 93% rename from docs/specs/2026-08-16-order-ingestion.md rename to docs/architecture/orders/ingestion.md index 265a43a..72795c2 100644 --- a/docs/specs/2026-08-16-order-ingestion.md +++ b/docs/architecture/orders/ingestion.md @@ -63,7 +63,7 @@ JSON-объектом с точным набором ключей: `order_id`, ` обязательными миллисекундами, дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только как JSON-массив. Каноническая форма и основания выбора зафиксированы в -[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md). +[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md). Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять скалярных значений непосредственно образуют типизированную строку заказа. У @@ -151,10 +151,10 @@ JSON-объектом с точным набором ключей: `order_id`, ` ## Что проверено -MCP Context7 в этой сессии недоступен. На локальном ClickHouse `26.3.17.56` -проверено, что прямой `SELECT` Kafka Engine завершается после одной порции, а -`FINAL` через `Distributed` исполняется на таблицах шардов. Поэтому версии -одного `order_id` направляются на один шард. Фоновое схлопывание +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), поведение чтения — с исходниками той же версии @@ -163,8 +163,8 @@ MCP Context7 в этой сессии недоступен. На локальн ## Связанные решения -- [ADR 0008](../adr/0008-order-ingestion.md) сохраняет выбор пакетного забора, - `RawBLOB`, одного чтеца и одной партиции топика. -- [ADR 0010](../adr/0010-order-versions-in-ods.md) заменяет публикацию слепка - версионным ODS. +- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного + забора, `RawBLOB`, одного чтеца и одной партиции топика. +- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию + слепка версионным ODS. - Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85. diff --git a/docs/architecture/orders/inventory.md b/docs/architecture/orders/inventory.md new file mode 100644 index 0000000..e790ee5 --- /dev/null +++ b/docs/architecture/orders/inventory.md @@ -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` — вопрос стенда и его + проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка + приёма заказов — число заведётся вместе с ней. diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md new file mode 100644 index 0000000..d733745 --- /dev/null +++ b/docs/architecture/orders/snapshot.md @@ -0,0 +1,96 @@ +# Заказ и его слепок + +Резолюция развилки [«Генератор слепков: где живёт и чем связан с +событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71). + +## Откуда берётся заказ + +Заказ и событие `purchase` — не два порождения, а две проекции одного факта +мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной +дня-функции; заказная половина берёт заказы дня готовой структурой — вторым +выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В +подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается, +согласованность двух источников не удерживается, а получается по построению. + +Следствия, которые уже решены соседями и здесь только связываются: + +- у всякого заказа изначально ровно одно событие `purchase`; заказ без события + в трекере — не отдельная порода, а класс B, и делает его событийная сторона + выбрасыванием события после присвоения номера + ([классы расхождений](fate.md)); +- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, + читаемый номер «день и порядковый номер покупки» ([спека + генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все + покупки, дошедшие до потока дня, — до всяких потерь; +- скидка заказа выводится из промокода события по таблице «код → скидка» — + числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). + +В модели строка заказа в базе источника создаётся синхронно с покупкой, +поэтому день рождения заказа и день создания строки совпадают. + +Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой +`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что +это разные источники; *независимая модель бэкенда* (заказ первичен, событие — +эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание +всего генератора; *слепок собирает SQL стенда из событий* — второй источник +исчезает вместе с уроком «две версии правды». + +## Слепок и его доставка + +**Запуск.** Третья команда того же пакета — `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`**. Детерминизм даёт +его даром, а хешу слепка в описи нужен именно названный порядок. diff --git a/docs/architecture/orders/start-world.md b/docs/architecture/orders/start-world.md new file mode 100644 index 0000000..b1639b0 --- /dev/null +++ b/docs/architecture/orders/start-world.md @@ -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` пропал бы навсегда; *подогнать план под горизонт* — мир +перестал бы быть чистой функцией зерна. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 8e4bd03..581c7fd 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -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 diff --git a/docs/research/2026-08-16-order-snapshot-wire-format.md b/docs/research/2026-08-16-order-snapshot-wire-format.md index 1b3cfd4..ce2db32 100644 --- a/docs/research/2026-08-16-order-snapshot-wire-format.md +++ b/docs/research/2026-08-16-order-snapshot-wire-format.md @@ -63,7 +63,7 @@ Debezium проводит ту же границу внутри одного с `created_at` и `updated_at` — аудит строки источника. Поэтому `toDate(created_at)` в -[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md) +[спецификации приёма заказов](../architecture/orders/ingestion.md) используется как стабильный технический ключ партиции `ods.order_snapshot`: это день создания строки источника, а не доказательство дня бизнес-события. @@ -209,4 +209,4 @@ KISS-вариант для [существующего модуля сериал не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS проверяет ту же каноническую форму и считает остальные формы браком; граница строгого приёма зафиксирована в -[спецификации заказов](../specs/2026-08-16-order-ingestion.md). +[спецификации заказов](../architecture/orders/ingestion.md). diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index f23b854..ef33d1b 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -235,7 +235,7 @@ Ecommerce (заполнены только у торговых событий): - Статусы держим все три: смена `created` → `paid` и есть причина «дыхания» выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда - `created`» не бывает ([спека заказов](2026-08-16-orders.md), раздел 3). + `created`» не бывает ([судьба заказа](../architecture/orders/fate.md)). ## 3. Каталог товаров @@ -304,7 +304,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate заказу с каждой куки — иначе вторая кука не попадает в карту соответствий (она строится только из покупок) и лаба не воспроизводится. Число таких пар растёт, пока мир едет, и в опись не кладётся - ([спека заказов](2026-08-16-orders.md), раздел 5). + ([что хранит опись](../architecture/orders/inventory.md)). - Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и **«известные пользователи»** (после склейки) — оба числа рядом в дашборде. @@ -513,7 +513,7 @@ Kafka день переигрывается генератором заново: - контрольные числа идентичности не заводятся: uniq известных пользователей и число двухкуковых покупателей растут, пока мир едет, а счётчик без названного читателя в опись не кладётся - ([спека заказов](2026-08-16-orders.md), раздел 5). + ([что хранит опись](../architecture/orders/inventory.md)). Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить при пересборке. diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index d00d6db..c2434bf 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -199,8 +199,8 @@ (уточнение при исполнении #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, заказная и событийная стороны (судьба заказов и порча - событийного потока) — в фиксированном порядке (уточнение [спекой - заказов](2026-08-16-orders.md), раздел 3: прежние «расхождения» и + событийного потока) — в фиксированном порядке (уточнение [судьбой + заказа](../architecture/orders/fate.md): прежние «расхождения» и «опоздания» были названы по классам витрины, а компонент называет часть мира). По построению: параллельный прогон равен последовательному; продление diff --git a/docs/specs/2026-08-16-orders.md b/docs/specs/2026-08-16-orders.md deleted file mode 100644 index 644124a..0000000 --- a/docs/specs/2026-08-16-orders.md +++ /dev/null @@ -1,550 +0,0 @@ -# Заказы бэкенда (этап 3): слепок окна, судьба заказа, мост к склейке - -Статус: черновик — впереди проход на вычитание (#84) и приёмка владельцем (#88). -Дата: 2026-08-16. Мандат — карта #69 (этап 3, родитель #5); собрано тикетом #87. -Развилки: приём (#70), генератор слепков (#71), расхождения и опоздания (#72), -мост к склейке (#73), место в стартовом мире (#74), форма записи на проводе -(#81), брак и версии в ODS (#80). -Источники: мастер-спека [«Боевой реализм стенда -(v2)»](2026-07-30-stand-v2-realism.md) — разделы 2–5 и 7; -[спека генератора](2026-08-01-generator.md); -[ADR 0008](../adr/0008-order-ingestion.md); -[ADR 0010](../adr/0010-order-versions-in-ods.md); -[спецификация приёма заказов](2026-08-16-order-ingestion.md); -[исследование формата слепка](../research/2026-08-16-order-snapshot-wire-format.md). - -## Зачем - -Мастер-спека решила, что у стенда есть второй источник: раз в модельный день -бэкенд магазина выгружает полный слепок заказов окна изменяемости, и деньги в -витринах считаются по нему, а не по трекеру. Как этот источник устроен — кто -порождает заказ, откуда берётся его судьба, как слепок доезжает до ODS и что из -этого видит менти, — она отложила. Карта #69 прошла семь развилок; эта спека -собирает их решения в одну картину. По ней этап 3 режется на тикеты (#89) — -после вычитания (#84) и приёмки владельцем (#88). - -Здесь не переоткрывается решённое мастер-спекой: поля слепка, окно K = 7 как -константа мира, три статуса, приоритет классов расхождений, правило «поведение -и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед которой -отвечает каждое решение, — «Чем меряется генератор» в спеке генератора: -конструкция внутри оправдана только наблюдаемым эффектом на выходе. - -## Целевая картина одним взглядом - -- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже - посчитала корзину, цены, купон и номер заказа; заказная половина навешивает - судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке. -- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых в - дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий прогон — - ночная выгрузка бэкенда за вчера. -- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо не - случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled`. -- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — - заказная и событийная стороны; броски независимы, пересечения выходят - арифметикой, приоритет классов работает по-настоящему. -- **Мост к склейке**: план состава владеет человеком; его непрозрачный - `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным. -- **На проводе** — один JSON-документ на заказ: деньги строками с двумя - знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом. -- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два - `INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot` - принимает версии заказа на `ReplacingMergeTree(updated_at)`. -- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого - дня клики есть, а заказов нет, и график выручки дозаполняется по ходу мира. - -## 1. Откуда берётся заказ - -Резолюция развилки [«Генератор слепков: где живёт и чем связан с -событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71). - -Заказ и событие `purchase` — не два порождения, а две проекции одного факта -мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной -дня-функции; заказная половина берёт заказы дня готовой структурой — вторым -выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В -подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается, -согласованность двух источников не удерживается, а получается по построению. - -Следствия, которые уже решены соседями и здесь только связываются: - -- у всякого заказа изначально ровно одно событие `purchase`; заказ без события - в трекере — не отдельная порода, а класс B, и делает его событийная сторона - выбрасыванием события после присвоения номера (раздел 4); -- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - читаемый номер «день и порядковый номер покупки» (спека генератора, - раздел 9); нумеруются все покупки, дошедшие до потока дня, — до всяких - потерь; -- скидка заказа выводится из промокода события по таблице «код → скидка» — - числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). - -В модели строка заказа в базе источника создаётся синхронно с покупкой, поэтому -день рождения заказа и день создания строки совпадают. Смысл полей от этого не -меняется: `created_at` и `updated_at` — аудит строки по часам базы источника, -бизнес-время покупки живёт в `purchase.UTCEventTime` (раздел 8). - -Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой -`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что -это разные источники; *независимая модель бэкенда* (заказ первичен, событие — -эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание -всего генератора; *слепок собирает SQL стенда из событий* — второй источник -исчезает вместе с уроком «две версии правды». - -## 2. Слепок и его доставка - -Та же резолюция #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 (исследование формата). - -**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню -рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого заказа -обязан читать из его собственного дня. Вся судьба решается при рождении, -поэтому слепок любого дня — чтение готовой судьбы, а не накопление состояния. -Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа и правка -торгового поведения стали бы одним рычагом; *бросать состояние в подпотоке дня -слепка* — траектория заказа зависела бы от того, какие слепки снимали. - -## 3. Судьба заказа - -Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что -обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72), -ревизия после двух холодных ревью. - -**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа, -решённая при его рождении и уложенная в окно K целиком. - -**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, -дельта, опоздание. Событийная сторона — порча событийного потока: потеря и -дубль. Довод за разделение — различимость по описи: правка заказной механики не -двигает хеши событий, правка событийной не двигает байты слепка, и по -покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` -и `LATECOMERS` решения не переживают — они названы по классам витрины, а -компонент называет часть мира; новые стороны занимают те же позиции, имена — при -реализации. Отклонено: *один компонент на всю судьбу* — правка событийной -механики молча меняла бы байты слепка; *компонент на класс* — пять имён под -ручки калибровки, которые крутятся разом. - -**Правило формы, без которого разделение не работает: броски заказной стороны -делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска -становится функцией доли, и правка одной доли перебрасывает весь подпоток после -себя (в генераторе такое место уже есть — `_at_least_one`; здесь оно не -повторяется). Изоляцию даёт форма броска, а не число подпотоков. - -**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и -отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо -`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, -он в резерве. - -## 4. Классы расхождений и опоздание - -Та же резолюция #72. Классы и ориентиры долей — мастер-спека, раздел 4; -здесь — механика каждого. - -**Дельта суммы (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`* — -подделка аудита источника: день создания строки разошёлся бы с днём покупки, -чьё равенство держит синхронная модель (раздел 1), заказ уехал бы в чужую -партицию, и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась -бы та самая сверка, ради которой всё строится. - -**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет -мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль -решается только у выживших покупок, а назначенное планом не теряется и не -опаздывает — дельта и отмена ему разрешены, моста они не рвут. -Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа -(часть заказов забирают победители по приоритету, часть у класса C недоступна — -однопозиционных заказов больше половины). Отклонено: *один класс на заказ* — -приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую. - -## 5. Что хранит опись - -Резолюции #72 и [«Места заказов в стартовом -мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74). - -> Опись хранит только то, чего движение мира не меняет. -> Хеш кладём всегда, счётчик — только когда назван его читатель. - -Хеш — растяжка: он не обещает, что числа те же вечно, он обещает, что их сдвиг -не пройдёт молча, и читает его `git diff`. Счётчик — опора: обещание -конкретного числа тому, кто его прочтёт, и платит за него каждая правка -генератора. - -- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты - слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй - артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же - байты») опись начинает сторожить. Окно изменяемости хешу не мешает: дышат - заказы, а не байты слепка — слепок дня D по-прежнему чистая функция - (зерно, D). -- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по - итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её - словом. Сойтись с запросом менти они могут только на днях с закрытым окном: - при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а - последний отправленный слепок несёт день N − 2). Читатель у них придёт - этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их. -- **Контрольные числа идентичности не заводятся вовсе**: uniq известных - пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них - не выходит; генератор сторожит хеш, транспорт — счёт событий. -- **Опись описывает мир, а не доставку.** Работа генератора кончается на - Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его - проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка - приёма заказов — число заведётся вместе с ней. - -## 6. Мост к склейке: человек, `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 её человека. Значения — ниже 2^53. -Раздельные прогоны ничего не хранят и не согласуют: генератор событий и -команда слепка заново спрашивают один план и получают тот же `person_id`. - -Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных -`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`; -отдельные прогоны одного мира дают то же соответствие; принятый мир -воспроизводит эффект склейки без случайных ложных объединений — конкретные -числа пар контрактом описи не являются. Межкогортные столкновения принимаются -по той же дисциплине, что у случайных `ClientID`: принимается конкретный -канонический мир по внешнему результату. - -Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно -пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* — -состояние между прогонами без нового внешнего эффекта; *первая кука как ID -человека* — магазин оказался бы замаскированным продолжением трекера; -*структурная координата, хеш или глобальная последовательность* — больше -механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает. - -## 7. Заказы в стартовом мире - -Резолюция развилки #74. Стартовый мир — не полный мир, а мир, остановленный на -границе суток 7|8. Генератор играет два источника с разными темпами — поток -Метрики и ночную выгрузку магазина; разные темпы дают всё остальное. - -`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`; -слепки отправляет второй запуск — команда `snapshot` того же диапазона (два -источника — два запуска, раздел 2), и со сдвигом отправки уезжают **слепки -дней 0…6**. Слепок дня 7 снят на границе -суток и уедет первым же ходом мира. Особого режима у стартового мира нет: -правило отправки живёт в одном месте — в проигрывателе; генератору это решение -не стоит ничего. - -Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет; -глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся. -**График выручки заваливается к правому краю** и дозаполняется, пока мир едет. -Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная -выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки -видит целый день `awaiting_order` — норма, а не поломка; как это назвать -менти — за витринами этапа 4. - -Цена принята с открытыми глазами: у 18 из 73 пар стартового мира поздний -назначенный заказ падает на день 7 (замерено на каноническом зерне), и до -первого хода мира этих пар в `dds.identity_map` нет. Опись пар не считает -(раздел 5), поэтому красной проверки из этого не выходит. Отклонено: -*дослать восьмой слепок* — исчезает `awaiting_order` на свежем стенде, в CLI -заводится рычаг, стартовый мир становится особым случаем ровно там, где #71 -его убирал; *счётчик пар учится спрашивать про слепки* — число верно ровно до -первого хода мира; *отменить сдвиг отправки* — `awaiting_order` пропал бы -навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой функцией -зерна. - -Замеры канонического зерна (сняты при резолюции): пар в плане 73, в слепках -стартового мира доезжает 55; на эталонном мире — 192 и 172; дней с закрытым -окном — один на стартовом, семь на эталонном. - -## 8. Запись на проводе - -Резолюция развилки [«Форма записи слепка на -проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81); -основания и проверка разбора — в [исследовании -формата](../research/2026-08-16-order-snapshot-wire-format.md). - -```json -{ - "order_id": "20260603-0001", - "user_id": 42, - "status": "paid", - "created_at": "2026-06-03T14:21:07.123Z", - "updated_at": "2026-06-03T14:24:18.456Z", - "items_total": "1299.90", - "discount": "0.00", - "delivery": "199.00", - "total": "1498.90", - "items": [ - {"sku": "sku-17", "qty": 1, "price": "1299.90"} - ], - "snapshot_date": "2026-06-07" -} -``` - -- **Деньги — строки с ровно двумя знаками после точки**, включая - `items[].price`: сохраняется десятичная запись источника, сумма не проходит - через JSON-число/Float. Разница с кликстримом намеренна — там - `purchaseRevenue` приезжает JSON-числом в `Array(Float64)`, а бэкенд - передаёт деньги строками для точного `Decimal`: расхождение представлений - денег в двух источниках и есть урок. -- **`items` — обычный массив JSON**, не строка с JSON внутри. В ODS колонка - остаётся `String`: приём извлекает сырой фрагмент через `JSONExtractRaw`, - а разбирается массив один раз — на границе ODS → DDS. -- **`created_at` и `updated_at`** — аудит строки по часам базы источника, UTC - с настоящими миллисекундами, одна узкая форма `YYYY-MM-DDTHH:mm:ss.SSSZ`; - `.000` выводится честно. `updated_at` монотонен по версиям одного заказа и - служит версией `ReplacingMergeTree`. Бизнес-время покупки остаётся в - `purchase.UTCEventTime`; нового `ordered_at` не добавляется — потребителя - нет. -- **`snapshot_date`** — дата завершившегося модельного дня, снятая на - исходящей границе суток; одна на всю выгрузку, не глобальная константа и не - дата отправки. Константы мира — D0, пояс модельного календаря и окно K = 7. -- **Порядок ключей** — порядок полей контракта; у позиции — `sku`, `qty`, - `price`: воспроизводимые байты без сортировки ключей. Порядок строк внутри - слепка — порядок рождения заказов, он же возрастание `order_id`: детерминизм - даёт его даром, а хешу слепка в описи нужен именно названный порядок. -- **Сериализация** — явная функция рядом с `events(...)` в существующем - `serialize.py`: один словарь с вложенным списком и один `orjson.dumps` на - запись. Классы кодеков, реестр схем и отдельный `dumps` для `items` не - заводятся. - -Отклонено: *JSON-числа для денег* — теряют десятичную форму и провоцируют путь -через Float; *строка с JSON в `items`* — двойная сериализация и второй разбор; -*секундная точность* — отбрасывает точность аудита источника; *глобальная -`SNAPSHOT_DATE`* — смешивает правило календаря с результатом его вычисления. - -## 9. Приём: от топика до ODS - -Резолюции развилок [«Приём заказов: нужен ли слепку слой -сырья»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/70) -и [«Брак в слепке: куда уходит и по каким -классам»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/80). -Источник истины здесь — [ADR 0008](../adr/0008-order-ingestion.md), -[ADR 0010](../adr/0010-order-versions-in-ods.md) и [спецификация -приёма](2026-08-16-order-ingestion.md); ниже — картина одним абзацем на слой. - -Топик `orders` — одна партиция; чтец байтовый (`RawBLOB`), объявлен только на -`clickhouse-01` и без `ON CLUSTER`, матвью к нему не привязана. Сырьё забирает -пакетный шаг под управлением Airflow: одно прямое чтение Kafka вставляет -порцию в `stg.orders_raw` с `_load_id` = `run_id` запуска. Один следующий -`task_id` двумя взаимодополняющими `INSERT SELECT` читает неизменный срез STG: -годные строки — в `ods.order_snapshot`, брак — в `ods.order_snapshot_errors` с -классом `not_an_object` / `keyset_mismatch` / `field_invalid`. Строгий приём -проверяет только контракт провода — 11 верхних ключей и канонические формы; -позиции и бизнес-инварианты остаются ниже границы. - -`ods.order_snapshot` принимает **версии заказа** на -`ReplacingMergeTree(updated_at)`: ключ — `order_id`, партиция — день -неизменного `created_at`, шардирование по `cityHash64(order_id)`. -`snapshot_date` — происхождение строки, а не ключ публикации: прямое чтение -Kafka не задаёт границы полного слепка, поэтому прежняя замена партиции -(ADR 0008) отменена ADR 0010. Точное текущее состояние читается через -`ods.order_v`; нужен ли при сборке DDS `FINAL`, `argMax` или инкрементальный -приём — решается вместе с DDS. - -Стенд получает два режима приёма рядом — поток против слепка, push против -pull, — и это опорная точка раздела 12 мастер-спеки. Тот же даг проигрывает -модельный день и забирает слепок; в штатном прогоне это слепок предыдущего -дня, который он сам и положил в топик. После падения между отправкой и -забором в топике может ждать и хвост прежних слепков — забор принимает всё -приехавшее, версии в ODS это поглощают (спека приёма). - -## 10. Правила кода этапа 3 - -Хвосты резолюций, обязательные для реализации: - -- **Броски заказной стороны — на полную длину дня**, а не на отобранных - заказах (правило формы, раздел 3); моментов всегда два. -- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы - целых весов, никаких плавающих распределений; деньги — в целых копейках. -- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения - заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок - когорты. -- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом - (исследование формата). -- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`, - прямых `json.dumps` по коду нет. -- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими). -- Спорные API (DDL ClickHouse, операторы Airflow) перед кодом сверять через - MCP Context7 — правило AGENTS.md. - -## 11. Расхождения с принятым - -Внесены тем же коммитом, что и эта спека: - -- **Мастер-спека, раздел 2**: цепочка статусов допускает прямой переход - `created → cancelled`; на выходе из окна заказ либо `paid`, либо - `cancelled` (#72). -- **Мастер-спека, раздел 4**: у класса C названа механика — вычеркнутая - позиция; ориентир «~5%» читается как доля отменённых вообще; - «`amount_delta` — необъяснённый остаток» читать как «не объяснённый - приведением к сравнимой базе» — сравнением позиций он объясняется, на этом - стоит урок класса C; счётчики описи — наблюдаемых классов, после приоритета, - в заказах (#72). -- **Мастер-спека, раздел 5**: «опись хранит число именно таких пар» снято — - число растёт, пока мир едет; ядро лабы — неравенство - `uniq(посетителей) > uniq(людей)` — верно в любой день (#74). -- **Мастер-спека, раздел 8**: опись получает строку на слепок с хешем байтов; - контрольные числа идентичности не заводятся; «заказы и выручка по дням» - читать как «по дням с закрытым окном» (#72, #74). -- **Спека генератора, раздел 2**: в перечислении компонентов дня расхождения - и опоздания уступают место заказной и событийной сторонам (#72). -- **ADR 0008**: фраза «переливается ровно то, что он положил в топик» - уточнена — со сдвигом отправки даг переливает слепок предыдущего дня (#71). -- **CONTEXT.md**: новый термин «судьба заказа» (#72); термины «человек» / - «посетитель» / «пользователь магазина», «дата слепка», «время аудита - источника» и поправки к «Слепку» и «Описи мира» внесены ранее, при закрытии - #72–#81. - -Ранее внесённые той же картой изменения (уже в main): раздел 12 мастер-спеки -переписан на «поток против слепка», сенсор дневного батча снят, первый даг -переехал на этап 3 (#70); разделы 2 и 7 мастер-спеки — формат провода и -версионный ODS (#81, #80). - -## 12. Решается при нарезке и позже - -- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто - не бросает, переименование ничего не сдвигает (#72). -- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания, - доли классов, стоимость доставки — калибровка при реализации; финальная - фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки - потребуют только числа, не устройство. -- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение» - и опыты из «Рисков и проверки» спецификации приёма — тикет реализации - приёма. -- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85). -- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) — - остаётся в тумане карты #69; естественное место разговора — этап 4. -- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к - механике заказов не привязан.