Compare commits
2
Commits
7e5e05ed47
...
0d9a83d6af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9a83d6af | ||
|
|
b9f570fc40 |
@@ -158,4 +158,7 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
||||||
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
||||||
ответственности: не событие истории и не решение, а текущее устройство —
|
ответственности: не событие истории и не решение, а текущее устройство —
|
||||||
один файл на зону, правится по мере постройки.
|
один файл на зону, правится по мере постройки. Крупный компонент живёт
|
||||||
|
подпапкой: индекс `README.md` и файл на каждую часть устройства
|
||||||
|
(`architecture/orders/`), имена по тому же правилу
|
||||||
|
([ADR 0011](docs/adr/0011-component-docs.md)).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||||
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||||
`dds.order` с `argMax`. Действующая форма STG → ODS описана в
|
`dds.order` с `argMax`. Действующая форма STG → ODS описана в
|
||||||
[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md).
|
[спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
@@ -17,10 +17,11 @@
|
|||||||
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
||||||
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
|
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
|
||||||
день генератором, поэтому переливается ровно то, что он положил в топик.
|
день генератором, поэтому переливается ровно то, что он положил в топик.
|
||||||
Уточнение со сдвигом отправки, решённым позже (#71, [спека
|
Уточнение со сдвигом отправки, решённым позже (#71, [слепок и его
|
||||||
заказов](../specs/2026-08-16-orders.md), раздел 2): даг, играющий день D,
|
доставка](../architecture/orders/snapshot.md)): даг, играющий день D, в
|
||||||
кладёт и забирает слепок дня D−1 — по-прежнему ровно то, что сам положил, но
|
штатном прогоне кладёт и забирает слепок дня D−1 — слепок предыдущего дня, а
|
||||||
это слепок предыдущего дня, а не сыгранного.
|
не сыгранного. После падения между шагами в топике может ждать и хвост
|
||||||
|
прежних слепков; забор принимает всё приехавшее.
|
||||||
|
|
||||||
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
||||||
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
||||||
|
|||||||
@@ -34,4 +34,4 @@
|
|||||||
Малый объём позволяет оставить одно чтение на запуск как проверяемое
|
Малый объём позволяет оставить одно чтение на запуск как проверяемое
|
||||||
эксплуатационное допущение, а не границу полноты. Полный контракт разбора,
|
эксплуатационное допущение, а не границу полноты. Полный контракт разбора,
|
||||||
граница брака и поведение повторов заданы в
|
граница брака и поведение повторов заданы в
|
||||||
[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md).
|
[спецификации приёма заказов](../architecture/orders/ingestion.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, мастер-спеки, спеки генератора, исследования
|
||||||
|
формата и доки хранилища перенацелены на набор.
|
||||||
@@ -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
|
||||||
|
человека* — магазин оказался бы замаскированным продолжением трекера;
|
||||||
|
*структурная координата, хеш или глобальная последовательность* — больше
|
||||||
|
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
|
||||||
@@ -63,7 +63,7 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
|||||||
обязательными миллисекундами, дата слепка — строкой `YYYY-MM-DD`. `items`
|
обязательными миллисекундами, дата слепка — строкой `YYYY-MM-DD`. `items`
|
||||||
проверяется только как JSON-массив. Каноническая форма и основания выбора
|
проверяется только как 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`
|
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
|
||||||
проверено, что прямой `SELECT` Kafka Engine завершается после одной порции, а
|
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
|
||||||
`FINAL` через `Distributed` исполняется на таблицах шардов. Поэтому версии
|
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
|
||||||
одного `order_id` направляются на один шард. Фоновое схлопывание
|
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
|
||||||
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
||||||
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/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) сохраняет выбор пакетного забора,
|
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||||||
`RawBLOB`, одного чтеца и одной партиции топика.
|
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||||||
- [ADR 0010](../adr/0010-order-versions-in-ods.md) заменяет публикацию слепка
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||||||
версионным ODS.
|
слепка версионным ODS.
|
||||||
- Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85.
|
- Вопрос `_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` в нём остаётся датой наблюдения строки, а не ключом
|
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||||
публикации. Полное решение — в
|
публикации. Полное решение — в
|
||||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
[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`,
|
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||||
[спецификации заказов](../specs/2026-08-16-order-ingestion.md).
|
[спецификации заказов](orders/ingestion.md).
|
||||||
|
|
||||||
## Раскладка DDL
|
## Раскладка DDL
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Debezium проводит ту же границу внутри одного с
|
|||||||
|
|
||||||
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
||||||
`toDate(created_at)` в
|
`toDate(created_at)` в
|
||||||
[спецификации приёма заказов](../specs/2026-08-16-order-ingestion.md)
|
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
||||||
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
||||||
это день создания строки
|
это день создания строки
|
||||||
источника, а не доказательство дня бизнес-события.
|
источника, а не доказательство дня бизнес-события.
|
||||||
@@ -209,4 +209,4 @@ KISS-вариант для [существующего модуля сериал
|
|||||||
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
|
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
|
||||||
проверяет ту же каноническую форму и считает остальные формы браком; граница
|
проверяет ту же каноническую форму и считает остальные формы браком; граница
|
||||||
строгого приёма зафиксирована в
|
строгого приёма зафиксирована в
|
||||||
[спецификации заказов](../specs/2026-08-16-order-ingestion.md).
|
[спецификации заказов](../architecture/orders/ingestion.md).
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
||||||
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
||||||
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
||||||
`created`» не бывает ([спека заказов](2026-08-16-orders.md), раздел 3).
|
`created`» не бывает ([судьба заказа](../architecture/orders/fate.md)).
|
||||||
|
|
||||||
## 3. Каталог товаров
|
## 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)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
|
|
||||||
@@ -465,8 +465,9 @@ Kafka день переигрывается генератором заново:
|
|||||||
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
||||||
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
||||||
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
||||||
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
|
`match` — большинство строк; `amount_delta` — остаток, не объяснённый
|
||||||
после приведения к сравнимой базе; его создаёт генератор намеренно
|
приведением к сравнимой базе: сравнением позиций он объясняется, и на этом
|
||||||
|
стоит урок класса C; создаёт его генератор намеренно
|
||||||
(~1–2% заказов, см. раздел 4).
|
(~1–2% заказов, см. раздел 4).
|
||||||
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
||||||
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
||||||
@@ -512,7 +513,7 @@ Kafka день переигрывается генератором заново:
|
|||||||
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||||
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||||
названного читателя в опись не кладётся
|
названного читателя в опись не кладётся
|
||||||
([спека заказов](2026-08-16-orders.md), раздел 5).
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||||||
при пересборке.
|
при пересборке.
|
||||||
|
|||||||
@@ -199,8 +199,8 @@
|
|||||||
(уточнение при исполнении #38, 2026-08-02);
|
(уточнение при исполнении #38, 2026-08-02);
|
||||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||||
торговые события, заказная и событийная стороны (судьба заказов и порча
|
торговые события, заказная и событийная стороны (судьба заказов и порча
|
||||||
событийного потока) — в фиксированном порядке (уточнение [спекой
|
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
||||||
заказов](2026-08-16-orders.md), раздел 3: прежние «расхождения» и
|
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||||
«опоздания» были названы по классам витрины, а компонент называет часть
|
«опоздания» были названы по классам витрины, а компонент называет часть
|
||||||
мира).
|
мира).
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
По построению: параллельный прогон равен последовательному; продление
|
||||||
|
|||||||
@@ -1,546 +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` (раздел 6).
|
|
||||||
|
|
||||||
Отклонено: *выводить заказ разбором собственного вывода* (`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`* — заказ
|
|
||||||
уехал бы в чужую партицию, и «выручка дня 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 мастер-спеки. Тот же даг проигрывает
|
|
||||||
модельный день и забирает слепок; со сдвигом отправки он переливает слепок
|
|
||||||
предыдущего дня — ровно тот, который сам и положил в топик.
|
|
||||||
|
|
||||||
## 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, к
|
|
||||||
механике заказов не привязан.
|
|
||||||
Reference in New Issue
Block a user