Files
clickstream-data-platform/docs/specs/2026-07-30-stand-v2-realism.md
T
ddadmin 319db308bf docs(storage): приняты решения по приёму событий и именам
- Зачем:
  - тикет #37 молча опирался на конвенции хранилища, которых в проекте не
    было; без них #43 и следующие этапы разъехались бы в именах, служебных
    колонках и механике приёма.
- Что:
  - ADR 0005: топик читается байтами в STG, разбор идёт функциями в матвью
    ODS; строгий приём — сверка набора ключей плюс Nullable на пяти опорных
    колонках.
  - ADR 0006: суффикс вида в именах объектов (_rep, _dist, _kafka, _mv, _v).
  - docs/architecture/storage.md: конвенции имён и служебных колонок, путь в
    keeper, раскладка по шардам, срок жизни сырья, свойства приёма, раскладка
    файлов DDL и карта таблиц.
  - спеки приведены в соответствие: механизм строгого приёма, имена объектов,
    контракт транспорта «одно событие — одно сообщение Kafka», три проверки
    при исполнении.
- Проверка:
  - make config-test
2026-08-04 00:14:13 +03:00

637 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Боевой реализм стенда (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; коды те же (14).
- **Наша честная добавка** — `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) и contract-тест в
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
Без контракта 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). Урок:
«какая нода читала топик — меняется между прогонами, куда легли данные — нет».
- **Приём строгий**: обязательные поля разбираются как `Nullable`, а набор
ключей сообщения сверяется с контрактным; строка с NULL среди обязательных
полей или с разошедшимся набором ключей уходит в `*_errors`. Сверка ключей —
не добавка: у массивов 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 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)
Предела расхода у стенда нет. Есть требование к машине: около 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 | сырые строки, 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).
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`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
- **`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` — только необъяснённый остаток
после приведения к сравнимой базе; его создаёт генератор намеренно
(~1–2% заказов, см. раздел 4).
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
закрытия окна K таких строк не остаётся — сироты исключены построением
(раздел 4).
- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру);
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
`declared_revenue` по UTM.
- **`v_daily_traffic`** — расширяется парой «посетители» / «известные
пользователи» (обогащение через `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 — ~56 файлов |
| 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 сработает, определение выручки в
`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. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
на amd64 и arm64.
8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002.
Критерий приёмки этапа — честный: `make up` работает и проходят
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
наблюдаемые критерии каждый этап получает при разбиении в /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. Проверить при исполнении
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode`
эмпирически на стенде (хвост #14).
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
прогонами, отсутствие дублей при штатной работе.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение (ADR 0005).
- Матвью, привязанная к локальной таблице, срабатывает, когда строки приходят
вставкой через `Distributed`: на этом стоит цепочка STG → ODS (ADR 0005).
- Форма именованного кортежа в `JSONExtract` с `Nullable`-членами — ею
сворачиваются 47 вызовов в один, если разбор окажется дорогим (ADR 0005).
- Размер артефакта эталонного мира после пересборки.
- Спорные 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) —
сравнение цены и наблюдаемости как задание;
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
детерминированная против вероятностной склейки, identity graph,
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
user id не отдаёт».