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