Files
clickstream-data-platform/docs/specs/2026-07-30-stand-v2-realism.md
T
ddadmin 2951359e6c docs(spec): порог приёмки — гигиена, и живёт он в карте проверок
Зачем: формулировка в спеке дублировала строку карты проверок и делала это
хуже оригинала. «make up работает» следует из зелёных проверок и потому
пусто; настоящее условие — «с нуля» — в спеке не проговаривалось. Заодно
гигиеническая планка носила имя приёмки этапа, хотя про предмет этапа она
молчит: дашборд может быть не нарисован, а все три цели зелены. Тот же промах
разобран в ADR 0004 — там он случился с числовым порогом.

Что: раздел 9 спеки отсылает к карте проверок вместо своей формулировки и
называет вещь своим именем — гигиена, а не приёмка. Копия строки убрана из
семи карт этапов и карты #1 в трекере: одна вещь — одно место.

Проверка: правка документная. grep по docs, README и AGENTS — других копий
формулировки нет.
2026-08-07 20:26:29 +03:00

60 KiB
Raw Blame History

Боевой реализм стенда (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; коды те же (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, подробности — спека генератора, раздел 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 createdpaidcancelled
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» на стенде.

  • Статусы держим все три: смена createdpaid и есть причина «дыхания» выручки внутри окна; сужение до двух — резервный срез 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 сам по себе не плывёт ~12%
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(сырой строки); полный список и доводы — в доке хранилища. Урок: «какая нода читала топик — меняется между прогонами, куда легли данные — нет».
  • Приём строгий: пять опорных колонок — 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 — ~1012 файлов
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 файла
Эталонный мир пересборка снимка на месте, счётчики описи, чек-скрипты ML
Мониторинг дашборды 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 (черновик)

  1. Рождение v2: создать репозиторий (рабочее имя clickstream-data-platform), осознанный первый коммит (скелет доков, AGENTS.md, лицензия), переезд карты #10 и открытых тикетов.
  2. Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow, Superset, мониторинг), конфиги, make up, smoke-проверка ON CLUSTER.
  3. DDL и генератор (слиты в один этап — DDL проверяется только настоящими данными): базы и таблицы событий ON CLUSTER, приём hits обеими нодами; широкое событие, таксономия, анонимность, N:1 (клиентская сторона целиком). В конце этапа фиксируется маленький стартовый мир для стабильных приёмок следующих этапов (полная пересборка эталонного мира — отдельный этап 7).
  4. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
  5. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
  6. Airflow: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets).
  7. Расхождения B+D и опоздания; счётчики описи.
  8. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация на amd64 и arm64.
  9. Superset-дашборд v2.
  10. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов и границы — ADR 0002.

Что гонять на приёмке этапа — строка в карте проверок, раздел «Какую проверку когда запускать»: стенд собирается с нуля и на нём зелены все три цели, которым нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд, и молчит о его предмете — дашборд может быть не нарисован, а все три останутся зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в /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.

Kafka Engine на двух нодах снят с этого списка при приёмке этапа 2 (7 августа 2026 года) — проверенным наполовину и осознанно. Что обе ноды читают топик и обе партиции доезжают, показал #37. Дубли и раскладку партиций между прогонами решено не проверять: дубль здесь возможен по устройству движка, окно названо и разобрано в доке хранилища, а в ODS его схлопывает ReplacingMergeTree. Проверять то, что заведомо случается и заведомо обезврежено, — работа без ответа на конце.

  • Поведение соединения двух Distributed-таблиц и distributed_product_mode — эмпирически на стенде (хвост #14).
  • Размер артефакта эталонного мира после пересборки.
  • Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом сверять через MCP Context7 (правило AGENTS.md).
  • Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры проверить через Context7.
  • Генератор: рабочее решение — Python с производительной архитектурой (батчевая генерация вместо посточной, быстрая JSON-сериализация, распараллеливание по модельным дням). Читаемость генератора для менти — не довод при выборе языка: он в любом случае сложнее уровня DE-джуна. Числовые требования производительности и способ замера зафиксированы спекой генератора, раздел 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 не отдаёт».