Compare commits

...
10 Commits
Author SHA1 Message Date
ddadminandClaude Fable 5 ffd467bf43 chore(scratch): убран отработавший handoff сессии спеки v2
- Зачем:
  - handoff одноразовый (ADR-0003), работа сессии завершена: спека принята,
    v2-репозиторий рождён.
- Что:
  - удалён .scratch/handoffs/20260730-1003-spec-v2-realism-review.md.
- Проверка:
  - git status чистый.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:01:38 +03:00
ddadminandClaude Fable 5 dda4f3aeb5 docs(readme): стенд заморожен, развитие переехало в clickstream-data-platform
- Зачем:
  - исполнение спеки «Боевой реализм стенда» идёт в новом репозитории;
    читатель v1 должен сразу видеть, где продолжение, а спека — где её
    актуальная версия.
- Что:
  - README: блок-указатель у начала — стенд заморожен для новых фич,
    остаётся учебным, развитие в clickstream-data-platform.
  - docs/specs/2026-07-30-stand-v2-realism.md: строка «Источник истины
    переехал в v2» со ссылкой на копию спеки в новом репозитории.
- Проверка:
  - ссылки открываются, содержание спеки не менялось.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:00:14 +03:00
ddadminandClaude Fable 5 05e4cdea4d docs(specs): спека «Боевой реализм v2» принята владельцем (#17)
- Зачем:
  - зафиксировать вердикт владельца по трём помеченным отступлениям и
    закрыть приёмку спеки.
- Что:
  - статус Draft -> Accepted; подтверждены порядок страховочных срезов
    и VisitID как эталон самопроверки.
  - Sign взят как колонка формата без механики (всегда 1, 47-я колонка
    события); честность — комментарий в DDL, абзац в документе о
    реализме, лекционный крючок про CollapsingMergeTree.
  - в опорные точки добавлена рамка лабы сессий: сначала собрать самому,
    потом рассказ про VisitID в хитах с октября 2025.
- Проверка:
  - вычитка разделов 1.1, 1.2, 10, 12 и шапки статуса.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:24:50 +03:00
ddadminandClaude Fable 5 9d3a8053d3 docs(specs): закрыты находки внешнего ревью спеки v2 (#17)
- Зачем:
  - слепое ревью постановки нашло места, где исполнителю пришлось бы
    молча изобретать решение.
- Что:
  - зафиксированы: правило соединения purchaseID[1]=order_id, исключение
    declared_* из правила «деньги по бэкенду», имена массивов позиций
    dds.order, контракт присутствия всех 46 полей, класс awaiting_order
    в сверке, счётчик дельт сумм в манифесте.
  - шардирование в разделе 6 и dds.identity_map приведено к
    cityHash64 — буквально тем же выражением, что в 1.3.
- Проверка:
  - сверка правок с индексом находок ревью (SOL-1..SOL-8, кроме
    отклонённого SOL-7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:04:41 +03:00
ddadminandClaude Fable 5 1fccaf8599 chore(scratch): handoff по ревью и приёмке спеки v2
- Зачем:
  - сохранить состояние сессии ревью спеки #17 на случай сбоя
    инфраструктуры (ADR-0003: handoff'ы живут в .scratch/handoffs/).
- Что:
  - добавлена записка 20260730-1003: состояние спеки, открытые вопросы
    владельцу, рецепт запуска ревью Сола, грабли с 529.
- Проверка:
  - чтение записки в свежей сессии по шагам раздела «Дальше».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:00:49 +03:00
ddadminandClaude Fable 5 247ab72d26 docs(specs): добавлен черновик спеки «Боевой реализм стенда v2» (#17)
- Зачем:
  - собрать резолюции карты #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>
2026-07-30 15:00:36 +03:00
ddadmin 1fdc7be733 docs(adr): дом разработки — Gitea, GitHub — одностороннее зеркало
- Зачем:
  - вопрос о доме репозитория и трекера висел без решения после блокировки
    GitHub-аккаунта; своя площадка выигрывает по существу, а не только как
    вынужденная мера.
- Что:
  - добавлен ADR-0007: активная разработка в Gitea, GitHub — публичное
    зеркало кода (push-mirror, направление одно).
  - зафиксировано, что issues не зеркалируются, а durable-часть (спеки, ADR,
    история коммитов) уезжает в зеркало вместе с кодом.
  - репозиторий v2 создаём сразу в Gitea.
- Проверка:
  - чтением docs/adr/0007-gitea-as-development-home.md.
2026-07-29 21:49:47 +03:00
ddadmin 772a6c48c7 chore(scratch): handoff по настройке трекера на Gitea
- Зачем:
  - продолжение работы в новой сессии: контракт трекера переписан,
    граф блокировок карты #10 восстановлен, часть веток не влита.
- Что:
  - добавлен .scratch/handoffs/20260729-2125-gitea-tracker-tea-setup.md
    с состоянием карты, списком хвостов и подсказками по скиллам.
- Проверка:
  - текст читается, ссылки на docs/ и на трекер ведут в существующие места.
2026-07-29 21:32:05 +03:00
ddadmin 6c9d986114 docs(agents): контракт трекера переведён на Gitea и CLI tea
- Зачем:
  - GitHub-аккаунт заблокирован, работа идёт в Gitea на git.dementev.space,
    а инструкции для агентов всё ещё описывали GitHub Issues и gh.
- Что:
  - docs/agents/issue-tracker.md переписан под Gitea и tea 0.15.0: установка,
    вход, скоупы токена, обход прокси, команды для issues/комментариев/меток;
  - блокировки переведены на нативные зависимости Gitea через tea api,
    текстовые строки Blocked by из тел тикетов убраны;
  - GitHub ушёл одной строкой в раздел «Архив»;
  - docs/agents/triage-labels.md и блок «Agent skills» в AGENTS.md
    переобвязаны на Gitea.
- Проверка:
  - tea issues list, tea labels list — читают трекер;
  - граф блокировок карты #10 собран заново и прочитан обратно через
    tea api repos/{owner}/{repo}/issues/<n>/dependencies.
2026-07-29 21:32:05 +03:00
ddadminandClaude Fable 5 2e42cf63ff chore(scratch): сохранены слепок трекера и handoff миграции на Gitea
- Зачем:
  - GitHub-аккаунт заблокирован 2026-07-26; трекер карты «боевой
    реализм» перенесён на git.dementev.space, контекст нужен новой
    сессии независимо от исхода апелляции.
- Что:
  - .scratch/backup/ — слепок всех 27 issues и резолюция тикета #15
    на момент блокировки;
  - .scratch/handoffs/ — handoff с состоянием карты, нюансами доступа
    к Gitea и списком хвостов (апелляция, выбор основного трекера).
- Проверка:
  - тексты читаются; ссылки на Gitea-трекер открываются
    (git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:03:00 +03:00
10 changed files with 1215 additions and 36 deletions
+99
View File
@@ -0,0 +1,99 @@
## Ответ
Сумма заказа живёт **на обеих сторонах**, и стороны законно расходятся:
клиентское событие несёт объявленную сумму (то, что сайт продиктовал трекеру
через dataLayer — до пересчёта на бэкенде), заказ бэкенда — итоговую правду
со статусами. Правило стенда: **поведение и атрибуцию считаем по трекеру,
деньги — по бэкенду**. Правило выучивается на конфликте: суммы у менти
не сойдутся, и он сам раскопает почему.
### Клиентская сторона (широкое событие, рамка #18)
- Таксономия торговых событий: `pageview` + `add_to_cart` + `purchase`.
Полный словарь Метрики (`detail`, `remove`, `impressions`) не берём:
механика та же, объём массивов большой, новых идей нет — остаётся теорией
в документе о реализме.
- `purchase` несёт `purchaseID/Revenue/Currency/Coupon` + массивы `product*`
состава заказа + сырую строку `ecommerce` (по образцу выгрузки Метрики,
фактура — #27).
- `add_to_cart` — массивы `product*` с одним товаром. Оживают мёртвые
счётчики `purchases`/`add_to_carts` в `v_utm_effectiveness` без правки витрины.
### Сторона бэкенда (второй источник, рамка #18)
- **Формат выгрузки: полный ежедневный слепок заказов в пределах окна
изменяемости K дней.** Запись = снапшот заказа: `order_id`, `user_id`,
времена, `status` (`created`/`paid`/`cancelled`), `updated_at`, итоговые
суммы (товары / скидка / доставка / итого) + **позиции вложенным JSON**
(`items: [{sku, qty, price}]`) — «родной» экспорт бэкенда, а не диалект
Метрики.
- Обоснование слепка (а не инкремента): очень частый боевой формат;
идемпотентный приём и самовосстановление (пропущенный день ничего не
ломает); дедуп до последней версии становится обязательным с первого дня.
Окно K делает объём защитимым (за окном заказ неизменяем — возить незачем)
и даёт границу пересчёта: выручка дня D «дышит» K дней, потом замерзает.
- Приём: дедуп через `ReplacingMergeTree`/`argMax`; разбор JSON-позиций —
**один раз** в трансформации ODS → DDS, дальше витрины работают с плоскими
массивами. Это единственный носитель навыка «вложенный JSON в ClickHouse»
в стенде.
- В `docs/generator-realism.md` — честный абзац trade-off «инкремент экономнее,
слепок надёжнее» и сноска про compacted topic как родной Kafka-паттерн
для состояния сущности.
### Сверка
- **Ключ: клиентский `purchaseID` = `order_id` бэкенда** (магазин знает номер
заказа на `/confirmation` — как `actionField.id` у Метрики).
- Конструируемые расхождения — перечислимый список из четырёх причин,
детерминированных от seed:
- **A. Отмена** — `purchase` есть, заказ дошёл до `status='cancelled'`
(бесплатно из статусов);
- **B. Потерянное событие** — заказ есть, `purchase` не доехал
(вероятность в генераторе);
- **C. Дельта суммы** — систематическая, по построению: клиент объявляет
сумму товаров до промокода и доставки, бэкенд — итог; менти может вывести
формулу связи;
- **D. Дубль события** — повторный `purchase` от обновления
`/confirmation` (вероятность).
- Пятый эффект бесплатно даёт формат доставки: **опоздание** (заказ впервые
появляется в слепке D+1/D+2) — «вчера не сходилось, сегодня сошлось».
- Не берём: сироту-фрод (дублирует B механически) и расхождение часов
клиент/сервер — это жители тумана «Грязь в данных», придут своим тикетом.
### Слои и витрины
- DDS: одна новая сущность `dds.order` (последняя версия заказа, позиции
разобраны в массивы). `purchase` отдельной сущности не получает — это
строка широкого события.
- DM, три роли: **выручка** (`v_revenue_daily`, по категориям через `dictGet`
каталога) — строится только от заказов; **сверка**
(`v_purchase_vs_orders`) — FULL OUTER JOIN по ключу с классификатором
расхождения; **атрибуция** (`v_utm_effectiveness`) — остаётся клиентской,
плюс объявленная выручка по UTM. Точный состав колонок — в спеку (#17).
### Эталонный мир
Пересборка артефакта неизбежна и уже оплачена решением #18; этот тикет
нагружает её смыслом: манифест расширяется контрольными числами заказной
стороны — заказы и выручка по дням, и ровно N потерянных / M дублей /
K отмен для самопроверки лабы сверки. Политика версионирования артефакта
здесь не решается (пункт тумана карты).
### Ограничения исполнения и страховочные срезы
Аудитория — джун после базовой программы: расхождения — перечислимый список,
не хаос. Если при сборке спеки (#17) суммарный объём испугает, резать в
порядке: (1) статусы сузить до `created`/`cancelled` — урок дедупа держится
на самом слепке; (2) расхождения вводить поэтапно — сначала A+C, потом B+D;
(3) окно K сделать константой мира, а не параметром.
### Отклонено по дороге
Событийный лог заказов (сборка автомата — дальше от типовой работы DE),
CDC-формат (имитация Debezium без Debezium), шапка+строки (воскрешает склейку
по ключам), отдельный поток возвратов, полный словарь торговых событий
Метрики, механика Sign/CollapsingMergeTree (остаётся кандидатом на потом,
её дом — клиентская сторона, поток визитов Метрики Про, не заказы).
Фактура: `docs/research/2026-07-26-yandex-clickstream-format.md` (#27),
резолюция «Модель данных» (#18).
@@ -0,0 +1,213 @@
# Слепок трекера на 2026-07-26 (аккаунт GitHub заблокирован)
Страховочная копия wayfinder-карты и ключевых резолюций из GitHub Issues.
Снята из контекста сессии в момент блокировки аккаунта `dementev-dev`
(сразу после закрытия #15). Если аккаунт восстановят — файл можно удалить;
если нет — это источник для восстановления трекера на новом месте.
**Хвост, не доехавший до GitHub:** строка про #15 в Decisions so far карты #10
(текст — в разделе «Карта», помечен как НЕ ОПУБЛИКОВАНО).
## Состояние issues (все, на момент блокировки)
| # | Состояние | Метки | Название |
|---|---|---|---|
| 27 | CLOSED | wayfinder:research | Что отдаёт Яндекс как кликстрим: форма события и выгрузка |
| 24 | CLOSED | needs-triage | Вычистить прозаические тесты из test_world_dags_contract.py |
| 23 | CLOSED | ready-for-agent | Сверка цифр курса на живом стенде и финальная проверка |
| 22 | CLOSED | ready-for-agent | Лабы 07 (next-day) и 08 (continue) + метадокументы курса |
| 21 | CLOSED | ready-for-agent | Каркас курса и переобвязка уроков 0–6 под путь import |
| 20 | OPEN | wayfinder:task | Обновить мажорную версию airflow до версии 3 |
| 18 | CLOSED | wayfinder:grilling | Модель данных: широкое событие и второй источник |
| 17 | OPEN | wayfinder:task | Собрать спеку боевого реализма |
| 16 | OPEN | wayfinder:grilling | Анонимы и identity stitching: нужно ли и сколько |
| 15 | CLOSED (2026-07-26) | wayfinder:grilling | Purchase с выручкой: форма события и место в стенде |
| 14 | OPEN | wayfinder:grilling | Кластер: где живёт опыт менти и какая топология |
| 13 | CLOSED | wayfinder:research | Цена кластера для пайплайна |
| 12 | CLOSED | wayfinder:research | Ресурсный бюджет стенда на 16 ГБ |
| 11 | CLOSED | wayfinder:task | Реализм генератора: границы применимости стенда |
| 10 | OPEN | wayfinder:map | Карта: боевой реализм стенда |
| 9 | CLOSED | — | Редизайн пути менти: мир из артефакта и две ветки роста |
| 8 | CLOSED | wontfix | Техдолг: инкрементальный ETL вместо full_refresh на каждый день |
| 7 | CLOSED | ready-for-agent | Редизайн лаб курса под три режима менти |
| 6 | CLOSED | ready-for-agent | Один учебный профиль: daily-wave — учебный, ci — служебный |
| 5 | CLOSED | ready-for-agent | Инкрементальные счётчики manifest: next-day без перечитки всей Kafka |
| 4 | CLOSED | ready-for-agent | Поверхность DAG'ов: generator_control → world_init, беспараметрный world_next_day |
| 3 | CLOSED | ready-for-agent | Эталонный мир: 3-дневный артефакт в git и import по умолчанию |
| 2 | CLOSED | wontfix | Airflow Grid: всплывающая JS-ошибка при авто-обновлении |
| 1 | CLOSED | — | Быстрый разлогин в Airflow и Superset |
Блокировки #15 (нативные dependencies): blocked_by #11, #18 — обе закрыты.
## Карта #10 «Карта: боевой реализм стенда» (тело)
### Destination
Принятая спека в `docs/specs/` «Боевой реализм стенда»: где менти получает
кластерный опыт ClickHouse и в какой топологии; какие доработки реализма
данных генератора делаем; явные границы. Спека готова к разбиению через
`/to-tickets`.
### Notes
- Расчёт на ноутбук менти 16 ГБ RAM (у кого 8 ГБ — VDS за счёт менти).
- Исполнение доработок — после фичи «Редизайн пути менти» (#9); карта
решений может идти параллельно с ней.
- Изменения генератора тянут пересборку эталонного мира
(`data/startup_history/reference-world.json.xz`) и «поплывшие» числа
в лабах — учитывать в каждом решении.
- Скиллы: `/grilling` и `/domain-modeling` для тикетов-решений,
`/research` для тикетов-исследований.
- Рабочая гипотеза владельца: кластер здесь, облегчённо (2 шарда без
реплик), цель — Distributed и ON CLUSTER на живом потоке; «голый»
clickhouse-learning-cluster этого не даёт.
- Порядок: решение по модели данных предшествует кластерному — иначе
межшардовые джойны четырёх топиков придётся оплатить дважды.
### Decisions so far
- [Реализм генератора: границы применимости стенда](#11) — документ
`docs/generator-realism.md` (коммит 068d96f): честно как в бою —
схема/воронка/сессии/волна/обвязка; упрощено — четыре топика, только
pageview, клоны пользователей, нет «грязи», масштаб.
- [Ресурсный бюджет стенда на 16 ГБ](#12) — полный стенд в покое ≈3,4 ГБ;
2×1 добавляет ≈0,6–0,8 ГБ (влезает свободно), 2×2 — ≈1,7–1,9 ГБ (влезает,
но впритык к дефолтному бюджету WSL2 ~8 ГБ); координатором брать
clickhouse-keeper, не ZooKeeper.
- [Цена кластера для пайплайна](#13) — объём средне-крупный (~15–20 файлов,
тяжёлое — SQL); главная боль — JOIN поверх Distributed и TRUNCATE в
трансформациях (риск для контрольных сумм), приём из Kafka требует одного
консьюмера + Distributed-цели; рекомендация исследования — опциональный
`make up-cluster`, не дефолт.
- [Что отдаёт Яндекс как кликстрим](#27) — плоское широкое ядро (~140
колонок) плюс параллельные массивы для многозначного, вложенного JSON
нет (Яндекс ближе к Snowplow, чем к Segment); доставка батчем (Logs API,
TSV, лог доформировывается ~3 дня), поток только в «Метрике Про» через
Data Transfer с задержкой до 15 минут — Kafka у Яндекса нет, наша Kafka
учебная замена; детали в `docs/research/2026-07-26-yandex-clickstream-format.md`
(ветка `research/yandex-clickstream-format`, коммит `e6e34f2`).
- [Модель данных: широкое событие и второй источник](#18) — переходим на
одно широкое событие по образцу Яндекс Метрики (плоское ядро +
параллельные массивы + сырое поле `ecommerce`, таксономия event_type);
интеграционная ценность — заказы бэкенда той же Kafka, но пачками с
опозданиями и отменами (одна труба, два режима), каталог товаров —
словарь ClickHouse из файла; прямое чтение прод-Postgres и файловые
источники отклонены (файлы — зона Lakehouse-стенда); Kafka — учебная
замена батчевого Logs API, фиксируем в docs/generator-realism.md.
- **[НЕ ОПУБЛИКОВАНО — добавить при восстановлении доступа]**
[Purchase с выручкой: форма события и место в стенде](#15) — деньги на
обеих сторонах (клиент объявляет через dataLayer, бэкенд — итоговая
правда; атрибуция по трекеру, деньги по бэкенду); клиент:
`pageview`+`add_to_cart`+`purchase`; заказы — ежедневный полный слепок
окна изменяемости K со статусами и JSON-позициями, приём через
ReplacingMergeTree; сверка по `purchaseID`=`order_id` с четырьмя
конструируемыми расхождениями (отмена, потеря, дельта суммы, дубль);
выручка в DM — только от заказов.
### Not yet specified
- «Грязь» в данных: боты, дубли событий, опоздавшие мобильные батчи,
расхождение часов клиент/коллектор — вернуться после решений по
purchase и identity.
- Политика версионирования эталонного артефакта при изменениях
генератора (когда пересобирать, как жить лабам со сменой чисел).
- Как новые возможности лягут в лабы курса (после редизайна лаб, #7).
### Out of scope
- ~~Формат доставки событий: закрыть теорией~~ — решение отменено
2026-07-22: исследование цены кластера показало, что четыре топика
несовместимы с шардированием без GLOBAL JOIN; вопрос вернулся в рамку
тикетом «Модель данных: широкое событие и второй источник».
- Редизайн лаб курса — отдельный issue #7.
- Инкрементальный ETL — отдельный issue #8; багфиксы — #1, #2.
### Комментарии карты
1. Решение 2026-07-23: дальнейшая работа карты пойдёт в новом
v2-репозитории. v1 замораживается как стабильный стенд для менти
(добить путь менти, баги #1/#2, лекции). v2 стартует пустым
репозиторием с осознанным первым коммитом (переносим только нужное;
генератор переписывается, переиспользуются идеи). Имя нового
репозитория выберем из решения о нише; карта и открытые тикеты
переедут туда после создания.
2. Рабочий кандидат имени v2-репозитория: **clickstream-data-platform**
(согласован 2026-07-23). Мотив: стенд перерастает классическое DWH —
потоковый приём, оркестрация, кластер, витрины; «data platform»
описывает целое. Финальное закрепление — при решении тикета о нише.
## Тикет #15 «Purchase с выручкой: форма события и место в стенде»
### Тело
Part of #10
**Question:** Решить форму события покупки с суммой заказа: схема и носитель
(клиентское событие в широкой модели, источник заказов бэкенда или обе
стороны со сверкой — зависит от решения «Модель данных» #18), как ложится
в DDL/DM и дашборд, что делает с эталонным миром (пересборка артефакта
и чисел лаб).
Комментарий владельца: решение #18 добавило вторую сущность — заказ бэкенда
(Kafka, пачками, с опозданиями и отменами). При решении формы purchase решить
и форму события заказа, и сюжет сверки «клиентский purchase против
бэкенд-заказа». Фактура по ecommerce Яндекса — в
docs/research/2026-07-26-yandex-clickstream-format.md.
### Резолюция (опубликована 2026-07-26, тикет закрыт)
Опубликованный текст: комментарий
`issues/15#issuecomment-5084445090`. Полная копия — в
`.scratch/backup/20260726-resolution-15.md` (соседний файл).
## Тикет #18 «Модель данных: широкое событие и второй источник» (резолюция)
**Да, переходим на широкое событие.** Четыре топика-осколка уходят; модель
одна, целевая (двух моделей «для дефолта и для кластера» не держим). Мотив —
реализм: менти должен узнавать в стенде тот кликстрим, с которым столкнётся
на работе. Кластер — побочный довод, не причина.
**Форма события — по образцу Яндекс Метрики** (фактура — #27,
`docs/research/2026-07-26-yandex-clickstream-format.md`): плоское широкое
ядро плюс параллельные массивы для многозначного (товары, цели, свои
параметры) плюс одно сырое поле-строка `ecommerce`. Вложенных объектов в
стиле Segment/Amplitude не делаем. Таксономия `event_type` вместо «только
pageview». Точный состав полей — в спеку (#17), опора — таблица 53 полей
из исследования.
**Интеграционная учебная ценность** (взамен склейки осколков):
- **Второй источник — заказы бэкенда.** Вторая версия правды о покупке;
учебный сюжет — сверка клиентского purchase против заказа, расхождения,
отмены. Форма события заказа и сверка — тикет #15.
- **Транспорт заказов — та же Kafka, но пачками**: бэкенд выгружает заказы
раз в модельный день, с опозданиями и отменами. Одна труба, два режима —
как в бою, где батчи льют в брокер из удобства. Открывает темы, которых
у менти нет после курсовой airflow-greenplum: согласование потока и
батча, поздние данные, кросс-источниковые проверки, сенсоры/Datasets
(DAG слоя DDS ждёт дневной батч).
- **Каталог товаров — словарь ClickHouse из файла** (CSV в репозитории;
тот же файл использует генератор — расхождений нет по построению).
Даёт `dictGet` и политику обновления словаря.
**Ландшафт итогом:** Kafka — единственная труба (кликстрим потоком, заказы
пачками); Postgres остаётся только служебной базой Airflow; смешанность
ландшафта выражена режимами и частотами, а не второй трубой.
**Отклонено по дороге:** прямое чтение прод-базы магазина из ClickHouse
(анти-приём: нагрузка на прод и связность; в документе о реализме
зафиксировать как явный учебный пункт «в бою — реплика или выгрузка»);
файловые дропы как источник (зона Lakehouse-стенда; остаются теорией —
«настоящий Logs API — это скачанный TSV»); HTTP-сервис заказов и CDC
(Debezium) — цена выше учебной отдачи; каталог отдельным топиком Kafka —
выдумка, в бою так не делают.
**Честность к Яндексу:** у Метрики кликстрим — батч (Logs API), потока в
общем доступе нет; наша Kafka — учебная замена, так и называем в
`docs/generator-realism.md`.
**Что это открывает дальше:** #15 (purchase и форма заказа) и #14 (кластер:
с широким событием склейка осколков исчезает, ключ шардирования решается
там) разблокированы; новые приёмы стенда — ARRAY JOIN, словари,
сенсоры/Datasets, кросс-источниковый DQ; кандидат — версии записи через
Sign (механика CollapsingMergeTree из потока Метрики Про).
@@ -0,0 +1,92 @@
# Handoff: трекер переехал на Gitea, карта живёт, GitHub в блоке
Дата: 2026-07-26. Пишу по итогам сессии, в которой закрыли тикет #15
карты «Боевой реализм стенда» и экстренно переносили репозиторий с
трекером на собственный Gitea после блокировки GitHub-аккаунта.
## Что случилось и что сделано
1. **Тикет #15 «Purchase с выручкой» решён и закрыт** (шесть решений,
полная резолюция — в тикете). Гист — в Decisions so far карты #10.
2. **GitHub-аккаунт dementev-dev заблокирован** (ToS violation, причина
не названа) — сразу после закрытия #15. Апелляция готовится, см.
«Хвосты» ниже.
3. **Репозиторий и весь трекер перенесены на Gitea:**
`https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo`
(remote `gitea`, ветка `main` запушена). Все 27 номеров issues
воссозданы 1:1 (на местах PR #19/#25/#26 — закрытые заглушки, чтобы
ссылки `#NN` в текстах не разъехались).
4. **Из транскриптов прошлых сессий субагентами восстановлены дословно:**
тела #18, #20, #27; резолюции #12, #13, #15, #18, #27; граф блокировок.
Тела #14, #16, #17 — реконструкции по памяти (помечены в самих тикетах).
Резолюция #11 отдельным текстом не нашлась — её содержание есть в
`docs/generator-realism.md`.
## Состояние карты (#10 на Gitea)
- Закрыты: #11, #12, #13, #15, #18, #27 — гисты в Decisions so far.
- **Фронтир:** #14 «Кластер: где живёт опыт менти и какая топология»
(все блокеры закрыты; для него готова фактура — резолюции #12 и #13)
и #16 «Анонимы и identity stitching» (разблокирован закрытием #15).
- #17 «Собрать спеку боевого реализма» ждёт #14 и #16.
- Блокировки записаны строками `Blocked by:` в телах тикетов — нативные
dependencies в этой инсталляции Gitea выключены (API отдаёт 404).
## Как работать с Gitea (нюансы, стоившие времени)
- Хост домашний — **в обход прокси**: `curl --noproxy '*'`,
`no_proxy=git.dementev.space git push gitea ...`.
- Токен — в `~/.git-credentials` (строка ddmitry). Скоупы:
`write:repository`, `write:issue`, `write:package`. Создание репо через
API недоступно (нужен `write:user`) — создавать в UI.
- Токен не подставлять в командную строку (блокируется классификатором) —
читать из файла в python/через конфиг curl.
- `docs/agents/issue-tracker.md` всё ещё описывает GitHub/gh — пока
GitHub в блоке, рабочий трекер де-факто Gitea (операции — через
`curl`/python по API, образцы: `upload.py` и `apply_updates.py` в
scratchpad прошлой сессии; проще написать заново по образцу из этого
handoff).
## Хвосты (в порядке срочности)
1. **Апелляция в GitHub.** SMS на номера РФ не доходят (шлюз GitHub не
шлёт в РФ), попытки смены номера упёрлись в rate limit. План: спустя
~сутки — казахстанский номер (в выпадашке стран выбрать Kazakhstan);
параллельно тикет через «I can't sign in» на support.github.com
(без SMS, только почтовый код) с почты аккаунта. Черновик письма —
в прошлой сессии; суть: спросить причину, описать легитимное
использование (учебные репо, менти, Pages), упомянуть всплеск
API-активности через gh CLI как возможный триггер. Один тред, не
плодить дубли. Если ответят «multiple free accounts» — стандартный
выход: конвертировать менторскую учётку в организацию.
2. **При восстановлении GitHub:** донести в карту #10 строку про #15
(на Gitea она уже есть, на GitHub — нет), затем решить, какой трекер
основной, и синхронизировать/заморозить второй.
3. **Решение о доме трекера и v2.** Подозрение владельца: собственный
git-сервер для менторской работы — не такая плохая идея (независимость
от блокировок). Против: доступность для менти (публичные ссылки из
роадмапа, GitHub Pages де-факто витрина), привычность GitHub в резюме
менти. Это решение стоит принять осознанно — возможно, грилингом,
и оно связано с запланированным v2-репозиторием (комментарии к карте
#10: v2 стартует пустым, кандидат имени clickstream-data-platform).
4. **Резервные копии:** `.scratch/backup/20260726-*.md` — слепок трекера
и резолюция #15 на момент блокировки. После стабилизации (GitHub или
окончательный переезд) — можно удалить. Этот handoff и backup пока
не закоммичены.
## Suggested skills
- `/wayfinder #10 #14` или `/wayfinder #10 #16` — продолжать карту
(следующий тикет по выбору владельца; для #14 фактура уже собрана
в резолюциях #12/#13).
- `/grilling` + `/domain-modeling` — внутри тикетов-решений; владелец
просил на развилках сначала веер гипотез (дивергенцию), потом
конвергенцию с рекомендацией.
- `/conventional-commits` — при коммите backup/handoff.
## Ссылки
- Трекер: https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/issues
- Карта: issue #10 там же; резолюция #15 — комментарий в issue #15.
- Исследование формата Яндекса: `docs/research/2026-07-26-yandex-clickstream-format.md`.
- Границы реализма генератора: `docs/generator-realism.md`.
@@ -0,0 +1,76 @@
# Handoff: трекер настроен на Gitea и tea, граф блокировок восстановлен
Дата: 2026-07-29. Сессия была узкой: перевести контракт работы с задачами с
GitHub на Gitea, поставить CLI `tea` и убедиться, что всё это живое.
## Что сделано
1. **`tea` 0.15.0 поставлен** в `~/.local/bin/tea` (бинарник с
`dl.gitea.com`, sha256 сверена). Логин `git.dementev.space` назначен
логином по умолчанию — `tea` работает из любого каталога.
2. **Контракт переписан** — коммит `bc62b94` в ветке
`chore/gitea-tracker-config` (`docs/agents/issue-tracker.md`,
`docs/agents/triage-labels.md`, блок «Agent skills» в `AGENTS.md`).
Ветка **не влита и не запушена**.
3. **Нативные зависимости Gitea оказались рабочими.** Прошлый вывод «API
отдаёт 404» был следствием нехватки прав у старого токена. Граф
блокировок карты #10 собран заново нативными связями, текстовые строки
`Blocked by:` из тел #14, #16, #17 убраны — источник истины теперь один.
Что построено: #17#14, #15, #16; #14#12, #13, #18; #16#15.
4. **Метка `wayfinder:prototype`** заведена (не хватало; остальные
`wayfinder:*` и все пять меток триажа уже были).
5. **Прокси.** `~/dotfiles` домен покрывал, расхождение было только в
`~/.t3/userdata/settings.json` — владелец поправил сам. Короткая рабочая
форма, если переменная не подхватилась: `NO_PROXY='*' tea ...`.
6. **Старый токен отозван** владельцем. Он оставался открытым текстом в
записях разрешений `.claude/settings.local.json` — файл стоит подчистить
при случае, хотя токен уже мёртвый.
Подробности по командам, скоупам и граблям — в самом
`docs/agents/issue-tracker.md`, здесь не дублирую.
## Состояние карты «Боевой реализм стенда» (#10)
Не менялось за эту сессию, только уточнилось представление блокировок.
- Закрыты: #11, #12, #13, #15, #18, #27.
- **Фронтир:** #14 «Кластер: где живёт опыт менти и какая топология» и
#16 «Анонимы и identity stitching» — оба открыты и разблокированы.
- #17 «Собрать спеку боевого реализма» ждёт #14 и #16.
- Вне карты: #20 «Обновить мажорную версию airflow до версии 3».
## Хвосты
1. **Ветка `chore/gitea-tracker-config`** — влить в `main` (PR в Gitea или
merge локально) и запушить.
2. **Ветка `chore/gitea-migration`** — тоже не влита. В ней слепок трекера
на момент блокировки GitHub и handoff предыдущей сессии
(`.scratch/backup/`, `.scratch/handoffs/20260726-2201-*`). Решить:
влить или удалить как отработавшую.
3. **Переписка с GitHub** идёт, затянулась. Владелец считает, что основную
работу в любом случае ведём в Gitea. Открытым остаётся вопрос, что делать
с GitHub-зеркалом, когда (и если) аккаунт вернут.
4. **Решение о доме трекера и v2-репозитории** — не принято. Против Gitea:
доступность публичных ссылок для менти, привычность GitHub в их резюме.
За: независимость от блокировок. Связано с планом v2 (кандидат имени
`clickstream-data-platform`, стартует пустым).
5. **`docs/adr/0001`** упоминает GitHub Issues как отклонённый вариант — это
ADR своего времени, трогать не надо, но при чтении может сбивать.
## Suggested skills
- `/wayfinder #10 #14` или `/wayfinder #10 #16` — продолжить карту; какой
из двух, выбирает владелец. Для #14 фактура уже собрана в резолюциях
#12 и #13.
- `/grilling` внутри тикетов-решений: владелец просил на развилках сначала
веер гипотез (дивергенцию), потом конвергенцию с рекомендацией.
- `/conventional-commits` — при любом коммите в этом репозитории.
- `/github` — если дойдёт до вливания веток через PR.
## Ссылки
- Трекер: https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo/issues
- Карта — issue #10 там же.
- Контракт трекера: `docs/agents/issue-tracker.md`.
- Исследование формата Яндекса: `docs/research/2026-07-26-yandex-clickstream-format.md`.
- Границы реализма генератора: `docs/generator-realism.md`.
+2 -2
View File
@@ -41,11 +41,11 @@
### Issue tracker
GitHub Issues (через CLI `gh`). Спека фичи — файлом в `docs/specs/` (источник истины), корневой issue — тонкий, со ссылкой на спеку и чек-листом дочерних issues. См. `docs/agents/issue-tracker.md`.
Gitea на `git.dementev.space` (через CLI `tea`). Спека фичи — файлом в `docs/specs/` (источник истины), корневой issue — тонкий, со ссылкой на спеку и чек-листом дочерних issues. См. `docs/agents/issue-tracker.md`.
### Triage labels
Пять канонических ролей как метки GitHub, имена совпадают (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`.
Пять канонических ролей как метки Gitea, имена совпадают (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`.
### Domain docs
+7
View File
@@ -3,6 +3,13 @@
[![Stack](https://img.shields.io/badge/stack-Kafka%20%7C%20ClickHouse%20%7C%20Airflow%20%7C%20Superset%20%7C%20Prometheus%2FGrafana-blue)](./docker-compose.yml)
[![Layers](https://img.shields.io/badge/layers-STG%20→%20ODS%20→%20DDS%20→%20DM-green)](./docs/ARCHITECTURE.md)
> **Стенд заморожен для новых фич.** Он остаётся стабильным учебным стендом:
> что здесь работает, то работает и дальше — курс и лабы живут тут.
> Развитие переехало в
> [clickstream-data-platform](https://git.dementev.space/ddmitry/clickstream-data-platform):
> там одно широкое событие кликстрима вместо четырёх топиков, заказы бэкенда
> вторым источником и ClickHouse кластером.
Живой стек для работы с кликстримом: Kafka, ClickHouse, Airflow, Superset и мониторинг
(Prometheus с Grafana) поднимаются в Docker одной командой. На этом стенде можно учиться
по курсу или просто поднять его у себя и поэкспериментировать с потоковой загрузкой и
@@ -0,0 +1,45 @@
# ADR-0007: Дом разработки — свой Gitea, GitHub — зеркало
Принято: 2026-07-29
Статус: accepted
## Решение
Активную разработку — код, issues, PR, ревью — ведём в собственном Gitea
(`git.dementev.space`). Он остаётся домом независимо от того, чем закончится
история с блокировкой GitHub-аккаунта. GitHub держим как **публичное зеркало**
кода: туда уходит `main`, чтобы у менти и внешнего читателя была привычная
публичная ссылка.
## Контекст
Поводом стала блокировка аккаунта `dementev-dev` (2026-07-26): работа встала,
пока не подняли Gitea. Но решение принято не «назло» блокировке — своя площадка
выигрывает и по существу:
- **Свой API — без лимитов.** Агентские скиллы (wayfinder, триаж, конвейер)
дёргают трекер интенсивно. На чужом хостинге это упирается в квоты, на своём —
нет.
- **Ещё одна резервная копия.** Репозиторий физически лежит на своём железе, а
не только у поставщика услуги.
- **Ролевые игры с менти.** Форк репозитория, ветка, PR, ревью, обсуждение в
issue — весь учебный цикл «как в настоящей команде» Gitea даёт целиком, и
удобнее GitLab. Заводить менти учебные аккаунты на своём хосте можно свободно.
Против Gitea был один довод: публичность ссылок и привычность GitHub в резюме
менти. Зеркало его снимает.
## Последствия
- **Трекер один — Gitea.** Контракт уже переписан на `tea`
(`docs/agents/issue-tracker.md`). Зеркало на GitHub — только код; issues туда
не едут, и это не потеря: issue — вещь короткоживущая, а всё, что должно
пережить задачу, лежит в самом репозитории — спеки (`docs/specs/`), ADR и
история коммитов. Зеркало кода несёт эту часть целиком.
- **Зеркало настраиваем встроенным push-mirror Gitea**, направление одно:
Gitea → GitHub. Обратной синхронизации нет, чтобы не было двух источников
истины. Настройка отложена до разблокировки аккаунта.
- **Репозиторий v2** (кандидат имени `clickstream-data-platform`) создаём сразу
в Gitea, с тем же зеркалированием.
- В ADR-0001 GitHub Issues значится как отклонённый вариант — это запись своего
времени (тогда поток держали файлами в `.scratch/`), переписывать её не нужно.
+93 -31
View File
@@ -1,16 +1,52 @@
# Issue tracker: GitHub
# Issue tracker: Gitea
Задачи этого репозитория живут в GitHub Issues. Все операции — через CLI `gh`;
репозиторий `gh` определяет сам по `git remote`.
Задачи этого репозитория живут в Gitea на `git.dementev.space`
(`ddmitry/clickstream-ch-kafka-superset-demo`, это remote `origin`). Все
операции — через CLI [`tea`](https://gitea.com/gitea/tea), официальный клиент
Gitea; по устройству он близок к `gh` и `glab`. Логин и репозиторий `tea`
определяет сам по git remote в текущем каталоге.
- **Создать issue**: `gh issue create --title "..." --body "..."` (многострочное
тело — heredoc'ом).
- **Прочитать issue**: `gh issue view <номер> --comments`.
- **Список**: `gh issue list --state open --json number,title,labels` с нужными
фильтрами `--label` / `--state`.
- **Комментарий**: `gh issue comment <номер> --body "..."`.
- **Метки**: `gh issue edit <номер> --add-label "..."` / `--remove-label "..."`.
- **Закрыть**: `gh issue close <номер> --comment "..."`.
## Перед первым запуском
- **Бинарник.** Скачивается с `https://dl.gitea.com/tea/<версия>/` (файл
`tea-<версия>-linux-amd64` и `.sha256` рядом), кладётся в `~/.local/bin/tea`.
Проверка: `tea --version`.
- **Вход.** `tea logins add --name git.dementev.space --url
https://git.dementev.space`, токен передаётся переменной
`GITEA_SERVER_TOKEN` (не аргументом командной строки — он попадёт в историю
оболочки). Логин уже добавлен и назначен по умолчанию, так что `tea` работает
из любого каталога.
- **Скоупы токена:** `read:user` (без него `tea` откажется добавлять логин),
`write:issue`, `write:repository`. Токен выпускается в UI: Settings →
Applications. Нехватка скоупа выглядит не как «нет прав», а как невнятная
ошибка или пустой ответ — на этом уже один раз потеряли нативные блокировки
(решили, что их нет в установке).
- **Прокси.** Домен `dementev.space` должен быть в `NO_PROXY`, иначе запросы
уходят в прокси и виснут. В обычной оболочке это делает `proxy-client` из
`~/dotfiles`; для агента в t3 — блок `environment` в
`~/.t3/userdata/settings.json`. Если переменная не подхватилась, короткий
разовый префикс: `NO_PROXY='*' tea ...`.
## Команды
- **Создать issue:** `tea issues create --title "..." --description "..."`.
Многострочное тело удобнее собрать heredoc'ом в переменную и подставить
как `--description "$BODY"`.
- **Прочитать issue:** `tea issues <номер> --comments`.
- **Список:** `tea issues list --state open --output json --fields
index,title,labels,assignees`. Фильтры: `--labels`, `--assignee`,
`--keyword`.
- **Комментарий:** `tea comments add <номер> -d "..."`.
- **Метки:** `tea issues edit <номер> --add-labels "..."` / `--remove-labels
"..."`. Список меток репозитория — `tea labels list`, создать новую —
`tea labels create --name "..." --color "..."`.
- **Закрыть:** `tea issues close <номер>`. Комментария при закрытии команда не
принимает — сначала `tea comments add`, потом `close`.
- **Взять в работу:** `tea issues edit <номер> --add-assignees ddmitry`.
Сокращения вида `@me` в `tea` нет, имя пишется целиком.
- **Чего нет в CLI** — через `tea api <path>`: команда ходит в REST API Gitea
уже с сохранённым токеном, например
`tea api repos/ddmitry/clickstream-ch-kafka-superset-demo/issues/17`.
## Спека — источник истины
@@ -25,39 +61,65 @@
## Когда скилл говорит «опубликовать в issue tracker»
Создать GitHub issue.
Создать issue в Gitea: `tea issues create ...`.
## Когда скилл говорит «достать тикет»
`gh issue view <номер> --comments`.
`tea issues <номер> --comments`.
## PR как поверхность триажа
**Нет** — одиночный учебный репозиторий, внешних PR не ждём. (Если включить —
`/triage` начнёт гонять PR через те же метки и состояния командами `gh pr ...`.)
`/triage` начнёт гонять PR через те же метки и состояния командами
`tea pulls ...`.)
## Wayfinding-операции
Используются `/wayfinder`. Карта — один issue, тикеты — дочерние issues.
- **Карта**: issue с меткой `wayfinder:map` (Notes / Decisions-so-far / Fog в теле).
- **Дочерний тикет**: sub-issue карты (`gh api` на endpoint sub-issues); если
sub-issues недоступны — пункт task-list в теле карты + `Part of #<map>` в
начале тела тикета. Метки: `wayfinder:<type>` (`research`/`prototype`/
`grilling`/`task`).
- **Блокировки**: нативные issue dependencies —
`gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<db-id блокера>`
(`<db-id>` — числовой database id: `gh api repos/<owner>/<repo>/issues/<n> --jq .id`,
не `#номер`). Fallback — строка `Blocked by: #<n>` в начале тела. Тикет
разблокирован, когда все блокеры закрыты.
- **Карта**: issue с меткой `wayfinder:map` (Notes / Decisions-so-far / Fog
в теле).
- **Дочерний тикет**: вложенных issues в Gitea нет, поэтому связь держится
двумя ссылками — пункт списка `- [ ] #NN` в теле карты и строка
`Part of #<карта>` в начале тела тикета. Метки: `wayfinder:<тип>`
(`research` / `prototype` / `grilling` / `task`).
- **Блокировки**: нативные зависимости Gitea — единственный источник истины,
текстовых строк `Blocked by:` в телах тикетов больше нет. В CLI их команд
нет, работаем через `tea api` (`{owner}` и `{repo}` подставляются из текущего
репозитория):
- добавить блокер: `tea api repos/{owner}/{repo}/issues/<n>/dependencies
-F index=<блокер> -f owner=ddmitry -f repo=clickstream-ch-kafka-superset-demo`
— поля `owner` и `repo` обязательны, без них API отвечает
«repository does not exist»;
- кто блокирует тикет: `GET .../issues/<n>/dependencies`;
- кого блокирует тикет: `GET .../issues/<n>/blocks`;
- снять блокировку: тот же путь методом `DELETE` с тем же телом.
Тикет разблокирован, когда у всех блокеров `state == "closed"`.
- **Фронтир**: открытые дети карты минус заблокированные и назначенные; первый
в порядке карты.
- **Взять в работу**: `gh issue edit <n> --add-assignee @me`.
- **Закрыть**: комментарий с ответом, `gh issue close`, указатель на контекст —
в Decisions-so-far карты.
в порядке карты. Блокеры проверяются запросом `dependencies` по каждому
кандидату.
- **Взять в работу**: `tea issues edit <n> --add-assignees ddmitry` — первая
запись за сессию.
- **Закрыть**: `tea comments add <n> -d "<ответ>"`, затем `tea issues close
<n>`, затем указатель на контекст (суть + ссылка) в Decisions-so-far карты.
## Что проверено и когда
2026-07-29: Gitea 1.27.0, `tea` 0.15.0. Список команд и флагов снят с
`tea <команда> --help` установленного бинарника, а не из документации в вебе.
При обновлении `tea` стоит перечитать `--help`: набор флагов между версиями
менялся. Нативные зависимости и правка тел тикетов через `tea api` проверены
живыми запросами: граф блокировок карты #10 в тот день собран заново
(#17#14, #15, #16; #14#12, #13, #18; #16#15).
## Архив
До 2026-07-19 задачи велись markdown-файлами в `.scratch/<feature>/issues/`
(фичи `data-generator` и `generator-model-time-startup-history`, задачи 0121).
Не мигрированы; доступны в истории git — срез `0e312b3`.
- До 2026-07-26 трекер жил в GitHub Issues (`dementev-dev/…`). Аккаунт
заблокирован, remote `github` заморожен; все 27 номеров issues воссозданы в
Gitea один в один. Слепок трекера на момент блокировки —
`.scratch/backup/20260726-tracker-snapshot.md` (ветка
`chore/gitea-migration`).
- До 2026-07-19 задачи велись markdown-файлами в `.scratch/<feature>/issues/`
(фичи `data-generator` и `generator-model-time-startup-history`, задачи
01–21). Не мигрированы; доступны в истории git — срез `0e312b3`.
+4 -3
View File
@@ -1,9 +1,9 @@
# Triage labels
Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены с
метками GitHub Issues этого репозитория.
метками issues этого репозитория в Gitea.
| Роль в mattpocock/skills | Метка GitHub | Значение |
| Роль в mattpocock/skills | Метка Gitea | Значение |
| ------------------------ | ----------------- | ---------------------------------------------- |
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить задачу |
| `needs-info` | `needs-info` | Ждём от репортёра дополнительную информацию |
@@ -12,4 +12,5 @@
| `wontfix` | `wontfix` | Не будет сделано |
Правый столбец можно поменять под свою лексику. Сейчас — дефолт (метка = имя
роли); метки созданы в репозитории GitHub.
роли); все пять меток заведены в репозитории Gitea. Посмотреть текущий список —
`tea labels list`.
+584
View File
@@ -0,0 +1,584 @@
# Боевой реализм стенда (v2): широкое событие, заказы, кластер, анонимность
Статус: Accepted (2026-07-30). Три помеченных отступления подтверждены
владельцем на приёмке: порядок страховочных срезов (раздел 9), `Sign` как
колонка без механики (раздел 1.1), `VisitID` как эталон самопроверки (1.2).
Дата: 2026-07-30. Тикет: #17 (сборка карты #10).
Источник истины переехал в v2:
https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/specs/2026-07-30-stand-v2-realism.md
Источники: резолюции #18 (модель данных), #15 (`purchase` и заказы),
#14 (кластер), #13 (цена кластера), #16 (анонимность и склейка); исследование
[формата кликстрима Яндекса](../research/2026-07-26-yandex-clickstream-format.md);
[docs/generator-realism.md](../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` берём как колонку формата, без механики** (решение владельца на
приёмке спеки): генератор всегда пишет `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-модуль или YAML) — источник
истины: из него выводятся DDL и валидация генератора, а не наоборот. 47
колонок повторяются примерно в семи местах (генератор, 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; дальше
витрины работают с плоскими массивами `dds.order`: `item_sku`
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
— одной длины, порядок как в JSON. Это единственный носитель навыка
«вложенный JSON в ClickHouse» на стенде.
- Статусы держим все три: смена `created``paid` и есть причина «дыхания»
выручки внутри окна; сужение до двух — резервный срез 1.
## 3. Каталог товаров
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
разговор о политике обновления словаря. На кластере файл монтируется в обе
ноды, словарь создаётся ON CLUSTER.
## 4. Сверка `purchase` против заказов
Ключ: клиентский `purchaseID` = `order_id` бэкенда (магазин знает номер
заказа на `/confirmation`). У события `purchase` массив `purchaseID` несёт
ровно один элемент (одно подтверждение — один заказ), сверка соединяет по
`purchaseID[1]`; правило зафиксировать комментарием в SQL сверки.
Расхождения — перечислимый список,
детерминированный от seed, не хаос:
| | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
`duplicate_event` > `amount_delta` > `match`.
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
манифест хранит точные счётчики по каждому классу расхождений (отмены,
потери, дубли).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B.
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
бэкенду**. Единственное разрешённое исключение — клиентская оценка выручки
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей
- Email из кликстрима исчезает полностью: у события только `ClientID`.
Это честно к Logs API Метрики (UserID не выгружается). Отдельная «доля
анонимов» не нужна: мы знаем ровно тех, кто купил, — это сам урок.
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
- Форма и место карты: таблица `dds.identity_map`
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime),
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации
нет); ко-локация делает обогащение витрин локальным;
заказы при сборке карты подтягиваются через GLOBAL JOIN.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано
от seed. Ядро лабы:
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
Константа мира: каждый двухкуковый покупатель делает минимум по одному
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
(она строится только из покупок) и лаба не воспроизводится. Манифест
хранит число именно таких пар.
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
## 6. Кластер
Соседний `clickhouse-learning-cluster` остаётся разминкой при курсе: там
концепции, здесь жизнь — забыть ON CLUSTER, получить ошибку, починить.
Топология и режим — по резолюции #14:
- **2 шарда × 1 реплика + отдельный clickhouse-keeper**, единственный режим:
`make up` поднимает сразу кластер, выключателя нет. Страховка от «слишком
сложно» — отсутствие реплик и runbook, а не профиль без кластера.
- Движки локальных таблиц — `Replicated*` (макросы `{shard}`/`{replica}`,
пути keeper, готовность к будущей реплике). Без HAProxy — балансировать
нечего; в доках абзац «в бою здесь LB».
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
`cityHash64(order_id)`, сырьё STG —
`cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между
прогонами, куда легли данные — нет».
- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные
поля — без значений по умолчанию. Контракт присутствия: генератор выдаёт
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
опечатка иначе не падает.
- **Политика соединений**: по ключу ко-локации — обычное соединение с
комментарием, почему локальный результат корректен; по любому другому ключу
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
`purchase`↔заказ —
легитимная GLOBAL-витрина (заказы малы).
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
через argMax); батчевая переобработка — по дневным партициям
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` остаётся
только в `make reset`. `DROP/REPLACE PARTITION` работает только по
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
осознанная цена, не баг).
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
партиция дня слепка, без переделки старого); материализованное ниже —
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
Сессии перестраиваются только за текущий день: правило мира — сессия
режется по границе модельных суток, дневная партиция самодостаточна.
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
`insert_distributed_sync`), иначе проверки видят неполные данные.
- Все контрольные суммы и dq-проверки считают через `argMax`/`GROUP BY`/
`FINAL` — голый `count()` по ReplacingMergeTree зависит от того, сколько
мержей уже прошло.
- **Проверки и контрольные суммы — только по Distributed-таблицам**:
агрегаты от раскладки не зависят; раскладка по шардам нигде не фиксируется,
пошардовые наблюдения — исследовательские, в лабах.
### Ресурсный бюджет (#10)
Расчёт на ноутбук менти с 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` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
закрытия окна K таких строк не остаётся — сироты исключены построением
(раздел 4).
- **`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 — ~56 файлов |
| 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 (черновик)
0. Рождение v2: создать репозиторий (рабочее имя
`clickstream-data-platform`), осознанный первый коммит (скелет доков,
AGENTS.md, лицензия), переезд карты #10 и открытых тикетов.
1. Каркас стенда: кластерный compose (2×CH + keeper + Kafka, Airflow,
Superset, мониторинг), конфиги, `make up`, smoke-проверка ON CLUSTER.
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
целиком). В конце этапа фиксируется маленький «зерновой» мир для
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
отдельный этап 7).
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
батча заказов — сенсор/Datasets).
6. Расхождения B+D и опоздания; счётчики манифеста.
7. Эталонный мир: пересборка артефакта, чек-скрипты.
8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди».
Критерий приёмки этапа — честный: `make up` работает и проходят
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
(правило AGENTS.md).
## 10. Границы: чего не делаем
- «Грязь» в данных: боты, дубли на транспорте, опоздавшие мобильные батчи,
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Политика версионирования эталонного артефакта — туман карты.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
стенд работает, но учебного пути на нём ещё нет.
- Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- Полный словарь торговых событий Метрики (detail, remove, impressions),
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
визитов Метрики Про); сама колонка `Sign` уже в схеме, статикой (см. 1.1).
- Событийный лог заказов, CDC/Debezium, HTTP-сервис заказов, шапка+строки,
отдельный поток возвратов — отклонены в #15/#18.
- Файловые дропы как источник — зона следующего стенда (Lakehouse), у нас
остаются теорией; каталог отдельным топиком Kafka — в бою так не делают
(оба отклонения — из #18).
- Вероятностная склейка, identity graph, кука 1:N («семейный планшет») —
тема лекции, не лабы.
- Эмуляция `setUserID` и сюжет «логин посреди сессии» через свои параметры —
отклонены в #16: нечестно к формату выгрузки Метрики.
## 11. Проверить при исполнении
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode`
эмпирически на стенде (хвост #14).
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
прогонами, отсутствие дублей при штатной работе.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
- Размер артефакта эталонного мира после пересборки.
- Спорные 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 перестаёт быть
чистой теорией; «прямое чтение прод-базы — анти-приём, в бою — реплика или
выгрузка»; колонка `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()`, а в бою — нет; частый вопрос на собеседованиях;
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
детерминированная против вероятностной склейки, identity graph,
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
user id не отдаёт».