Зачем Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому же состоянию, а в git лежит то, чем это состояние проверяется. Что - Опись мира `data/world-inventory.json`: паспорт (зерно, версия генератора, хеш каталога) и по строке на каждый из восьми дней — дата, число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит `test_inventory.py` — тем же способом, что свежесть описания выгрузки. - Разовая служба `world-init` вышла из-под профиля и играет в топик восемь дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait` считает успешно отработавшую службу упавшей. - `scripts/wait-for-world.sh` — вторая половина `make up`: приём асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения заливки. Ограниченный цикл опроса, не пауза наугад. - Девятая проверка `make check-clickhouse`: подневный счёт событий против описи, рамка по датам стартового мира, счёт через `FINAL`. При расхождении называет, где искать, — в событиях или в браке. - Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал 1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с датой. Раздел 9 спеки закрыт: открытых вопросов не осталось. - Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром (решение владельца). Оба заведены в словарь CONTEXT.md. Проверка `make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий. `make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с), `make test` — 407 тестов за 71 с, `make lint`, `make typecheck`, `make config-test` зелёные. Что проверка умеет краснеть, снято двумя поломками: снос партиции 2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье — «сломан разбор». Строки опыта убраны, день переигран, счёт вернулся. Тест свежести проверен молчаливой правкой цены в каталоге: покраснел. Ссылка: #42
59 KiB
Боевой реализм стенда (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 (анонимность и склейка); исследование
формата кликстрима Яндекса;
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, подробности — спека генератора,
раздел 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_skuArray(String),item_qtyArray(UInt64),item_priceArray(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_idUInt64,user_idUInt64,first_matched_atDateTime), 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(сырой строки); полный список и доводы — в доке хранилища. Урок: «какая нода читала топик — меняется между прогонами, куда легли данные — нет». - Приём строгий: пять опорных колонок —
WatchID,VisitID,ClientID,EventDate,UTCEventTime— разбираются какNullable, а набор ключей сообщения сверяется с контрактным; строка с NULL среди опорных колонок или с разошедшимся набором ключей уходит в*_errors. Опорными выбраны те, чья порча отравляет всё ниже по течению: идентификаторы события, визита и посетителя, дата партиции и метка времени, по которой события упорядочиваются внутри сессии.CounterIDформально тоже в ключе сортировки, но на стенде он константа, и NULL там взяться неоткуда. Остальные сорок две достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей — не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе неотличимо от пустого по смыслу. На входе разбора нет вовсе — Kafka-таблица читает сообщение байтами, строгость целиком в матвью ODS (ADR 0005). Контракт присутствия: генератор выдаёт все 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; для менти то же самое объясняет 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 |
см. ниже |
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — см. доку хранилища.
Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран приём сырья байтами с разбором функциями (ADR 0005); заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов; как учебное сравнение двух способов приёма это записано и в опорных точках раздела 12.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
ods.event и ods.order_snapshot получают метку загрузки _load_ts, и у
ods.event она же служит колонкой версии ReplacingMergeTree; а таблицы
stg.*_raw хранят метаданные доставки Kafka вместе с именем читавшей ноды —
без них урок «какая нода читала топик» ненаблюдаем.
Модельного дня в STG нет: EventDate — свойство содержимого, а содержимое
разбирает ODS, где эта колонка и служит ключом партиции. Переобработка режется
по времени загрузки, координате самого слоя доставки. При исчерпании retention
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
см. спеку генератора.
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, подробности —
спека генератора, раздел 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 (черновик)
- Рождение v2: создать репозиторий (рабочее имя
clickstream-data-platform), осознанный первый коммит (скелет доков, AGENTS.md, лицензия), переезд карты #10 и открытых тикетов. - Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow,
Superset, мониторинг), конфиги,
make up, smoke-проверка ON CLUSTER. - DDL и генератор (слиты в один этап — DDL проверяется только настоящими
данными): базы и таблицы событий ON CLUSTER, приём
hitsобеими нодами; широкое событие, таксономия, анонимность, N:1 (клиентская сторона целиком). В конце этапа фиксируется маленький стартовый мир для стабильных приёмок следующих этапов (полная пересборка эталонного мира — отдельный этап 7). - Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
- Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
- Airflow:
etl_pipeline(партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets). - Расхождения B+D и опоздания; счётчики описи.
- Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация на amd64 и arm64.
- Superset-дашборд v2.
- Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов и границы — ADR 0002.
Критерий приёмки этапа — честный: make up работает и проходят все три
проверки на стенде — make smoke, make check-clickhouse и
make check-services, — а не «дашборд зелёный». Названы они поимённо
намеренно: под общим словом «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. Проверить при исполнении
Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой
остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки
времени закрыты при исполнении #37; форма ключа ODS и поведение матвью над
Distributed — при исполнении #43, ответы в доке
хранилища, раздел «Что проверено»; запасной
именованный кортеж — там же в ADR 0005.
- Поведение соединения двух Distributed-таблиц и
distributed_product_mode— эмпирически на стенде (хвост #14). - Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между прогонами, отсутствие дублей при штатной работе. Закрыто пока наполовину: что обе ноды читают топик и обе партиции доезжают, показал #37; что дублей нет и как раскладка меняется между прогонами — нет.
- Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом сверять через MCP Context7 (правило AGENTS.md).
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры проверить через Context7.
- Генератор: рабочее решение — Python с производительной архитектурой (батчевая генерация вместо посточной, быстрая JSON-сериализация, распараллеливание по модельным дням). Читаемость генератора для менти — не довод при выборе языка: он в любом случае сложнее уровня DE-джуна. Числовые требования производительности и способ замера зафиксированы спекой генератора, раздел 5 (порядки величин — исследование); переход на компилируемый язык (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) против типизированного чтеца сkafka_handle_error_mode— сравнение цены и наблюдаемости как задание. Развилка этапа 3, не решена: типизированный чтец идёт заказам только вместе с ответом на вопрос, нужен ли им слой сырья. Нужен — и чтецов на один топик станет два, а эту схему ADR 0005 отверг; не нужен — и слои перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов; - матвью как рабочий механизм, а не диковина: их видно на приёме и на сборке
ODS, а пакетная работа начинается выше. Отдельным заданием — как читать из ODS
последние версии, через
FINALили оконной функцией: что нагляднее, решаем на месте; - лекция про идентичность «как в бою»:
setUserIDи first-party id, детерминированная против вероятностной склейки, identity graph, кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер user id не отдаёт».