diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md new file mode 100644 index 0000000..519383a --- /dev/null +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -0,0 +1,555 @@ +# Боевой реализм стенда (v2): широкое событие, заказы, кластер, анонимность + +Статус: Draft — на приёмку владельцу. +Дата: 2026-07-30. Тикет: #17 (сборка карты #10). +Источники: резолюции #18 (модель данных), #15 (`purchase` и заказы), +#14 (кластер), #13 (цена кластера), #16 (анонимность и склейка); исследование +[формата кликстрима Яндекса](../research/2026-07-26-yandex-clickstream-format.md); +[docs/generator-realism.md](../generator-realism.md). + +## Зачем + +Менти должен узнавать в стенде тот кликстрим и тот дата-контур, с которыми +столкнётся на работе. Сейчас стенд упрощён в четырёх местах: событие разрезано +на четыре топика, есть только просмотры страниц (нет денег), весь трафик +идентифицирован по email, ClickHouse — одна нода. Карта #10 приняла четыре +решения, которые эти упрощения снимают. Эта спека собирает их в одну целевую +картину и оценивает объём исполнения. + +Исполнение — **новый репозиторий**, не переработка этого (решение карты #10 +от 2026-07-23): v1 замораживается как стабильный стенд для менти, v2 стартует +пустым с осознанным первым коммитом — переносим только нужное, генератор +переписывается, переиспользуются идеи. Рабочее имя — `clickstream-data-platform` +(финальное закрепление — при решении тикета о нише). Карта и открытые тикеты +переедут туда после создания. Предусловие — задача «Редизайн пути менти» +(#9) — выполнено, задача закрыта. + +## Целевая картина одним взглядом + +- **Одно широкое событие** по образцу Яндекс Метрики: плоское ядро, + параллельные массивы, сырое поле `ecommerce`. Таксономия `EventType`: + `pageview`, `add_to_cart`, `purchase`. Четыре топика уходят. +- **Второй источник — заказы бэкенда**: та же Kafka, но ежедневный полный + слепок окна изменяемости, со статусами и JSON-позициями. Одна труба, + два режима. +- **Каталог товаров** — словарь ClickHouse из CSV в репозитории. +- **Сверка** клиентского `purchase` против заказа: четыре конструируемых + расхождения плюс опоздание. Деньги в витринах — только по бэкенду. +- **Кликстрим анонимный**: у события только `ClientID` (кука). Склейка + идентичностей — через мост `purchase`↔заказ; часть покупателей — с двух кук. +- **Кластер единственным режимом**: 2 шарда × 1 реплика + clickhouse-keeper, + `make up` поднимает сразу кластер. Superset — на ноду 2. + +## 1. Широкое событие кликстрима + +Форма — хит Метрики из облачной выгрузки: одно событие = одна строка, +многозначное — в параллельных массивах одной длины, плюс одно сырое +JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS. + +### 1.1 Решения по именам и типам + +- **Имена колонок — как в облачной выгрузке Метрики** (`ClientID`, + `UTCEventTime`, `purchaseID`…). Сырой слой хранит имена источника; свои + snake_case-имена появляются в DDS/DM. Это учебный пункт: у каждого + источника — свой стиль, нормализует его склад, а не трекер. +- **Идентификаторы — числовые UInt64** (`WatchID`, `VisitID`, `ClientID`), + UUID уходят. Исследование советовало UUID не трогать, но тот совет исходил + из цены переделки текущего генератора; v2 пишет генератор заново, цена + нулевая, а числовые id — самая узнаваемая черта формата Метрики. Генератор + держит значения id ниже 2^53: выше этой границы double-числа в JSON (jq, + консоль браузера) искажают id при округлении. Настоящая Метрика так не + делает — её id длиннее. +- **Убираем наши выдумки**: `geo_latitude`, `geo_longitude` — координат в + выгрузке Метрики нет (гео — регион и его числовой id). `browser_user_agent` + тоже не берём, но это наш выбор, а не запрет источника: исследование + запрещало только выдавать это поле за формат Яндекса, оставить разрешало. + Разбор строки user agent — не урок этого стенда. +- **Механику версий записи (`Sign`/`HitVersion`) не берём сейчас**: по #15 это + кандидат на потом, и её дом — клиентская сторона (поток визитов Метрики + Про). Это помеченное отступление от рекомендации исследования («`Sign` на + хитах полезен»): колонка без механики — мёртвый вес. +- **Не берём** `ClientEventTime` (в выгрузке Метрики нет клиентской метки; + расхождение часов — тема тумана «грязь»), `Params` (второй сырой JSON не + нужен: этот навык уже несут заказы), `LastSearchEngineRoot`, `IsPageView`, + `NotBounce`, `HTTPError`, `pageViewID`, `CounterUserIDHash`, Openstat + и соцдем-поля (см. «чего не воспроизводить» в исследовании). +- **Отступление по типу**: `DeviceCategory` берём как UInt8, у Метрики это + String; коды те же (1–4). +- **Наша честная добавка** — `EventType`: у Метрики такого поля нет + (там `isPageView` + `productEventType`), стенду таксономия нужна явно. + +### 1.2 Состав полей (46 колонок) + +Идентификаторы и время: + +| Колонка | Тип | Комментарий | +|---|---|---| +| `WatchID` | UInt64 | id события (хита) | +| `VisitID` | UInt64 | id визита от генератора — эталон самопроверки лабы сессий («собери сам, потом сравни») | +| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования | +| `CounterID` | UInt32 | константа стенда (один сайт) | +| `EventDate` | Date | дата события | +| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики | +| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах | +| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` | + +Правила резки визитов в генераторе документируются и совпадают с лабной +логикой (30-минутный таймаут). + +Страница и атрибуция: `URL`, `Referer`, `Title`, `UTMSource`, `UTMMedium`, +`UTMCampaign`, `UTMContent`, `UTMTerm`, `LastTrafficSource`, `HasGCLID` +(UInt8), `YCLID` (UInt64) — 11 колонок: все String, кроме `HasGCLID` +(UInt8) и `YCLID` (UInt64). + +Браузер, устройство, гео: `Browser`, `BrowserMajorVersion` (UInt16), +`BrowserLanguage`, `OperatingSystem`, `OperatingSystemRoot`, `DeviceCategory` +(UInt8, коды 1–4 как у Метрики), `MobilePhoneModel`, `ScreenWidth`, +`ScreenHeight` (UInt16), `IPAddress`, `RegionCountry`, `RegionCity`, +`RegionCountryID`, `RegionCityID` (UInt32) — 14 колонок. + +Массивы и параметры: `GoalsReached` Array(UInt32) (две цели: корзина и +покупка — цели в бою дублируют события, это нормально), `ParsedParamsKey1` +Array(String) (свои параметры сайта, один уровень, например вариант +A/B-теста; Key2..10 не берём). + +Ecommerce (заполнены только у торговых событий): + +| Колонка | Тип | +|---|---| +| `purchaseID` | Array(String) | +| `purchaseRevenue` | Array(Float64) | +| `purchaseCurrency` | Array(String) | +| `purchaseCoupon` | Array(String) | +| `productID`, `productName`, `productCategory` | Array(String) | +| `productPrice` | Array(Int64) | +| `productQuantity` | Array(UInt64) | +| `productEventType` | Array(String) | +| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) | + +`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав +заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики: +это не недосмотр, а часть урока о расхождениях (см. раздел 4). + +### 1.3 Ключи и движки + +- Партиции — **по дням** (`PARTITION BY EventDate`): дневная партиция — + единица переобработки (решение #14). Отступление от Метрики (там месяц) — + зафиксировать комментарием в DDL. +- `ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)` — ключ под + запросы «по сайту за период по посетителю», хвост `WatchID` даёт + дедупликацию в ReplacingMergeTree. Точную форму проверить при исполнении. + `SAMPLE BY intHash32(ClientID)` — семплирование по тому же выражению; + учебный вопрос к лабе: почему `SAMPLE 0.1` не портит uniq-метрики. +- В DDL комментарием зафиксировать вырождение ключа как учебный факт: + `CounterID` — константа стенда (один сайт), `EventDate` — константа внутри + дневной партиции; реальная сортировка идёт по посетителю и событию + (`intHash32(ClientID)`, `WatchID`). +- Шардирование — **по `cityHash64(ClientID)`, не по сырому `ClientID`**: + структурированный числовой id перекашивает остаток по модулю числа шардов, + хеш — нет. Сессионизация, склейка идентичностей и uniq-метрики остаются + локальными на шарде. У анонимов кука есть — перекоса в NULL нет. +- Предупреждение-урок из v1: колонка версии не должна попадать ни в + партицию, ни в ключ сортировки ReplacingMergeTree — иначе версии одной + строки никогда не окажутся рядом и не склеятся при мерже. + +### 1.4 Схема как контракт + +Одно машинное описание схемы события (python-модуль или YAML) — источник +истины: из него выводятся DDL и валидация генератора, а не наоборот. 46 +колонок повторяются примерно в семи местах (генератор, DDL, SELECT матвью, +трансформации, витрины, манифест, доки) — без контракта они расходятся +молча. Заодно это учебный артефакт: менти видит на живом примере, что такое +«схема как контракт». + +## 2. Заказы бэкенда + +Второй источник и вторая версия правды о покупке. Транспорт — та же Kafka +(топик `orders`), но **пачками**: раз в модельный день бэкенд выгружает +**полный слепок заказов окна изменяемости K дней**. + +- **K = 7 модельных дней, константа мира** (страховочный срез 3 из #15 + применён — см. раздел 9). За окном заказ неизменяем, возить его незачем; + выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у + трекеров: лог Метрики «доформировывается» ещё около трёх дней. +- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт + бэкенда в snake_case: + +| Поле | Тип | Комментарий | +|---|---|---| +| `order_id` | String | номер заказа; равен клиентскому `purchaseID` | +| `user_id` | UInt64 | пользователь магазина — мост к склейке | +| `status` | String | `created` → `paid` → `cancelled` | +| `created_at`, `updated_at` | DateTime | | +| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal | +| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` | +| `snapshot_date` | Date | дата слепка (день выгрузки) | + +- Приём идемпотентный, но дедуп расщеплён на два слоя: + - `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**, + хранит «как приехало»; идемпотентность повторного прогона — заменой + партиции дня слепка, а не ReplacingMergeTree. + - Дедуп до последней версии — **argMax** в трансформации при сборке + `dds.order`. `dds.order` — единственная дедуплицированная таблица: + партиция по дню заказа (`toDate(created_at)`), + ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда + лежит в одной партиции, дедуп работает. + + Пропущенный день ничего не ломает, следующий слепок самовосстанавливает. +- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше + витрины работают с плоскими массивами. Это единственный носитель навыка + «вложенный JSON в ClickHouse» на стенде. +- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания» + выручки внутри окна; сужение до двух — резервный срез 1. + +## 3. Каталог товаров + +CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, +`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует +генератор — расхождений нет по построению. Даёт `dictGet` в витринах и +разговор о политике обновления словаря. На кластере файл монтируется в обе +ноды, словарь создаётся ON CLUSTER. + +## 4. Сверка `purchase` против заказов + +Ключ: клиентский `purchaseID` = `order_id` бэкенда (магазин знает номер +заказа на `/confirmation`). Расхождения — перечислимый список, +детерминированный от seed, не хаос: + +| | Расхождение | Механика в генераторе | Ориентир доли | +|---|---|---|---| +| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов | +| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% | +| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~1–2% | +| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% | + +Классы пересекаются — приоритет: `cancelled` > `lost_event` > +`duplicate_event` > `amount_delta` > `match`. + +Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов +впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), +ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира; +манифест хранит точные счётчики по каждому классу расхождений (отмены, +потери, дубли). + +Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — +механически дублирует B. + +Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по +бэкенду**. Оно выучивается на конфликте: суммы не сойдутся, менти сам +раскопает почему (Float64 против Decimal, промокод, доставка, отмены). + +## 5. Анонимность и склейка идентичностей + +- Email из кликстрима исчезает полностью: у события только `ClientID`. + Это честно к Logs API Метрики (UserID не выгружается). Отдельная «доля + анонимов» не нужна: мы знаем ровно тех, кто купил, — это сам урок. +- **Карта соответствий кука↔пользователь** строится трансформацией из уже + существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ + (`order_id`, `user_id`). Ни новых полей, ни нового транспорта. +- Форма и место карты: таблица `dds.identity_map` + (`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime), + ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по + `cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации + нет); ко-локация делает обогащение витрин локальным; + заказы при сборке карты подтягиваются через GLOBAL JOIN. +- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — + параметр мира; значение фиксирует эта спека: 15%, детерминировано + от seed. Ядро лабы: + `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. + Константа мира: каждый двухкуковый покупатель делает минимум по одному + заказу с каждой куки — иначе вторая кука не попадает в карту соответствий + (она строится только из покупок) и лаба не воспроизводится. Манифест + хранит число именно таких пар. +- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и + **«известные пользователи»** (после склейки) — оба числа рядом в дашборде. + +## 6. Кластер + +Соседний `clickhouse-learning-cluster` остаётся разминкой при курсе: там +концепции, здесь жизнь — забыть ON CLUSTER, получить ошибку, починить. + +Топология и режим — по резолюции #14: + +- **2 шарда × 1 реплика + отдельный clickhouse-keeper**, единственный режим: + `make up` поднимает сразу кластер, выключателя нет. Страховка от «слишком + сложно» — отсутствие реплик и runbook, а не профиль без кластера. +- Движки локальных таблиц — `Replicated*` (макросы `{shard}`/`{replica}`, + пути keeper, готовность к будущей реплике). Без HAProxy — балансировать + нечего; в доках абзац «в бою здесь LB». +- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на + ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или + VIEW поверх локальной таблицы проявляются в дашборде сами. +- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group, + 2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ: + события — по `cityHash64(ClientID)` (см. 1.3), заказы — + `cityHash64(order_id)`, сырьё STG — + `cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между + прогонами, куда легли данные — нет». +- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные + поля — без значений по умолчанию. Несовпадение имени поля — громкая + ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы, + опечатка иначе не падает. +- **Политика соединений**: по ключу ко-локации — обычное соединение с + комментарием, почему локальный результат корректен; по любому другому ключу + — только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка + `purchase`↔заказ — + легитимная GLOBAL-витрина (заказы малы). +- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп + через argMax); батчевая переобработка — по дневным партициям + (`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` остаётся + только в `make reset`. `DROP/REPLACE PARTITION` работает только по + **локальным** таблицам ON CLUSTER, не по Distributed; замена через + DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это + осознанная цена, не баг). +- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая + партиция дня слепка, без переделки старого); материализованное ниже — + нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у + заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`). + Сессии перестраиваются только за текущий день: правило мира — сессия + режется по границе модельных суток, дневная партиция самодостаточна. +- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась + `insert_distributed_sync`), иначе проверки видят неполные данные. +- Все контрольные суммы и dq-проверки считают через `argMax`/`GROUP BY`/ + `FINAL` — голый `count()` по ReplacingMergeTree зависит от того, сколько + мержей уже прошло. +- **Проверки и контрольные суммы — только по Distributed-таблицам**: + агрегаты от раскладки не зависят; раскладка по шардам нигде не фиксируется, + пошардовые наблюдения — исследовательские, в лабах. + +### Ресурсный бюджет (#10) + +Расчёт на ноутбук менти с 16 ГБ памяти; у кого 8 ГБ — берёт VDS за свой счёт. +Полный стенд в покое ≈3,4 ГБ. Топология 2×1 добавляет ≈0,6–0,8 ГБ — влезает +свободно. Топология 2×2 добавила бы ≈1,7–1,9 ГБ и упёрлась бы в дефолтный +бюджет WSL2 (~8 ГБ) — это второй довод против реплик, рядом с главным +(репликационная эксплуатация — отдельный операционный домен). Координатор — +clickhouse-keeper, а не ZooKeeper, в том числе из-за этого бюджета. + +## 7. Слои: карта таблиц v2 + +| Слой | Объект | Что это | +|---|---|---| +| Kafka | `hits`, `orders` | два топика, по 2 партиции | +| STG | `stg.kafka_hits`, `stg.hits_raw` + MV; то же для orders | сырые строки, Kafka Engine на обеих нодах | +| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree | +| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа | +| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | +| DDS | `dds.v_event` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | +| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке | +| DDS | `dds.identity_map` | карта кука↔пользователь | +| DDS | словарь `products` | каталог из CSV | +| DM | витрины `dm.v_*`, `dm.dq_summary` | см. ниже | + +Служебные колонки: `ods.event` и `ods.order_snapshot` получают метку приёма +`_ingested_at`; у `ods.event` та же колонка — колонка версии +ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальные колонки Kafka +(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода +читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый +`event_date` — им кормится переобработка дня X (при исчерпании retention +Kafka переобработка возможна только из эталонного артефакта). + +`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не +обязательно копия данных». + +Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже +широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ, +идентичность). «Грязные» записи по-прежнему уходят в `*_errors`, не валят +пайплайн. + +### Витрины DM + +- **`v_revenue_daily`** (выручка, только от заказов): `report_date`, + `product_category` (через `dictGet` каталога + ARRAY JOIN позиций), + `orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе + `paid`; внутри окна K число дня «дышит». +- **`v_purchase_vs_orders`** (сверка): FULL OUTER GLOBAL JOIN по + `purchaseID = order_id`; колонки: `order_day`, `order_id`, + `declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не + `total`: промокод и доставка клиенту не видны), `status`, `mismatch_class` + (`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`, + в порядке приоритета — классы пересекаются, побеждает более ранний). + `match` — большинство строк; `amount_delta` — только необъяснённый остаток + после приведения к сравнимой базе (округления Float64, ~1–2% заказов). + Строка «`purchase` без заказа» внутри живого окна — это опоздание, ждущее + слепка, а не отдельный класс расхождения. +- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру); + счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется + `declared_revenue` по UTM. +- **`v_daily_traffic`** — расширяется парой «посетители» / «известные + пользователи» (обогащение через `dds.identity_map`, локальное соединение + по ключу ко-локации). +- `v_events_enriched`, `v_top_pages_daily`, `v_session_overview`, + `v_dq_errors_daily` — переезжают на новую модель без смены роли: источник — + `dds.v_event`, не `ods.event`. +- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену + (политика «без TRUNCATE»). + +Дашборд Superset получает три новых сюжета: выручка по дням и категориям, +таблица сверки с классами расхождений, пара посетители/известные. + +Ландшафт итогом: Kafka — единственная труба (кликстрим потоком, заказы +пачками), Postgres остаётся только служебной базой Airflow. Смешанность +ландшафта выражена режимами и частотами, а не второй трубой. + +## 8. Эталонный мир и манифест + +Пересборка артефакта `data/startup_history/` неизбежна и оплачена решением +#18 один раз — все изменения генератора съезжаются в одну пересборку. +Манифест расширяется контрольными числами: + +- заказная сторона: заказы и выручка по дням; манифест хранит точные + счётчики по каждому классу расхождений (отмены, потери, дубли) — + самопроверка лабы сверки; +- идентичность: uniq кук, uniq известных пользователей, число двухкуковых + покупателей — лаба склейки получает самопроверку. + +Артефакт вырастет (ecommerce-массивы, заказы) — размер проверить при +пересборке. Политика версионирования артефакта здесь не решается (туман +карты #10). + +## 9. Оценка объёма исполнения + +v2 стартует пустым, поэтому объём ниже — это новый код, а не правка на +месте; v1 служит источником идей и образцов (масштаб оценён по нему). + +| Направление | Что строим | Объём | +|---|---|---| +| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L | +| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов | +| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность | +| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов | +| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла | +| Эталонный мир | сборка артефакта v2, манифест-счётчики, чек-скрипты | M–L | +| Мониторинг | Prometheus/Grafana: цели двух нод и keeper | S — 2–4 конфига | +| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR | + +Итого ~80–110 файлов нового репозитория (посчитаны конфиги по нодам, +документация, экспорт Superset — дерево YAML, CI и артефакты данных); +тяжёлое — генератор и SQL. Это крупный релиз, но он режется на этапы с +работающим стендом после каждого. Согласуется с оценкой исследования #13 +(~15–20 файлов только на кластерную часть). + +### Решение по страховочным срезам (#15) + +- **Срез 3 применён**: окно K — константа мира (7 дней), не параметр. +- **Срез 2 применён как порядок, не как отказ**: расхождения A+C входят в + этап сверки, B+D — отдельным следующим этапом. +- **Срез 1 в резерве**: статусы держим все три (`created`/`paid`/ + `cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары + `created`/`cancelled` — запасной ход, если генератор заказов окажется дороже + ожиданий. Связка: если срез 1 сработает, определение выручки в + `v_revenue_daily` придётся сменить с «заказы в статусе `paid`» на «все + неотменённые заказы». +- **Отступление от порядка #15**: резолюция предписывала резать в порядке + 1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2 + ничего не отнимают у уроков (окно и так одно, расхождения и так вводятся + этапами), а срез 1 убирает статус `paid` — вместе с ним ушло бы «дыхание» + выручки. + +### Этапы для /to-tickets (черновик) + +0. Рождение v2: создать репозиторий (рабочее имя + `clickstream-data-platform`), осознанный первый коммит (скелет доков, + AGENTS.md, лицензия), переезд карты #10 и открытых тикетов. +1. Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow, + Superset, мониторинг), конфиги, `make up`, smoke-проверка ON CLUSTER. +2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими + данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами; + широкое событие, таксономия, анонимность, N:1 (клиентская сторона + целиком). В конце этапа фиксируется маленький «зерновой» мир для + стабильных приёмок следующих этапов (полная пересборка эталонного мира — + отдельный этап 7). +3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь. +4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C. +5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного + батча заказов — сенсор/Datasets). +6. Расхождения B+D и опоздания; счётчики манифеста. +7. Эталонный мир: пересборка артефакта, чек-скрипты. +8. Superset-дашборд v2. +9. Мониторинг и runbook «keeper упал / DDL повис в очереди». + +Критерий приёмки этапа — честный: `make up` работает и проходят +smoke-проверки, а не «дашборд зелёный». Документация правится в PR этапа +(правило AGENTS.md). + +## 10. Границы: чего не делаем + +- «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи, + расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. +- Политика версионирования эталонного артефакта — туман карты. +- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной + работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не + переносится. Спека даёт будущим лабам только опорные точки — контрольные + числа манифеста (сверка, идентичность). Явное следствие: после этапа 9 + стенд работает, но учебного пути на нём ещё нет. +- Инкрементальный ETL (#8) — свой issue. +- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. +- Полный словарь торговых событий Метрики (detail, remove, impressions), + пять уровней категорий, блоки `purchasedProduct*`/`impressions*`. +- `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток визитов + Метрики Про). +- Событийный лог заказов, CDC/Debezium, HTTP-сервис заказов, шапка+строки, + отдельный поток возвратов — отклонены в #15/#18. +- Файловые дропы как источник — зона следующего стенда (Lakehouse), у нас + остаются теорией; каталог отдельным топиком Kafka — в бою так не делают + (оба отклонения — из #18). +- Вероятностная склейка, identity graph, кука 1:N («семейный планшет») — + тема лекции, не лабы. +- Эмуляция `setUserID` и сюжет «логин посреди сессии» через свои параметры — + отклонены в #16: нечестно к формату выгрузки Метрики. + +## 11. Проверить при исполнении + +- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` — + эмпирически на стенде (хвост #14). +- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между + прогонами, отсутствие дублей при штатной работе. +- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе + ReplacingMergeTree). +- Размер артефакта эталонного мира после пересборки. +- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом + сверять через MCP Context7 (правило AGENTS.md). +- Генератор: рабочее решение — Python с производительной архитектурой + (батчевая генерация вместо посточной, быстрая JSON-сериализация, + распараллеливание по модельным дням). Читаемость генератора для менти — + не довод при выборе языка: он в любом случае сложнее уровня DE-джуна. + До этапа 3 зафиксировать требования производительности (пересборка + эталонного мира, живой поток ×60); переход на компилируемый язык + (Rust/Go) — только если замеры покажут, что Python приемлемой скорости + не даёт. + +## 12. Влияние на документацию + +Состав и структура доков v2 проектируются заново: набор документов v1 +сложился исторически и не копируется. Какие документы нужны v2 — решение +этапа 0. Обязательный минимум по содержанию (не по списку файлов): +быстрый старт, архитектура слоёв, операционка с runbook keeper/DDL, +словарь терминов (широкое событие, слепок, окно изменяемости, +посетители/известные пользователи, ко-локация). + +Документ о реализме (`generator-realism.md`) переезжает в v2 и получает +крупное обновление: Kafka — учебная замена батчевого Logs API («настоящий +Logs API — это скачанный TSV»); trade-off «инкремент экономнее, слепок +надёжнее» + сноска про compacted topic; identity stitching перестаёт быть +чистой теорией; «прямое чтение прод-базы — анти-приём, в бою — реплика или +выгрузка»; полный словарь торговых событий — теория. + +В v1 при заморозке — указатель на v2 в README (форму решить при рождении +v2, этап 0). + +### Опорные точки для будущих лекций и лаб + +Лабы и курс — вне скоупа спеки (см. раздел 10). Список нужен только затем, +чтобы хвосты резолюций не потерялись: + +- анти-паттерны ключа шардирования (`rand()`, `toDate`) — как отрицательные + примеры; +- «как выбирают топологии в бою»: часто 1 шард × N реплик, шардирование — про + рост; +- сцена «разные consumer groups → дубли»; +- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет + `RegionCityID`); +- лекция про идентичность «как в бою»: `setUserID` и first-party id, + детерминированная против вероятностной склейки, identity graph, + кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер + user id не отдаёт».