# Боевой реализм стенда (v2): широкое событие, заказы, кластер, анонимность Статус: Accepted (2026-07-30). Три помеченных отступления подтверждены владельцем на приёмке: порядок страховочных срезов (раздел 9), `Sign` как колонка без механики (раздел 1.1), `VisitID` как эталон самопроверки (1.2). Дата: 2026-07-30. Тикет: #17 (сборка карты #10). Переехала из v1 (https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/specs/2026-07-30-stand-v2-realism.md); номера тикетов #NN в тексте — из трекера v1. Источники: резолюции #18 (модель данных), #15 (`purchase` и заказы), #14 (кластер), #13 (цена кластера), #16 (анонимность и склейка); исследование [формата кликстрима Яндекса](../research/2026-07-26-yandex-clickstream-format.md); [docs/generator-realism.md](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/src/branch/main/docs/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`. Одна длина у массивов общая внутри группы: `purchase*` — по элементу на заказ, `product*` — по элементу на товар; между собой группы разной длины. Сессий в потоке нет — их менти собирает сам в 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` берём как колонку формата, без механики** (решение владельца на приёмке спеки): генератор всегда пишет `Sign = 1`, исправлений записей не шлёт — движки и запросы не меняются. Сама механика версий (CollapsingMergeTree, пара `HitVersion`) — кандидат на потом, по #15. Честность: комментарий в DDL и абзац в документе о реализме («в бою здесь бывают −1/+1, считают через `sum(Sign)`»); в лекции — крючок про CollapsingMergeTree (частый вопрос на собеседованиях). - **Не берём** `ClientEventTime` (в выгрузке Метрики нет клиентской метки; расхождение часов — тема тумана «грязь»), `Params` (второй сырой JSON не нужен: этот навык уже несут заказы), `LastSearchEngineRoot`, `IsPageView`, `NotBounce`, `HTTPError`, `pageViewID`, `CounterUserIDHash`, Openstat и соцдем-поля (см. «чего не воспроизводить» в исследовании). - **Отступление по типу**: `DeviceCategory` берём как UInt8, у Метрики это String; коды те же (1–4). - **Наша честная добавка** — `EventType`: у Метрики такого поля нет (там `isPageView` + `productEventType`), стенду таксономия нужна явно. ### 1.2 Состав полей (47 колонок) Идентификаторы и время: | Колонка | Тип | Комментарий | |---|---|---| | `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` | | `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) | Правила резки визитов в генераторе документируются и совпадают с лабной логикой (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-модуль с чистыми данными, собственность генератора (data contract; решение развилки «Архитектура» карты #26, подробности — [спека генератора](2026-08-01-generator.md), раздел 3). Из контракта выводятся сам генератор, его валидация и рендеренное «описание выгрузки» в доках — аналог документации Метрики. Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по этой документации на своих этапах, как в бою хранилище адаптируется к источнику; границу сторожит строгий приём (раздел 6). Вторым сторожем здесь стояла сверка объявлений — `system.columns` поднятого стенда против схемы генератора; она снята при исполнении #43 как ничего не добавляющая к соседу. Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся молча. Заодно это учебный артефакт: менти видит на живом примере, что такое data contract. ## 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; дальше витрины работают с плоскими массивами `dds.order`: `item_sku` Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2)) — одной длины, порядок как в JSON. Это единственный носитель навыка «вложенный JSON в ClickHouse» на стенде. - Статусы держим все три: смена `created` → `paid` и есть причина «дыхания» выручки внутри окна; сужение до двух — резервный срез 1. ## 3. Каталог товаров CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, `brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл использует генератор — расхождений нет по построению. Даёт `dictGet` в витринах и разговор о политике обновления словаря. На кластере файл монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка — уровень спроса товара, заведена при исполнении #50 (спека генератора, раздел 9): генератор решает по ней, что уходит из карточки в корзину. ## 4. Сверка `purchase` против заказов Ключ: клиентский `purchaseID` = `order_id` бэкенда (магазин знает номер заказа на `/confirmation`). У события `purchase` массив `purchaseID` несёт ровно один элемент (одно подтверждение — один заказ), сверка соединяет по `purchaseID[1]`; правило зафиксировать комментарием в SQL сверки. Расхождения — перечислимый список, детерминированный от 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. Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по бэкенду**. Единственное разрешённое исключение — клиентская оценка выручки под именем `declared_*` там, где атрибуция без трекера невозможна (UTM); слово `declared` в имени — сигнал «это заявка клиента, не деньги отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам раскопает почему (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(сырой строки)`; полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок: «какая нода читала топик — меняется между прогонами, куда легли данные — нет». - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей сообщения сверяется с контрактным; строка с NULL среди опорных колонок или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те, чья порча отравляет всё ниже по течению: идентификаторы события, визита и посетителя, дата партиции и метка времени, по которой события упорядочиваются внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он константа, и NULL там взяться неоткуда. Остальные сорок две достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей — не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе неотличимо от пустого по смыслу. На входе разбора нет вовсе — Kafka-таблица читает сообщение байтами, строгость целиком в матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)). Контракт присутствия: генератор выдаёт **все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы, опечатка иначе не падает. - **Политика соединений**: по ключу ко-локации — обычное соединение с комментарием, почему локальный результат корректен; по любому другому ключу — только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка `purchase`↔заказ — легитимная GLOBAL-витрина (заказы малы). - **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп через argMax); батчевая переобработка — по дневным партициям (`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не применяется вовсе — полный сброс стенда делается `make clean && make up`, то есть вместе с томами. `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) Предела расхода у стенда нет. Есть требование к машине: около 8 ГБ памяти, доступной Docker. Решение и его основания — [ADR 0004](../adr/0004-resource-limits.md); для менти то же самое объясняет README. Прежняя оценка «полный стенд в покое ≈3,4 ГБ» была расчётом по стенду-предшественнику, сделанным до первой сборки v2, и предела не задавала. Проверка `make smoke`, сторожившая это число, убрана: она мерила потребление вместе со страничным кэшем и с появлением настоящих данных начала бы краснеть на здоровом стенде. Топология 2×2 остаётся отвергнутой по главному доводу — репликационная эксплуатация есть отдельный операционный домен. Второй довод, от бюджета, снят вместе с бюджетом. Выбор clickhouse-keeper вместо ZooKeeper держится на резолюции #14 и ссылки на бюджет больше не требует. ## 7. Слои: карта таблиц v2 | Слой | Объект | Что это | |---|---|---| | Kafka | `hits`, `orders` | два топика, по 2 партиции | | STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, не решена (ниже) | сырые строки, Kafka Engine на обеих нодах | | ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree | | ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа | | DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | | DDS | `dds.event_v` | представление над `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` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — см. [доку хранилища](../architecture/storage.md). Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md)); заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов; как учебное сравнение двух способов приёма это записано и в опорных точках раздела 12. Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: `ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у `ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы `stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды — без них урок «какая нода читала топик» ненаблюдаем. Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое разбирает ODS, где эта колонка и служит ключом партиции. Переобработка режется по времени загрузки, координате самого слоя доставки. При исчерпании retention Kafka день переигрывается генератором заново: снимок — кэш чистой функции, см. [спеку генератора](2026-08-01-generator.md). `dds.event_v` — первый на стенде пример правила «слой — это контракт, а не обязательно копия данных». Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ, идентичность). «Грязные» записи по-прежнему уходят в `*_errors`, не валят пайплайн. ### Витрины DM - **`revenue_daily_v`** (выручка, только от заказов): `report_date`, `product_category` (через `dictGet` каталога + ARRAY JOIN позиций), `orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе `paid`; внутри окна K число дня «дышит». - **`purchase_vs_orders_v`** (сверка): 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` — только необъяснённый остаток после приведения к сравнимой базе; его создаёт генератор намеренно (~1–2% заказов, см. раздел 4). Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее слепка, а не расхождение: она получает служебный класс `awaiting_order` (шестое значение `mismatch_class`, вне приоритетов расхождений). После закрытия окна K таких строк не остаётся — сироты исключены построением (раздел 4). - **`utm_effectiveness_v`** — остаётся клиентской (атрибуция по трекеру); счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется `declared_revenue` по UTM. - **`daily_traffic_v`** — расширяется парой «посетители» / «известные пользователи» (обогащение через `dds.identity_map`, локальное соединение по ключу ко-локации). - `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`, `dq_errors_daily_v` — переезжают на новую модель без смены роли: источник — `dds.event_v`, не `ods.event`. - `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену (политика «без TRUNCATE»). Дашборд Superset получает три новых сюжета: выручка по дням и категориям, таблица сверки с классами расхождений, пара посетители/известные. Ландшафт итогом: Kafka — единственная труба (кликстрим потоком, заказы пачками), Postgres остаётся только служебной базой Airflow. Смешанность ландшафта выражена режимами и частотами, а не второй трубой. ## 8. Эталонный мир и опись В git хранится только опись эталонного мира; сам снимок (14 модельных дней) генерируется на месте — при `make up` и при проверках (решение развилки «Производительность» карты #26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт паспорт мира (каноническое зерно, версия генератора), контрольные счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман карты #10) закрыта этим же ходом: версионируется опись. Контрольные числа описи: - заказная сторона: заказы и выручка по дням; опись хранит точные счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) — самопроверка лабы сверки; - идентичность: uniq кук, uniq известных пользователей, число двухкуковых покупателей — лаба склейки получает самопроверку. Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить при пересборке. ## 9. Оценка объёма исполнения v2 стартует пустым, поэтому объём ниже — это новый код, а не правка на месте; v1 служит источником идей и образцов (масштаб оценён по нему). | Направление | Что строим | Объём | |---|---|---| | Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L | | Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов | | SQL | 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 файла | | Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L | | Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды | | Документация | доки 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 сработает, определение выручки в `revenue_daily_v` придётся сменить с «заказы в статусе `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. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация на amd64 и arm64. 8. Superset-дашборд v2. 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов и границы — ADR 0002. Что гонять на приёмке этапа — строка в [карте проверок](../architecture/testing.md), раздел «Какую проверку когда запускать»: стенд собирается с нуля и на нём зелены все три цели, которым нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд, и молчит о его предмете — дашборд может быть не нарисован, а все три останутся зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа (правило AGENTS.md). ## 10. Границы: чего не делаем - «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи, расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не переносится. Спека даёт будущим лабам только опорные точки — контрольные числа описи (сверка, идентичность). Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. - Инкрементальный ETL (#8) — свой issue. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - Полный словарь торговых событий Метрики (detail, remove, impressions), пять уровней категорий, блоки `purchasedProduct*`/`impressions*`. - Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток визитов Метрики Про); сама колонка `Sign` уже в схеме, статикой (см. 1.1). - Событийный лог заказов, CDC/Debezium, HTTP-сервис заказов, шапка+строки, отдельный поток возвратов — отклонены в #15/#18. - Файловые дропы как источник — зона следующего стенда (Lakehouse), у нас остаются теорией; каталог отдельным топиком Kafka — в бою так не делают (оба отклонения — из #18). - Вероятностная склейка, identity graph, кука 1:N («семейный планшет») — тема лекции, не лабы. - Эмуляция `setUserID` и сюжет «логин посреди сессии» через свои параметры — отклонены в #16: нечестно к формату выгрузки Метрики. ## 11. Проверить при исполнении Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки времени закрыты при исполнении #37; форма ключа ODS и поведение матвью над `Distributed` — при исполнении #43, ответы в [доке хранилища](../architecture/storage.md), раздел «Что проверено»; запасной именованный кортеж — там же в [ADR 0005](../adr/0005-event-ingestion.md). Kafka Engine на двух нодах снят с этого списка при приёмке этапа 2 (7 августа 2026 года) — проверенным наполовину и осознанно. Что обе ноды читают топик и обе партиции доезжают, показал #37. Дубли и раскладку партиций между прогонами решено не проверять: дубль здесь возможен по устройству движка, окно названо и разобрано в [доке хранилища](../architecture/storage.md), а в ODS его схлопывает `ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо обезврежено, — работа без ответа на конце. - Поведение соединения двух Distributed-таблиц и `distributed_product_mode` — эмпирически на стенде (хвост #14). - Размер артефакта эталонного мира после пересборки. - Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом сверять через MCP Context7 (правило AGENTS.md). - Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры проверить через Context7. - Генератор: рабочее решение — Python с производительной архитектурой (батчевая генерация вместо посточной, быстрая JSON-сериализация, распараллеливание по модельным дням). Читаемость генератора для менти — не довод при выборе языка: он в любом случае сложнее уровня DE-джуна. Числовые требования производительности и способ замера зафиксированы [спекой генератора](2026-08-01-generator.md), раздел 5 (порядки величин — [исследование](../research/2026-08-01-python-batch-generation-speed.md)); переход на компилируемый язык (Rust/Go) — только если живые замеры этапа 2 выйдут за её порог. ## 12. Влияние на документацию Состав и структура доков v2 проектируются заново: набор документов v1 сложился исторически и не копируется. Какие документы нужны v2 — решение этапа 0. Обязательный минимум по содержанию (не по списку файлов): быстрый старт, архитектура слоёв, операционка с runbook keeper/DDL, словарь терминов (широкое событие, слепок, окно изменяемости, посетители/известные пользователи, ко-локация). Документ о реализме (`generator-realism.md`) переезжает в v2 и получает крупное обновление: Kafka — учебная замена батчевого Logs API («настоящий Logs API — это скачанный TSV»); trade-off «инкремент экономнее, слепок надёжнее» + сноска про compacted topic; identity stitching перестаёт быть чистой теорией; «прямое чтение прод-базы — анти-приём, в бою — реплика или выгрузка»; колонка `Sign` без механики — честное ограничение стенда (в бою −1/+1 и `sum(Sign)`); полный словарь торговых событий — теория. В v1 при заморозке — указатель на v2 в README (форму решить при рождении v2, этап 0). ### Опорные точки для будущих лекций и лаб Лабы и курс — вне скоупа спеки (см. раздел 10). Список нужен только затем, чтобы хвосты резолюций не потерялись: - анти-паттерны ключа шардирования (`rand()`, `toDate`) — как отрицательные примеры; - «как выбирают топологии в бою»: часто 1 шард × N реплик, шардирование — про рост; - сцена «разные consumer groups → дубли»; - словарь регионов из CSV той же машинерией, что каталог товаров (оживляет `RegionCityID`); - лаба сессий: менти сначала собирает сессии сам, и только после — рассказ, что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично синтетическая постановка — осознанный приём; - лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` = `count()`, а в бою — нет; частый вопрос на собеседованиях; - два способа принять топик, рядом на одном стенде: сырьё байтами с разбором функциями (`hits`, [ADR 0005](../adr/0005-event-ingestion.md)) против типизированного чтеца с `kafka_handle_error_mode` — сравнение цены и наблюдаемости как задание. **Развилка этапа 3, не решена**: типизированный чтец идёт заказам только вместе с ответом на вопрос, нужен ли им слой сырья. Нужен — и чтецов на один топик станет два, а эту схему ADR 0005 отверг; не нужен — и слои перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов; - матвью как рабочий механизм, а не диковина: их видно на приёме и на сборке ODS, а пакетная работа начинается выше. Отдельным заданием — как читать из ODS последние версии, через `FINAL` или оконной функцией: что нагляднее, решаем на месте; - лекция про идентичность «как в бою»: `setUserID` и first-party id, детерминированная против вероятностной склейки, identity graph, кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер user id не отдаёт».