Merge ветки feature/data-generator в main

This commit is contained in:
ddadmin committed 2026-07-19 22:21:27 +03:00
commit 0e312b3c6c
135 files changed
+24244 -777

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
---
name: deep-reasoner
description: >
Use for reasoning-heavy phases: architecture, debugging complex issues,
algorithm design, tricky trade-offs. Think thoroughly, return a concise
conclusion the orchestrator can act on.
model: opus
effort: high
color: purple
---
# Deep Reasoner
You are the orchestrator's reasoning specialist. You are handed the hard
thinking: architecture decisions, complex debugging, algorithm design,
non-obvious trade-offs.
## Operating stance
- Think thoroughly before answering. Explore the problem, consider
alternatives, check your reasoning for holes.
- Do the deep work internally. What you return to the orchestrator is a
**concise conclusion it can act on** — not a transcript of your thinking.
- State assumptions explicitly. If a decision hinges on something you
couldn't verify, say so and give your best recommendation anyway.
- When there are real trade-offs, name them briefly, then commit to a
recommendation. The orchestrator wants a decision, not a menu.
## Output
Lead with the conclusion. Then, only if it adds value:
- the key reasoning in a few bullets,
- risks or open questions the orchestrator should know about,
- concrete next steps.
Keep it tight. Your value is the quality of the conclusion, not its length.
+31
View File
@@ -0,0 +1,31 @@
---
name: fast-worker
description: >
Use for mechanical tasks: boilerplate, tests, formatting, simple edits,
batch and file operations, running checks. Execute efficiently and
return only the result.
model: sonnet
color: green
---
# Fast Worker
You are the orchestrator's execution specialist. You are handed mechanical,
well-scoped work: boilerplate, tests, formatting, simple edits, batch and
file operations, running lint/checks.
## Operating stance
- Execute efficiently. The task is usually clear — do it, don't re-plan it.
- Follow existing conventions in the codebase: match surrounding style,
naming, and structure. Read a neighbouring file if unsure.
- If the task is genuinely ambiguous or you hit a real blocker, stop and
report back with the specific question — don't guess on decisions that
belong to the orchestrator.
- Read noisy input/output yourself and return only the distilled result,
so the orchestrator's context stays clean.
## Output
Return just what the orchestrator needs: what you did, and any result it
must see (a summary, a diff of note, a failing check). Skip narration.
+5
View File
@@ -124,6 +124,8 @@ desktop.ini
# Тестовые данные и артефакты # Тестовые данные и артефакты
# ============================================================================= # =============================================================================
# Файлы с данными (но не папки data/ — там исходные данные проекта) # Файлы с данными (но не папки data/ — там исходные данные проекта)
# JSON-артефакты генератора; сиды data/*.jsonl остаются под Git.
data/*.json
*.csv.gz *.csv.gz
*.jsonl.gz *.jsonl.gz
*.parquet *.parquet
@@ -174,3 +176,6 @@ node_modules
.playwright-cli .playwright-cli
.playwright-mcp .playwright-mcp
# Локальные настройки Claude Code (агенты .claude/agents/ коммитятся)
.claude/*
!.claude/agents/
@@ -0,0 +1,37 @@
Status: ready-for-human
# Минимальный связанный визит в новом ядре
## What to build
Построить первый проверяемый срез нового steady-stream генератора: публичный
вызов генеративного ядра создаёт один визит, который выглядит как нормальная
единица доменной модели `пользователь -> визит -> событие`.
Визит должен выпускать несколько связанных событий: один общий `click_id`,
разные `event_id`, общий device/geo-контекст и согласованные записи для четырёх
Kafka-топиков. Kafka на этом шаге не нужна: задача проверяет форму данных на
выходе генеративного ядра.
Источник решений: `docs/specs/2026-06-09-generator-rework-hierarchical.md`,
`docs/specs/2026-06-10-generator-math-model.md`, `CONTEXT.md`.
## Acceptance criteria
- [x] Тест через публичный интерфейс генератора создаёт один визит с несколькими
browser-событиями под одним `click_id`.
- [x] У всех событий визита разные валидные `event_id`, а `click_id` не берётся
из исходного сида.
- [x] Для каждого browser-события есть связанная location-запись с тем же
`event_id`.
- [x] Для каждого события визита публикуются device- и geo-записи с тем же
`click_id`; их содержимое в рамках визита одинаковое, как в сиде.
- [x] Один `click_id` связан ровно с одним `user_domain_id`.
- [x] Старый тестовый контракт "одно событие = один новый click_id" удалён или
заменён на новый контракт визита.
## Blocked by
None - can start immediately.
## Comments
@@ -0,0 +1,56 @@
Status: ready-for-human
# Уборка сервиса генератора перед активными визитами
## What to build
Разнести текущий `generator/generator.py` на понятные модули, не меняя внешнее
поведение генератора. Цель — подготовить код к задаче 03, где появятся активные
визиты между тиками.
Источник решений: `docs/specs/2026-06-11-generator-service-cleanup.md`,
`docs/specs/2026-06-10-generator-math-model.md`, задачи 01-03 в этом каталоге.
## Acceptance criteria
- [x] Генеративная модель визита отделена от Kafka, состояния, метрик и
сервисного цикла.
- [x] Код загрузки и индексации JSONL-сида вынесен из сервисного слоя.
- [x] Расчёт интенсивности отделён от генерации одного визита.
- [x] Kafka publisher, Kafka-state и создание служебных топиков не смешаны с
генеративной моделью.
- [x] `generator/generator.py` остаётся тонкой точкой входа или совместимым
фасадом, чтобы не ломать запуск и тесты без причины.
- [x] `generate_tick_batch()` не остаётся в чистой генеративной модели визита:
он вынесен в переходный тиковый слой и подготовлен к замене активными визитами
в задаче 03.
- [x] Dockerfile и запуск контейнера обновлены под новую структуру файлов.
- [x] Все существующие тесты генератора проходят.
- [x] README генератора кратко отражает новую структуру файлов.
## Non-goals
- Не реализовывать активные визиты между тиками.
- Не добавлять популяцию пользователей и возвраты.
- Не вводить состояние версии 2.
- Не менять Kafka-топики и формат событий.
- Не удалять `generator_batch_history`, если отдельно не доказано, что он больше
не нужен.
## Blocked by
- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md`
## Blocks
- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md`
## Comments
Задача появилась после разбора распухания сервиса: первые два среза уже внесли
новую модель визита, а третий срез добавит состояние активных визитов. Перед ним
нужно отделить модель, тиковый слой и Kafka-интеграцию.
2026-06-11: исходники перенесены не россыпью в `generator/`, а в пакет
`generator/src/clickstream_generator/`. `generator/generator.py` оставлен тонким
фасадом и точкой входа для совместимости.
@@ -0,0 +1,37 @@
Status: ready-for-human
# Путь визита по страницам и монотонное время
## What to build
Расширить минимальный визит до правдоподобного пути по страницам. Визит должен
начинаться со страницы, выбранной по стартовому распределению, дальше идти по
марковской цепочке и назначать событиям запланированные метки времени, которые
строго растут внутри `click_id`.
Задача остаётся без Kafka и без популяции пользователей: она проверяет качество
одного визита как учебного объекта и как основы будущей воронки.
Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел
«События и путь по страницам».
## Acceptance criteria
- [x] Тест через публичный интерфейс генератора показывает, что
`event_timestamp` внутри одного `click_id` строго возрастает.
- [x] Метки времени событий являются запланированными моментами визита, а не
одинаковым `now()` для всего набора событий.
- [x] Паузы внутри визита меньше 30 минут; 95-й перцентиль на симуляции лежит в
масштабе единиц минут.
- [x] Визит не может зациклиться бесконечно: длина ограничена потолком
`GEN_MAX_SESSION_EVENTS` или его новым эквивалентом.
- [x] Доля визитов, дошедших до `/confirmation`, на симуляции сопоставима с
ориентиром сида около 25%.
- [x] Визит может продолжаться после `/confirmation`; `/confirmation` не
считается обязательным последним событием визита.
## Blocked by
- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md`
## Comments
@@ -0,0 +1,41 @@
Status: ready-for-human
# Поток по тикам с активными визитами
## What to build
Разложить визиты по тикам генератора. Визит может жить дольше одного тика:
генератор хранит активные визиты между вызовами, выпускает только те события,
чьё запланированное время наступило, и не приклеивает все события к текущему
тику.
Задача проверяет основную потоковую механику без Kafka и без восстановления
после рестарта.
Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел
«Раскладка по тикам».
## Acceptance criteria
- [x] Тест через публичный интерфейс генератора показывает, что один `click_id`
может появляться в выходе нескольких последовательных тиков.
- [x] На каждом тике выпускаются только созревшие события активных визитов.
- [x] `event_timestamp` отражает запланированное время события, а не время
фактической отправки тика.
- [x] Завершённые визиты больше не выпускают события в следующих тиках.
- [x] При достижении потолка активных визитов новые рождения в этот тик
пропускаются, а бюджет не копится бесконечно.
- [x] Конфигурация запрещает состояние, где потолок активных визитов не меньше
потолка популяции пользователей.
## Blocked by
- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md`
- `.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md`
## Comments
2026-06-11: реализовано через `TickStreamGenerator` в тиковом слое. Проверка:
`uv run --with-requirements generator/requirements.txt pytest generator/tests -q`
— 81 passed. После ревью исправлено: входной `event_budget` снова трактуется
как бюджет событий, а не как число рождений визитов.
@@ -0,0 +1,49 @@
Status: ready-for-human
# Популяция пользователей и возвраты
## What to build
Добавить ограниченную популяцию пользователей с постоянными `user_domain_id`.
Новые визиты должны доставаться либо новым пользователям, либо возвращающимся
пользователям из популяции, доступным после кулдауна и не имеющим активного
визита.
Задача должна превратить поток из набора независимых визитов в живую модель
возвратов пользователей.
Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, разделы
«Популяция пользователей» и «Визиты».
## Acceptance criteria
- [x] На длинной симуляции получается здоровая пирамида
`users < sessions < events`.
- [x] Один пользователь может иметь несколько `click_id` во времени.
- [x] Один `click_id` принадлежит ровно одному `user_domain_id`.
- [x] Пользователь не получает новый визит раньше минимального кулдауна возврата.
- [x] Если доступных возвращающихся пользователей нет, новый визит достаётся
новому пользователю.
- [x] Размер активной популяции не растёт выше заданного потолка; при
переполнении вытесняется давно неактивный пользователь без активного визита.
## Blocked by
- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md`
## Comments
2026-06-11: реализовано через ограниченную популяцию в `TickStreamGenerator`.
Профиль пользователя переиспользуется между визитами, активный пользователь не
получает второй визит, после завершения действует `GEN_MIN_RETURN_MINUTES`.
Если доступных возвратов нет или срабатывает `GEN_P_NEW_USER`, создаётся новый
пользователь с вытеснением давно неактивного профиля без роста выше
`GEN_POPULATION_MAX`.
Проверка: `uv run --with-requirements generator/requirements.txt pytest
generator/tests -q` — 88 passed. В песочнице `/snap/bin/uv` падает на
`snap-confine`, поэтому полный pytest запускался вне песочницы.
После свежего ревью исправлены две детали: UUID теперь создаются через единый
ГПСЧ генератора, а кулдаун пользователя начинается от запланированного времени
последнего события визита, а не от времени позднего тика.
@@ -0,0 +1,55 @@
Status: ready-for-human
# Интенсивность и калибровка потока
## What to build
Связать иерархическую модель с целевой интенсивностью потока. Старая идея
Пуассона, часового коэффициента и jitter должна задавать бюджет активности, из
которого рождаются визиты; фактические события выходят по запланированным
паузам активных визитов.
На этом шаге также нужно проверить, что параметры модели дают поток,
сопоставимый с профилем сида: длина визита около 10 событий в среднем и по
медиане, доля дошедших до `/confirmation` около 25%, межсессионные паузы
согласованы с формулой из спеки.
Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, разделы
«Связка с моделью интенсивности», «Параметры» и «Критерии приёмки».
## Acceptance criteria
- [x] Средняя интенсивность событий за длинное окно соответствует
`GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением.
- [x] Рождения визитов рассчитываются из бюджета событий и средней длины визита,
а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле.
- [x] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`.
- [x] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на
дефолтах находится в районе расчёта из спеки.
- [x] Распределение длины визита на симуляции сопоставимо с сидом: медиана около
10, среднее около 10, максимум не выше потолка.
- [x] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation`
монотонно затухает на длинной симуляции.
## Blocked by
- `.scratch/feature-data-generator/issues/04-user-population-and-returns.md`
## Comments
2026-06-11: реализована калибровка потока через `TickStreamGenerator`: событийный
бюджет тика копится как бюджет рождений визитов через ожидаемую среднюю длину
визита (~10 событий), а фактические события выходят по запланированным паузам
активных визитов. Дефолт `GEN_LAMBDA_BASE_PER_MIN` в коде снижен до 30
событий/мин и синхронизирован с обычным запуском через `docker-compose.yml`;
`GEN_MIN/MAX_EVENTS_PER_TICK` теперь ограничивают событийный бюджет тика до
пересчёта в рождения визитов.
Калибровка таблицы переходов и пауз проверена тестами: средняя интенсивность
длинного окна близка к целевому бюджету, доля новых пользователей близка к
`GEN_P_NEW_USER`, межсессионные паузы не меньше кулдауна и имеют часовой
масштаб около формульного ориентира из спеки, длина визита сопоставима с сидом,
доля `/confirmation` остаётся около 25%, воронка монотонно затухает.
Проверка: `uv run --with-requirements generator/requirements.txt pytest
generator/tests -q` — 96 passed.
@@ -0,0 +1,55 @@
Status: ready-for-human
# Состояние версии 2 и рестарт
## What to build
Расширить состояние генератора до версии 2: кроме тика и состояния генератора
случайных чисел сохранять популяцию пользователей и активные визиты. После
рестарта генератор должен продолжать коротко прерванные визиты и закрывать
сильно просроченные, не теряя популяцию пользователей.
Задача проверяет поведение состояния через публичные методы сохранения и
восстановления, без реального Kafka-брокера там, где достаточно сериализации.
Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел
«Персистентность через рестарты».
## Acceptance criteria
- [x] Состояние версии 2 сериализуется в JSON и восстанавливает тик, ГПСЧ,
популяцию пользователей и активные визиты.
- [x] Старое состояние версии 1 или битое состояние не валит генератор:
фиксируется предупреждение, генератор начинает с чистого листа.
- [x] После простоя не больше 30 минут активный визит продолжается, а созревшие
события досылаются со своими исходными запланированными метками времени.
- [x] После долгого простоя просроченный активный визит закрывается без досылки
остатка.
- [x] Популяция пользователей переживает простой любой длины.
- [x] `GEN_STATE_RESET=true` явно сбрасывает состояние, как и раньше.
## Blocked by
- `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md`
## Comments
2026-06-11: задачу нельзя считать самостоятельной к реализации, пока не закрыты
предыдущие срезы генератора: 02 (путь визита и монотонное время), 03 (тики и
активные визиты), 04 (популяция и возвраты), 05 (интенсивность и калибровка).
Преждевременный кодовый задел state v2 был откатан, чтобы он не попал в коммиты
следующих задач. Возвращаться к этой задаче нужно после реализации зависимостей
и заново проверять все acceptance criteria на актуальной модели генератора.
2026-06-11: реализовано состояние версии 2 для `TickStreamGenerator`: в снимок
попадают тик, ГПСЧ, компактная популяция пользователей, компактные активные
визиты и остаток бюджета рождений визитов. `GeneratorService` теперь владеет
одним тиковым потоком, сохраняет его снимок и восстанавливает его при старте.
Короткий простой продолжает активные визиты и досылает созревшие события с
исходными метками времени; долгий простой закрывает просроченные визиты без
досылки остатка; популяция сохраняется.
Старое state v1 и битое v2-state деградируют в чистый старт с предупреждением;
`GEN_STATE_RESET=true` по-прежнему пропускает загрузку состояния. Проверка:
`uv run --with-requirements generator/requirements.txt pytest generator/tests -q`
— 112 passed.
@@ -0,0 +1,52 @@
Status: ready-for-human
# Подключение к сервису и документации
## What to build
Подключить новое генеративное ядро к рабочему steady-stream сервису. Генератор
должен запускаться прежними командами, писать сообщения в прежние Kafka-топики,
сохранять полезные метрики Prometheus и больше не документироваться как
концептуально сломанный источник.
На этом шаге нужно синхронизировать пользовательскую документацию и заметку об
известных проблемах с новой моделью.
Источник решений: `docs/specs/2026-06-09-generator-rework-hierarchical.md`,
`docs/specs/2026-06-10-generator-math-model.md`, `generator/README.md`,
`generator/KNOWN_ISSUES.md`.
## Acceptance criteria
- [x] Генератор в режиме steady-stream пишет связанные сообщения в
`browser_events`, `location_events`, `device_events`, `geo_events`.
- [x] Существующие команды запуска и остановки генератора остаются рабочими или
документация явно описывает замену.
- [x] Метрики Prometheus продолжают показывать успешные тики, ошибки публикации
и объём отправленных событий.
- [x] Интеграционный тест или проверка с мок-публикацией подтверждает, что
сервисный контур использует новую модель, а не старую плоскую генерацию.
- [x] `generator/README.md` описывает новые параметры и новую модель без старых
предупреждений о сломанном `click_id`.
- [x] `generator/KNOWN_ISSUES.md` обновлён: старый дефект закрыт или перенесён в
исторический раздел, не как актуальный блокер.
## Blocked by
- `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`
## Comments
- 2026-06-11: добавлена проверка сервисных тиков с мок-публикацией без Kafka:
steady-stream публикует несколько событий одного визита с общим `click_id`,
разными `event_id` и согласованными записями во всех четырёх топиках; история
тиков остаётся `success`.
- 2026-06-11: `docker-compose.yml` теперь пробрасывает переменные генератора
через `${VAR:-default}`, поэтому команды вида `GEN_STATE_RESET=true docker
compose up -d generator` реально меняют окружение контейнера. Внутренние
`KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` оставлены безопасными значениями
для контейнера.
- 2026-06-11: `generator/README.md` и `generator/KNOWN_ISSUES.md` синхронизированы
с новой моделью, состоянием v2 и историческим статусом старого дефекта.
- Проверка: `uv run --with-requirements generator/requirements.txt pytest
generator/tests -q` — 113 passed; `git diff --check` — без замечаний.
@@ -0,0 +1,158 @@
# Модельное время и стартовая история генератора
Status: Implemented
## Зачем
Нужно довести решение ADR-0005 и ADR-0006 до рабочего процесса: генератор живёт
по модельному времени, умеет быстро создать прошлое, сохранить слепок состояния
и продолжить поток так, чтобы результат был проверяем в ClickHouse.
Источник решений:
- `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`
- `docs/adr/0005-generator-model-clock.md`
- `docs/adr/0006-generation-as-sole-analytics-source.md`
- `docs/research/2026-06-11-subagent-coordinator-experiment.md`
## Общие правила приёмки
- Каждый кодовый срез должен заканчиваться проверкой через ClickHouse, а не
только локальными тестами генератора.
- Кодовые AFK-задачи идут через `/tdd`: один поведенческий тест, минимальная
реализация, зелёная проверка. HITL- и документные задачи идут через
обсуждение и фиксацию решения, без искусственного теста.
- Координатор не работает в `/goal` на всю цепочку. Один worker получает один
issue, не коммитит и не реализует следующие задачи.
- После реализации worker делает саморевью без правок. Координатор
классифицирует находки и возвращает только обязательные исправления.
- В задачах 03 и 05 worker заранее даёт промежуточный статус, если статистический
прогон или стендовая проверка идут долго.
- Если исправление после review gate меняет дизайн, формат state, манифест или
схему проверки, результат исправления проходит повторный review gate.
- Коммиты делает координатор после своих проверок и `git status --short`.
- Ручная проверка дашбордов глазами выполняется только в конце всей цепочки.
## Сквозные инварианты
- Рабочий контракт реализации живёт в
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`, раздел
«Рабочий контракт реализации». Задачи 02–06 берут имена настроек, формат
манифеста, границу `T_end` и правила проверки оттуда.
- `event_timestamp` — модельное время, а не настенные часы компьютера.
- Операционные метки сервиса, история пачек, метрики здоровья и длительность
тика остаются настенным временем, если отдельная задача не докажет обратное.
- Живой ход часов идёт фиксированным модельным шагом
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`; промотка прошлого тоже повторяется
точно при тех же настройках и чистом состоянии.
- При ×K модельное время и событийный бюджет идут по модельной длительности
тика, а не по реальной длительности сна процесса.
- Часовой пояс модельных часов явно задан в контракте; дневной коэффициент
считается по нему, а не по неявному локальному времени.
- После сбоя точка возобновления считается из сохранённой связки модельного и
настенного времени, а не простым `datetime.now()`.
- Стартовая история — это события плюс слепок состояния плюс манифест, чтобы не
смешать данные от разных `GEN_SEED`, `T0` и `T_end`.
- Стартовая история покрывает `[T0, T_end)`, живое продолжение начинается из
слепка на `T_end`; на стыке не должно быть дублей и дыр.
- Повторная проверка на чистом стенде должна быть воспроизводимой: либо команда
явно чистит ClickHouse, Kafka-топики данных и состояние генератора, либо
процесс идемпотентен.
## Задачи
Номер в имени файла — просто идентификатор задачи, порядок выполнения он не
задаёт. Порядок и зависимости живут здесь и в секциях «Blocked by» самих задач;
при изменении порядка файлы не переименовываются.
Сделано (ядро фичи, закрыто на триаже 2026-07-04):
- `issues/01-time-and-startup-history-contract.md` — контракт модельного
времени и стартовой истории.
- `issues/02-model-time-to-clickhouse.md` — минимальный поток по модельному
времени до ClickHouse.
- `issues/03-model-speed-and-day-factor.md` — ×K и дневной коэффициент по
модельному времени.
- `issues/04-state-v2-model-resume.md` — восстановление state v2 от модельной
точки возобновления.
- `issues/05-startup-history-backfill-to-clickhouse.md` — промотка прошлого,
стартовая история и живое продолжение до ClickHouse.
- `issues/06-generated-history-as-analytics-source.md` — штатный путь стенда
переводится на стартовую историю как источник аналитики.
- `issues/07-startup-history-portable-artifact-and-usage-docs.md` — портативный
артефакт стартовой истории и runbook.
- `issues/11-generator-launch-verbs-and-profiles.md` — глаголы, длительность и
профили запуска генератора.
- Airflow-пульт генератора, быстрый `daily-wave`, фикс фактуры визита на
восстановлении, читаемый гео-график и миграция курса на генерацию — закрыты
в очереди 2026-07-04.
- Доработки по кросс-линейному ревью 2026-07-05 закрыты в очереди 2026-07-05:
граница миров, свежий стенд и Airflow-пульт, доверенные проверки
startup-history/Superset, курс на чистом стенде. Подробности и коммиты см. в
issue-файлах и `coordinator-journal.md`.
Оставшаяся задача вне текущей очереди:
- `issues/13-...` — доливка истории от слепка; без приоритета,
`needs-triage`. После кросс-линейного ревью 2026-07-05 блокируется
задачами 15 и 17: доливка тиражирует стыки мира и должна опираться на
доверенную проверку фактической границы.
Дополнительная задача закрыта:
- `issues/19-test-and-lint-targets.md` — единые `make test` и `make lint` для
коммит-гейта; `done`. Возникло из coordinator-loop 2026-07-05:
координатору пришлось вручную собирать тестовый набор из частных команд.
Доработки по кросс-линейному ревью 2026-07-05 (закрыто):
- `issues/15-world-boundary-after-cross-review.md` — граница миров после
обновления, сброса и консольных запусков.
- `issues/16-fresh-stand-generator-control.md` — свежий стенд и Airflow-пульт
без неявных ручных шагов.
- `issues/17-trusted-checks-startup-history-superset.md` — доверенные проверки
startup-history и Superset export.
- `issues/18-course-clean-stand-startup-history.md` — курс на чистом стенде по
startup-history.
```mermaid
flowchart LR
i07["07 артефакт + runbook"] --> i11["11 глаголы запуска"]
i11 --> i12["12 DAG-пульт"]
i07 --> i08["08 миграция курса"]
i12 -. "приёмка через пульт" .-> i08
i14["14 быстрый профиль"] --> i08
i09["09 фикс стыка"] --> i13["13 доливка"]
i10["10 гео-карта"]
i15["15 граница миров"] --> i18["18 курс на чистом стенде"]
i16["16 свежий стенд"] --> i18
i17["17 доверенные проверки"] --> i18
i15 --> i13
i17 --> i13
i19["19 make test/lint"]
```
## Контрольные точки
- После задачи 3 нужен внешний review gate по сквозному инварианту времени:
проверить, где ещё остались настенные часы, и не расходятся ли живой путь,
расчёт интенсивности и сохранение состояния.
- После задачи 5 нужен внешний review gate по распределениям и двум путям
генерации: проверить форму данных, стык истории и живого продолжения,
однородность визита до и после восстановления.
Для review gate после задачи 3 reviewer другой родословной сильно желателен.
Для review gate после задачи 5 он обязателен: research показал, что форма
распределений и свойства на стыке двух путей хуже ловятся одной линией проверки.
Если такого reviewer-а нет, координатор останавливает цепочку и явно отдаёт
решение человеку.
## Финальная ручная приёмка
После задачи 6 человек смотрит Superset/Grafana и проверяет, что стенд живёт на
генерации: видны история, возвраты, воронка и суточное «дыхание». Если текущих
панелей не хватает для такого просмотра, создаётся отдельная задача на панель
или runbook, а не расширяется эта цепочка задним числом.
Миграция уроков по ADR-0006 сознательно отслеживается отдельно. Задача 6 должна
создать follow-up, если учебные материалы требуют нетривиальной переделки.
@@ -0,0 +1,577 @@
# Журнал coordinator-loop: generator-model-time-startup-history
Дата старта: 2026-07-04.
## 2026-07-04
- Старт цепочки: 07 -> 11. Дерево перед стартом чистое (`git status --short`
без вывода). Блокеры 07 (`05`, `06`) имеют `Status: done`.
- Issue 07 запущен worker-у. Режим ревью: гейт; требуется проверка по задаче и
по коду разными субагентами через `claude-team-review`.
- Issue 07: принят индекс worker-а со статусом `BLOCKED`. Unit: `uv run
--with-requirements generator/requirements.txt pytest generator/tests -q` —
PASS, 139 passed. Shell syntax: PASS. `git diff --check`: PASS. Стендовая
Docker-проверка импорта: BLOCKED, `docker` отсутствует в среде worker-а.
Review origin от worker-а: оба reviewer-а не созданы из-за sandbox/app-server
blocker. Chain-level решение: не коммитить, добрать два reviewer-индекса
отдельными субагентами координатора.
- Issue 07 review по задаче: `CHANGES_REQUESTED`. Finding: проверка после
импорта не доказывает совпадение ClickHouse с manifest/original; текущий
`generated-history-check` проверяет здоровье диапазона, но не читает
артефакт или `generator_startup_history_manifest`. Возвращено worker-у как
обязательное исправление issue 07.
- Issue 07 review по коду: `CHANGES_REQUESTED`. Findings: `state_version` из
manifest не участвует в live-восстановлении startup-history state; импорт
может задублировать data-топики при повторном или частично упавшем запуске.
Возвращено worker-у: первое чинить, второе чинить или явно классифицировать
как вне скоупа с защитой контракта clean-stand import.
- Issue 07: worker вернул `DONE` после исправлений. Unit: PASS, 142 passed.
Docker export/import/check: PASS; manifest-aware сверка ClickHouse с manifest:
PASS, `events=16054`, `visits=1516`, `users=445`. Повторный импорт на
непустых data-топиках: PASS, отказ до публикации дублей. `git diff --check`:
PASS. Chain-level решение: повторить review gate на обновлённом diff.
- Issue 07 rerun review по задаче: `APPROVED`.
- Issue 07 rerun review по коду: `CHANGES_REQUESTED`. Findings: импорт не
защищён от частичной записи data-топиков до compact state/manifest; артефакт
может не воспроизводить raw Kafka value байт-в-байт из-за повторной
сериализации dict. Возвращено worker-у как обязательная доработка или явное
уточнение контракта с тестами.
- Issue 07: worker вернул финальный индекс `DONE_WITH_CONCERNS`. Исправления:
preflight clean Kafka data-топиков, rollback import-топиков при ошибке,
raw JSON value в артефакте и публикация bytes без повторной сериализации.
Unit: PASS, 143 passed. Docker export/import/check: PASS. Raw Kafka value
compare: PASS, SHA совпал, `cmp=0`. Chain-level concern: после последних
исправлений code rerun ещё не выполнен.
- Issue 07 third code gate: `CHANGES_REQUESTED`. Finding: raw Kafka value в
артефакте всё ещё, по мнению reviewer-а, строится повторной сериализацией dict
и валидируется против той же сериализации, а не доказывает связь с фактически
опубликованными Kafka bytes. Возвращено worker-у для исправления или
аргументированного отклонения через контракт и тест.
- Issue 07 xhigh narrow code gate: `CHANGES_REQUESTED`. Findings: `raw_topics`
остаётся необязательным, поэтому артефакт без raw bytes может пройти import с
повторной сериализацией; fallback импорта через publisher без `publish_records`
игнорирует сохранённые raw records. Возвращается worker-у как обязательная
доработка.
- Issue 07: worker вернул `DONE_WITH_CONCERNS` после xhigh-находок. Исправлено:
для artifact v1.0 `raw_topics` обязательны; importer требует `publish_records`
и публикует только raw records. Unit: PASS, 146 passed. Chain-level concern:
xhigh code rerun после последней правки ещё не выполнен.
- Issue 07 xhigh code rerun: `APPROVED`. Findings: нет. Unit: PASS, 146
passed. Shell syntax: PASS. `git diff --check`: PASS. Chain-level решение:
открыть коммит-гейт 07.
- Issue 07 commit gate: staged files в границах issue 07, журнал не staged.
Unit: PASS, 146 passed. Shell syntax: PASS. `git diff --cached --check`:
PASS. Commit: `3c38465` (`feat(generator): добавлен артефакт стартовой
истории`).
- Перед issue 11: процессный журнал issue 07 отделён docs-коммитом `d2899e9`.
Дерево чистое. Блокер 11 (`07`) имеет `Status: done`.
- Issue 11 запущен worker-у. Режим ревью: обычный; требуется один code review
через `claude-team-review` после реализации. Условие эскалации: если worker
меняет state-схему, сериализацию или контракт manifest, перевести в гейт.
- Issue 11: worker вернул `BLOCKED` только из-за отсутствия reviewer mechanism.
Unit: PASS, 153 passed. Shell syntax: PASS. `git diff --check`: PASS. Dry-run
`PROFILE=daily-wave ... run_generator.sh backfill`: PASS. Chain-level
решение: добрать code review отдельным reviewer-субагентом координатора.
- Issue 11 code review: `CHANGES_REQUESTED`. Finding: профиль запуска виден в
логах, но не пишется в manifest/артефакт; критерий issue 11 требует, чтобы
выбранный профиль был виден в логах/manifest. Возвращено worker-у как
обязательное исправление.
- Issue 11: worker вернул `DONE` после исправления. `launch_profile` добавлен
в manifest/артефакт, state-схема не менялась. Unit: PASS, 154 passed.
`git diff --check`: PASS. Chain-level решение: повторить code review.
- Issue 11 code rerun: `APPROVED`. Findings: нет. `make generator-test`: PASS,
154 tests. Shell syntax: PASS. `git diff --check`: PASS. Dry-run
`backfill`/`continue`/`reset` с `PROFILE=daily-wave`: PASS. Chain-level
решение: открыть коммит-гейт 11.
- Issue 11 commit gate: Unit: PASS, 154 passed. Shell syntax: PASS.
`git diff --check`: PASS. Dry-run `PROFILE=daily-wave ... run_generator.sh
backfill`: PASS после повторного запуска вне sandbox из-за `snap-confine`
внутри sandbox.
- Issue 11 commit: `4f8f992` (`feat(generator): добавлены профили запуска`).
- Диагностика nested review tools: read-only субагент не видит
`multi_agent_v1.spawn_agent`; `tool_search` по `multi-agent spawn subagent`,
`claude-team-review`, `reviewer` не находит инструменты для запуска
субагентов. Process finding: в текущей среде worker не может сам запускать
reviewer-а; координатор должен запускать reviewer-субагентов сам и возвращать
worker-у только индекс находок.
- Final chain review запущен read-only reviewer-у в режиме `xhigh`.
- Final chain review: `CHANGES_REQUESTED`, low finding — PRD всё ещё держит
07/11 в остатке и объясняет статус 12 отсутствием глаголов. Исправлено:
07/11 перенесены в сделанное, остаток основной ветки начинается с 12.
- Уточнение диагностики nested review tools: первый диагност был read-only
explorer, что не доказывает toolset worker-а. Запущен отдельный диагност с
`agent_type=worker`, без правок, для проверки той же гипотезы в роли worker-а.
- Worker-диагност подтвердил: видит `web`, `image_gen`, `functions`,
`tool_search`, `multi_tool_use`; `tool_search` не находит multi-agent spawn
или `claude-team-review`; `multi_agent_v1.spawn_agent` недоступен. Вывод:
worker не может сам запускать reviewer-субагентов в этой среде; процесс нужно
лечить переносом запуска review-субагентов на координатора.
- 20:44 Новая очередь стартовала: 12 -> 14 -> 09 -> 10 -> 08. Дерево перед
стартом чистое (`git status --short` без вывода). Issue 12 запущен worker-у.
Режим ревью: обычный, один code-review от координатора после реализации.
Условие эскалации: если worker меняет state-схему, сериализацию,
межмодульный контракт manifest или нестабильный Airflow API без проверки
Context7, перевести issue в гейт.
- 20:55 Issue 12: worker вернул `DONE_WITH_CONCERNS`. Unit: PASS, 165 tests;
targeted DAG/control tests: PASS, 11 tests; `py_compile`: PASS;
`docker compose config --quiet`: PASS. Context7 Airflow 2.10.5: `Param`
enum/title/description и `TriggerDagRunOperator(wait_for_completion=True)`.
Concern: живой проход через Airflow UI не выполнен; готовая команда стенда
дана worker-ом. Chain-level решение: открыть обычный code-review.
- 20:55 Issue 12 code review запущен координатором: свежий observer-субагент,
рабочий уровень, без правок репозитория и среды.
- 20:59 Issue 12 code review: `CHANGES_REQUESTED`. Finding:
`startup_history_artifact.py:239` — backfill/import на непустом Kafka data
topic падает без подсказки `make clean`. Возвращено worker-у дословно.
- 21:00 Issue 12: worker вернул `DONE_WITH_CONCERNS` после ревью-находки.
Исправлено: `airflow_control.py:102` оборачивает ошибку непустых Kafka
data-топиков подсказкой `make clean`; добавлен регрессионный тест. Targeted
tests: PASS, 12 tests; unit: PASS, 166 tests; `py_compile`: PASS.
- 21:00 Process update: перечитан `references/effort-levels.md` после правки
пользователем. Для Codex CLI обычный worker на обычном issue = экономный
`medium`; любое code review и worker на гейтовом issue = рабочий `high`;
узкий спор/финальное chain review = усиленный `xhigh`. Дальнейшие запуски —
с явным уровнем по таблице.
- 21:00 Issue 12 code rerun запущен координатором: свежий observer-субагент,
рабочий уровень `high`, без правок репозитория и среды.
- 21:04 Issue 12 code rerun: `APPROVED`. Targeted tests: PASS; generator
tests: PASS. Chain-level решение: открыть коммит-гейт 12.
- 21:06 Issue 12 commit gate: staged files в границах issue 12, журнал не
staged. Unit: PASS, 166 tests; `docker compose config --quiet`: PASS;
`py_compile`: PASS; `git diff --cached --check`: PASS. Commit: `dd4af82`
(`feat(airflow): добавлен пульт управления генератором`).
- 21:07 Перед issue 14: процессный журнал issue 12 отделён docs-коммитом
`200bb82`. Дерево чистое. Блокеров нет; issue 12 имеет `Status: done`.
Issue 14 запущен worker-у. Режим ревью: обычный; worker — `medium`, code
review после реализации — `high`. Условие эскалации: изменение частоты
сохранения state, state-схемы или контракта восстановления переводит issue в
гейт.
- 21:12 Issue 14: worker вернул `DONE_WITH_CONCERNS`. `daily-wave` переведён
на `speed=60`, `tick=1`; `ci` не изменён; успешные live-тик логи приглушены
до DEBUG; state-сохранение не менялось. `make generator-test`: PASS, 167
tests. Concern: стендовая backfill/import/live-проверка и дашборд не
запускались.
- 21:12 Issue 14 code review запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:16 Issue 14 code review: `APPROVED`. Findings: нет. Reviewer checks:
profile values PASS, DAG enum from `PROFILES` PASS, peak budget below cap
PASS. `pytest` без `--with pytest` у reviewer-а не стартовал.
- 21:17 Issue 14 commit gate: staged files в границах issue 14, журнал не
staged. `make generator-test`: PASS, 167 tests; `py_compile`: PASS;
`git diff --cached --check`: PASS. Commit: `9d1bcc4`
(`feat(generator): ускорен учебный профиль daily-wave`).
- 21:18 Перед issue 09: процессный журнал issue 14 отделён docs-коммитом
`0cbfe9b`. Дерево чистое. Блокеров нет. Issue 09 запущен worker-у. Режим
ревью: гейт; worker reasoning_effort=`high`; после реализации требуются два
ревью-субагента от координатора: task review `high` и code review `high`.
- 21:33 Issue 09: worker вернул `DONE_WITH_CONCERNS`. State поднят до v3,
`base_click_id` сериализуется и валидируется; неизвестный донор — ошибка;
добавлены unit-тесты восстановления фактуры и повторяемая стыковая проверка
`CHECK_LIVE_SEAM=1`. Unit: PASS, 171 tests; `bash -n` seam script: PASS;
`git diff --check`: PASS. Concern: стендовая ClickHouse-проверка 2d
backfill + live не запускалась.
- 21:33 Issue 09 task review запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:33 Issue 09 code review запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:38 Issue 09 task review: `CHANGES_REQUESTED`. Finding:
`scripts/check_generated_analytics.sh:315` — device/os/geo проверяются через
схлопнутый `dds.click`, а критерий требует ODS-уровень; нужен `uniqExact` по
`ods.device_by_click` и `ods.geo_by_click` для переходящих `click_id`.
- 21:38 Issue 09 code review: `CHANGES_REQUESTED`. Findings:
`runtime.py:133` — микросекунды offset считаются через float и теряют 1 мкс,
тест скрывает `event_timestamp`; `Makefile:48` — обычный
`make generated-history-check` не включает seam-блок и может быть ложнозелёным.
Индексы обоих ревью возвращены worker-у дословно.
- 21:41 Issue 09: worker вернул `DONE_WITH_CONCERNS` после гейт-находок.
Исправлено: целочисленный offset микросекунд, тест снова сравнивает
`event_timestamp`, seam check auto включается после live-строк, ODS-проверка
device/os/geo добавлена, `make generated-history-check` использует auto.
Unit: PASS, 172 tests; `bash -n`: PASS; `git diff --check`: PASS.
- 21:41 Issue 09 task rerun запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:41 Issue 09 code rerun запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:45 Issue 09 code rerun: `APPROVED`. Findings: нет. Checks: timestamp
offset без float PASS; тест исключает только `event_id` PASS; seam auto и
ODS checks PASS; unknown donor error PASS; state v3 validation PASS.
- 21:45 Issue 09 task rerun: `APPROVED`. Findings: нет. Checks: all fields
except `event_id` PASS; browser/source + ODS device/os/geo seam check PASS;
`make generated-history-check` auto seam PASS; state v3 validation PASS.
Chain-level решение: открыть коммит-гейт 09.
- 21:46 Issue 09 commit gate: staged files в границах issue 09, журнал не
staged. Unit: PASS, 172 tests; `bash -n scripts/check_generated_analytics.sh`:
PASS; `git diff --cached --check`: PASS. Commit: `e2d0684`
(`fix(generator): сохранена фактура визита при восстановлении`).
- 21:47 Перед issue 10: процессный журнал issue 09 отделён docs-коммитом
`0f435d7`. Дерево чистое. Блокеров нет. Issue 10 запущен worker-у. Режим
ревью: обычный; worker reasoning_effort=`medium`; code review после
реализации — `high`. Требование: проверить Superset-визуализации через
Context7 и зафиксировать выбор.
- 21:52 Issue 10: worker вернул `DONE_WITH_CONCERNS`. `world_map` заменён на
`echarts_timeseries_bar` top-N стран; экспорт Superset, docs и урок 06
обновлены; контрактный тест добавлен. Context7 `/apache/superset`: `world_map`
legacy, ECharts bar поддерживает legend/tooltip/axis labels. Tests: PASS
dashboard config; `py_compile`: PASS; `jq`: PASS. Concern: скриншоты не
сняты, стенд остановлен.
- 21:52 Issue 10 code review запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 21:55 Issue 10 code review: `CHANGES_REQUESTED`. Findings: Superset export
`position_json` и `charts[].slice_name` не согласованы с `create_dashboard.py`;
тест проверяет только гео-чарт и может быть ложнозелёным; `previous_slice_names`
может оставить дубль старого и нового гео-чарта. Индекс возвращён worker-у.
- 21:58 Issue 10: worker вернул `DONE_WITH_CONCERNS` после ревью-находок.
Исправлено: export `position_json` и chart list синхронизированы с кодом,
добавлены тесты export/layout, rename удаляет старый дубль. Targeted tests:
PASS; `py_compile`: PASS; `jq`: PASS. Concern: визуальная приёмка не
выполнена, Superset не запущен.
- 21:58 Issue 10 code rerun запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 22:01 Issue 10 code rerun: `CHANGES_REQUESTED`. Finding:
`tests/test_superset_dashboard_config.py:1` — регрессионный тест лежит в
untracked `tests/`, рядом с `__pycache__`; тест должен попасть в изменение,
сгенерированный кэш — нет. Остальные проверки reviewer-а: export/layout,
chart list, rename dedupe, ECharts tooltip/legend/units, docs и неизменность
гео-фактуры — PASS. Индекс возвращён worker-у.
- 22:02 Issue 10: worker вернул `DONE_WITH_CONCERNS` после узкой находки.
`tests/__pycache__/` удалён; `git status --short --untracked-files=all tests`
показывает только `tests/test_superset_dashboard_config.py`. Targeted tests:
PASS; `py_compile`: PASS; `jq`: PASS. Concern: визуальная приёмка не
выполнена.
- 22:02 Issue 10 narrow code rerun запущен координатором: свежий
observer-субагент, reasoning_effort=`high`, без правок репозитория и среды.
- 22:05 Issue 10 narrow code rerun: `APPROVED`. Findings: нет. Checks:
статус `tests/` PASS; chart list/layout test content PASS; direct test
functions PASS. Chain-level решение: открыть коммит-гейт 10.
- 22:06 Issue 10 commit gate: staged files в границах issue 10, журнал не
staged. Dashboard config tests: PASS, 5 tests; `py_compile`: PASS; `jq`:
PASS; `git diff --cached --check`: PASS. Commit: `0c80e24`
(`fix(superset): заменена нечитаемая гео-карта`).
- 22:06 Перед issue 08: процессный журнал issue 10 отделён docs-коммитом
`de938ed`. Дерево чистое. Предпосылки 07, 12, 14, 09 выполнены. Issue 08
запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`;
code review после реализации — `high`. HITL-проход по урокам через пульт
ожидается как concern, если не выполнен фактически.
- 22:14 Issue 08: worker вернул `DONE_WITH_CONCERNS`. Курс и TEST_PLAN
переведены с архивного сида на стартовую историю; `data/*.jsonl` оставлены
как кладовка значений генератора; старые маркеры `make data`/`kafka_load`/
`LIMIT=`/фиксированные сид-ожидания не найдены. `git diff --check`: PASS.
Concern: нужен ручной проход уроков 00-06 через UI и оценка Superset/Grafana.
- 22:14 Issue 08 code review запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 22:17 Issue 08 code review: `CHANGES_REQUESTED`. Findings:
`docs/course/LESSON_STANDARD.md:24` — стандарт уроков всё ещё ведёт через
старый `make clean/up/ddl/data/transform` путь; `lessons/06_superset_bi.md:76`
говорит, что `generated-history-analytics` создаёт dashboard, но урок ниже
отдельно запускает `make superset-dashboard`. Индекс возвращён worker-у.
- 22:19 Issue 08: worker вернул `DONE` после ревью-находок. Исправлено:
старый путь отката в lesson standard заменён на
`make generated-history-analytics && make up`; урок 06 уточняет роль
`superset-init` и ручной `superset-dashboard`. Поиск старых маркеров: PASS;
`git diff --check`: PASS.
- 22:19 Issue 08 code rerun запущен координатором: свежий observer-субагент,
reasoning_effort=`high`, без правок репозитория и среды.
- 22:21 Issue 08 code rerun: `APPROVED`. Findings: нет. Checks: старые
команды и архивный сид как основной путь отсутствуют PASS; Superset запуск
согласован PASS; `data/*.jsonl` как кладовка значений PASS; TEST_PLAN и HITL
PASS. Chain-level решение: открыть коммит-гейт 08.
- 22:22 Issue 08 commit gate: staged files в границах issue 08, журнал не
staged. Старые маркеры: отсутствуют; `jsonl` только в целевых пояснениях;
`git diff --cached --check`: PASS. Commit: `d76c036`
(`docs(course): переведены уроки на стартовую историю`).
- 22:23 Финальное chain review запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды;
цель — проверить всю цепочку 12 -> 14 -> 09 -> 10 -> 08 против PRD и issue.
- 22:31 Финальное chain review: `CHANGES_REQUESTED`. Findings: PRD всё ещё
выглядел как Draft/остаток для закрытой очереди; закрытые issue имели
незаполненные чекбоксы; Superset docs/course ещё упоминали старую карту/World
Map рядом с новым Top Countries. Исправлено: PRD переведён в Implemented и
оставляет только issue 13 вне текущей очереди; чекбоксы закрытых issue
отмечены; старые пользовательские упоминания карты заменены. Reviewer
repro-поиски: PASS.
- 22:31 Финальное chain review rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 22:35 Финальное chain review rerun: `CHANGES_REQUESTED`. Finding:
`docs/SUPERSET_DASHBOARD.md:70` — рядом с Top Countries оставался старый
маркер `legacy world map`. Исправлено: пользовательский документ говорит о
прежней геовизуализации без старого имени; узкие поиски reviewer-а: PASS.
- 22:35 Финальное chain review second rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 22:39 Финальное chain review second rerun: `CHANGES_REQUESTED`. Findings:
закрытые issue 07 и 11 тоже имели пустые чекбоксы; в
`docs/SUPERSET_DASHBOARD.md` оставался маркер `world_map`. Исправлено:
чекбоксы всех `Status: done` issue закрыты, старый Superset-маркер убран.
Reviewer repro-поиски: PASS.
- 22:39 Финальное chain review third rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 22:42 Финальное chain review third rerun: `CHANGES_REQUESTED`. Finding:
`superset/create_dashboard.py:152` — в комментарии остался старый маркер
`Legacy world_map`. Исправлено нейтральным описанием прежней
геовизуализации. Reviewer repro-поиск по Superset markers: PASS;
`py_compile`: PASS; `git diff --check`: PASS.
- 22:42 Финальное chain review fourth rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 22:54 Финальное chain review fourth rerun: `CHANGES_REQUESTED`. Findings:
`docs/OPERATIONS.md:591` — быстрые проверки Airflow не включали
`generator_control`; `docs/course/PRD.md:99` — режим курса всё ещё сводился к
`make up`. Исправлено: быстрые проверки Airflow называют `generator_control`
основным пультом; PRD курса ведёт через
`make generated-history-analytics && make up`. Минимальные проверки: PASS.
- 22:54 Финальное chain review fifth rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 22:59 Финальное chain review fifth rerun: `CHANGES_REQUESTED`. Findings:
legacy-спека `docs/specs/2026-06-06-superset-dashboard-redesign.md` всё ещё
содержала старые Superset-маркеры `world_map`. Исправлено: спека явно
помечает геоблок как позже заменённый задачей 10. Расширенный поиск по
старым Superset-маркерам: PASS; `git diff --check`: PASS.
- 22:59 Финальное chain review sixth rerun запущено координатором: свежий
observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды.
- 23:03 Финальное chain review sixth rerun: `CHANGES_REQUESTED`. Findings:
`docs/ARCHITECTURE.md:30` — архитектура всё ещё ведёт через
`kafka_load/bootstrap` и автозапуск генератора; `docs/REPO_MAP.md:7` — карта
репозитория не упоминает `generator_control_dag.py` в ручном/учебном пути;
`CONTEXT.md:77` — статический сид всё ещё описан как демо-датасет уроков 0-6
и миграция витрин/дашбордов как незавершённая.
- 23:03 Final-review fixer запущен координатором: worker-субагент,
reasoning_effort=`medium`, цель — закрыть три проектные документные находки
`James`; запрет коммита.
- 23:06 Final-review fixer вернул `DONE`. Исправлено: `docs/ARCHITECTURE.md`
ведёт через `generator_control`/startup-history и явный `generator-continue`;
`docs/REPO_MAP.md` упоминает `generator_control_dag.py`; `CONTEXT.md`
описывает `data/*.jsonl` как кладовку значений и курс/витрины как уже
переведённые на startup-history-путь. Fixer checks: PASS.
- 23:06 Финальное chain review narrow rerun отправлено тому же reviewer-у
`James`: проверить закрытие трёх находок и новый локальный дрейф рядом.
- 23:08 Финальное chain review narrow rerun: `APPROVED`. Checks:
`docs/ARCHITECTURE.md` startup-history/`generator_control`/явный
`generator-continue` PASS; `docs/REPO_MAP.md` Airflow path PASS;
`CONTEXT.md` кладовка `jsonl` и завершённая миграция курса PASS; старые
drift-маркеры рядом отсутствуют PASS. Chain-level решение: финальный гейт
закрыт, собрать handoff.
## 2026-07-05: coordinator-loop 15-18
- 19:59 Старт цепочки 15 -> 16 -> 17 -> 18. `git status --short`: clean.
Issue 13 исключён из текущей цепочки: `needs-triage`, заблокирован 15 и 17.
Issue 15 запущен worker-у. Режим ревью: гейт; worker
reasoning_effort=`high`; task review `high`, code review `high`.
- 20:09 Issue 15: worker вернул `DONE`. Индекс: state v2 теперь даёт
миграционный отказ при `GEN_STATE_RESET=false`; `down`/`clean` охватывают
compose-профиль `live-generator`; общая host/DAG-проверка чистого стенда
подключена к backfill/import/continue; docs описывают миграционный отказ.
Checks: `make generator-test` PASS; `docker compose config --quiet` PASS;
`py_compile` PASS; `bash -n` PASS; точечный `uv run pytest ...` FAIL
из-за отсутствия `pytest` в uv-окружении.
- 20:10 Issue 15 task review запущен координатором: свежий observer-субагент
`Ptolemy`, reasoning_effort=`high`, без правок репозитория и среды.
- 20:10 Issue 15 code review запущен координатором: свежий observer-субагент
`Linnaeus`, reasoning_effort=`high`, без правок репозитория и среды.
- 20:13 Issue 15 code review `Linnaeus`: `CHANGES_REQUESTED`. Findings:
host backfill/import guard не проверяет уже живой live-generator;
`generated-history-analytics` при clean start делает `down` без профиля
`live-generator`.
- 20:13 Issue 15 task review `Ptolemy`: `CHANGES_REQUESTED`. Finding:
`generator-continue` не защищён на грязном стенде без совместимого state.
Индексы ревью возвращены worker-у дословно.
- 20:17 Issue 15: worker вернул `DONE` после находок ревью. Исправлено:
отдельный guard для `continue`; host metrics check перед backfill/import;
`generated-history-analytics` clean start использует профиль `live-generator`.
Checks: `make generator-test` PASS; `docker compose config --quiet` PASS;
`py_compile` PASS; `bash -n` PASS. Отклонений и споров нет.
- 20:17 Issue 15 task narrow rerun отправлен `Ptolemy`: проверить закрытие
находки по `generator-continue`.
- 20:17 Issue 15 code narrow rerun отправлен `Linnaeus`: проверить закрытие
находок по host live-generator guard и `generated-history-analytics` clean.
- 20:19 Issue 15 task narrow rerun `Ptolemy`: `APPROVED`. Findings: нет.
- 20:19 Issue 15 code narrow rerun `Linnaeus`: `APPROVED`. Findings: нет.
Chain-level решение: открыть коммит-гейт 15.
- 20:20 Issue 15 commit gate: `make test` и `make lint` отсутствуют в
Makefile; фактический гейт: `make generator-test` PASS (181 tests);
`docker compose config --quiet` PASS; `py_compile` PASS; `bash -n` PASS;
`git diff --check` PASS. Diff в границах issue 15; журнал не staged.
- 20:20 Issue 15 рабочий коммит: `080d5ae`
(`fix(generator): исключено смешение миров при запуске`).
- 20:22 Перед issue 16: дерево clean; dependency issue 15 закрыта. Issue 16
запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`;
code review после реализации `high`. Условие эскалации: широкая переделка
DAG-зависимостей вместо предпроверки паузы переводит issue в гейт.
- 20:28 Issue 16: worker вернул `DONE_WITH_CONCERNS`. Индекс: README/course
называют `uv` и пререквизиты; quick start добавляет `make ddl`;
`make up` пересобирает Airflow; `generator_control` проверяет pause
`etl_pipeline` перед `TriggerDagRunOperator`; решение Airflow 2.10.5
проверено через Context7 и зафиксировано рядом с выбором API; runbook
предупреждает про дыру `daily-wave` x60. Checks: red phase PASS; generator
tests PASS (186); DAG contract PASS (16); compose config PASS. Concern:
полный clean-stand проход не выполнен.
- 20:28 Issue 16 code review запущен координатором: свежий observer-субагент
`Laplace`, reasoning_effort=`high`, без правок репозитория и среды.
- 20:30 Issue 16 code review `Laplace`: `CHANGES_REQUESTED`. Finding:
pause-check `etl_pipeline` стоит после `run_backfill`, поэтому отказ
происходит уже после записи истории и повторный backfill упирается в
clean-stand guard. Индекс возвращён worker-у дословно.
- 20:33 Обновлённый `coordinator-loop` перечитан по просьбе пользователя.
Процессный вывод по тестам уточнён: шумные команды коммит-гейта дальше
идут через файл-лог, в контекст координатора попадает только сводка.
- 20:33 Issue 16: worker вернул `DONE_WITH_CONCERNS` после code review.
Исправлено: paused-check вынесен перед `precheck`/`run` для backfill/import;
контрактный тест закрепляет порядок branch -> paused-check -> precheck ->
run -> trigger ETL. Checks: DAG contract PASS; generator tests PASS (186);
compose config PASS. Concern: полный Airflow UI clean-stand сценарий не
выполнен.
- 20:33 Issue 16 code narrow rerun отправлен `Laplace`: проверить закрытие
high-находки по порядку pause-check и отсутствие нового рядом.
- 20:34 Issue 16 code narrow rerun `Laplace`: `APPROVED`. Findings: нет.
Chain-level решение: открыть коммит-гейт 16.
- 20:38 Issue 16 commit gate: `uv run ... pytest` не дошёл до pytest из-за
`snap-confine`/`cap_dac_override` в окружении координатора; log analyst
подтвердил ошибку запуска, не тестов. `make generator-test` через Docker:
PASS (186 tests) после запуска вне sandbox. `docker compose config --quiet`
PASS; `py_compile` PASS; `git diff --check` PASS. Clean-stand probe:
`make clean` PASS, `make up` FAIL на `superset-init`; log analyst finding:
`superset-init` падает с `NoSuchTableError: dm.v_events_enriched` до шага
`make ddl`. Finding возвращён worker-у как failed commit gate.
- 20:42 Issue 16: worker вернул `DONE_WITH_CONCERNS` после failed commit
gate. Исправлено: `make up` поднимает базовый набор без `superset`/
`superset-init`, quick start доходит до `make ddl`; README/OPERATIONS
фиксируют, что Superset-дэшборд собирается позже, когда DM готов; контрактный
тест ловит порядок. Checks worker-а: DAG contract PASS; compose config PASS;
`py_compile` PASS; `git diff --check` PASS; `make generator-test` PASS
(186); `make up` PASS; `make ddl` PASS. Concern: базовые контейнеры
остались запущены после проверки.
- 20:42 Issue 16 code narrow rerun отправлен `Laplace`: проверить новый
контракт `make up` без раннего `superset-init` и соседний quick start drift.
- 20:44 Issue 16 code narrow rerun `Laplace`: `APPROVED`. Findings: нет.
Chain-level решение: повторить коммит-гейт 16 после commit-gate фикса.
- 20:46 Issue 16 final commit gate: `make generator-test` PASS (186 tests);
`docker compose config --quiet` PASS; `py_compile` PASS; `git diff --check`
PASS; `make clean` PASS; `make up` PASS; `make ddl` PASS. Diff в границах
issue 16; журнал не staged. Стенд после проверки поднят в базовом состоянии
с применённым DDL.
- 20:46 Issue 16 рабочий коммит: `27947ce`
(`fix(airflow): исправлен запуск пульта на свежем стенде`).
- 20:47 Перед issue 17: дерево clean; issue 15 и 16 закрыты. Issue 17
запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`;
code review после реализации `high`.
- 20:54 Issue 17: worker вернул `DONE_WITH_CONCERNS`. Индекс:
`check_generated_analytics.sh` берёт `model_t0/model_t_end/profile` из
фактического Kafka manifest, ждёт live-строки перед seam-check и требует
ненулевые ODS device/geo; `generated-history-check` пробрасывает PROFILE;
добавлен CLI `kafka-manifest-summary`; Superset tests сравнивают полный
существенный `params`; top countries сортируется по `Events, pcs`; sync
удаляет дубли только в целевом датасете; деструктивность sync задокументирована.
Checks: generated analytics contract PASS; Superset config PASS; startup
history artifact PASS; `py_compile` PASS; `bash -n` PASS; `git diff --check`
PASS. Concern: полный `PROFILE=daily-wave CHECK_LIVE_SEAM=1 make
generated-history-check` не выполнен.
- 20:55 Issue 17 code review запущен координатором: свежий observer-субагент
`Bacon`, reasoning_effort=`high`, без правок репозитория и среды.
- 20:58 Issue 17 code review `Bacon`: `CHANGES_REQUESTED`. Findings:
Superset sync всё ещё может перезаписать/удалить одноимённый чарт вне
целевого dashboard при том же датасете; `CHECK_LIVE_SEAM=auto` может зелёно
пропустить попытку live без строк после стыка. Индекс возвращён worker-у
дословно.
- 21:02 Issue 17: worker вернул `DONE_WITH_CONCERNS` после code review.
Исправлено: Superset sync/delete ограничены чартами целевого dashboard;
обычный `generated-history-check` требует live-строки, а backfill-only путь
явно отключает seam-check. Checks: generated analytics + Superset tests PASS;
startup history artifact PASS; `bash -n` PASS; `py_compile` PASS;
`git diff --check` PASS. Отклонений и споров нет; полный runtime-прогон
ещё не выполнен.
- 21:02 Issue 17 code narrow rerun отправлен `Bacon`: проверить закрытие
находок по Superset sync ownership и live seam default.
- 21:03 Issue 17 code narrow rerun `Bacon`: `APPROVED`. Findings: нет.
Chain-level решение: открыть коммит-гейт 17.
- 21:19 Issue 17 commit gate partial: локальный `uv` не дошёл до pytest из-за
`snap-confine`/`cap_dac_override`; Docker target tests PASS (12);
`make generator-test` PASS (186); `py_compile` PASS; `bash -n` PASS;
`git diff --check` PASS. Runtime prep `PROFILE=daily-wave make
generated-history-analytics` был остановлен после долгого выполнения:
generator-run и ClickHouse были активны, но gate не завершался за разумное
время; после SIGINT running services только `clickhouse`, `kafka`. Finding
возвращён worker-у: нужен точный bounded runtime-путь или изменение проверки.
- 21:27 Issue 17: worker вернул `DONE` после runtime gate finding.
Добавлен bounded `make generated-history-runtime-check`: короткий daily-wave
backfill 1h, live через Compose env, ожидание STG growth до 25s, второй batch
и `CHECK_LIVE_SEAM=1 REQUIRE_SUPERSET=0`. Контрактный тест и OPERATIONS
обновлены. Checks worker-а: Docker target tests PASS (13); `bash -n` PASS;
`py_compile` PASS; `git diff --check` PASS; `make
generated-history-runtime-check` PASS. Ключевые факты runtime:
profile=`daily-wave`, `model_t_end=2026-01-01T01:00:00+00:00`,
STG 10256 -> 10975, crossing visits 19, ODS device/geo 19/19. Concern:
runtime gate чистит volumes и оставляет `clickhouse`, `kafka` running.
- 21:28 Issue 17 code narrow rerun отправлен `Bacon`: проверить bounded
runtime gate и отсутствие нового рядом с уже одобренными исправлениями.
- 21:29 Issue 17 code narrow rerun `Bacon`: `APPROVED`. Findings: нет.
Chain-level решение: повторить коммит-гейт 17 с bounded runtime gate.
- 21:32 Issue 17 final commit gate: Docker target tests PASS (13);
`make generator-test` PASS (186); `py_compile` PASS; `bash -n` PASS;
`git diff --check` PASS; `make generated-history-runtime-check` PASS.
Runtime facts: STG 10256 -> 10862, `manifest_launch_profile=daily-wave`,
`manifest_model_t_end=2026-01-01T01:00:00+00:00`, crossing visits 19,
ODS device/geo 19/19. Diff в границах issue 17; журнал не staged. Runtime
gate оставил `clickhouse`, `kafka` running.
- 21:32 Issue 17 рабочий коммит: `00c97eb`
(`test(generator): усилены проверки startup-history и Superset`).
- 21:33 Перед issue 18: дерево clean; dependencies 15, 16, 17 закрыты. Issue
18 запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`;
code review/docs review после реализации `high`. Примечание: runtime gate 17
оставил `clickhouse`, `kafka` running; worker должен сам очистить или
подготовить стенд перед проверкой курса.
- 21:51 Issue 18: worker вернул `DONE_WITH_CONCERNS`. Индекс: урок 00
объясняет служебные топики генератора; урок 01 переводит перезаливку
`kafka_msg_ts` на `CLEAN_START=0`; урок 05 согласован с no-live default и
consumer lag; course README добавляет HITL-пункт чистого маршрута 00/01/05;
OPERATIONS ведёт STG-путь через startup-history, `make data` архивный.
Checks worker-а: `make generated-history-analytics` PASS на чистом стенде;
`make up` PASS; SQL-проверка `kafka_msg_ts` + `CLEAN_START=0` PASS
(16054/16054); восстановление схемы PASS; Kafka topics PASS; Prometheus
targets PASS (`generator=0` no-live); `system.kafka_consumers` PASS;
`git diff --check` PASS. Concern: стенд после проверки не свежий из-за
намеренного `CLEAN_START=0`.
- 21:52 Issue 18 code/docs review запущен координатором: свежий
observer-субагент `Hume`, reasoning_effort=`high`, без правок репозитория и
среды.
- 21:53 Issue 18 code/docs review `Hume`: `APPROVED` с medium finding:
урок 05 говорит о трёх Prometheus scrape-блоках, но в `configs/prometheus.yml`
их четыре, включая `generator`; находка возвращена worker-у для короткого
триажа перед коммитом.
- 21:54 Issue 18: worker вернул `DONE` после medium finding. Исправлено:
урок 05 говорит о четырёх scrape-блоках и связывает `generator:9109` с
ожидаемым `DOWN` без live. Checks: `rg` PASS; `git diff --check` по уроку
PASS.
- 21:55 Issue 18 docs narrow rerun отправлен `Hume`: проверить закрытие
medium-находки по Prometheus scrape-блокам.
- 21:55 Issue 18 docs narrow rerun `Hume`: `APPROVED`. Findings: нет.
Chain-level решение: открыть коммит-гейт 18.
- 22:00 Issue 18 commit gate: doc `rg` PASS; `git diff --check` PASS;
`make generated-history-analytics` PASS (manifest profile `ci`,
T_end=`2026-01-01T06:00:00+00:00`, 16054 events); `make up` PASS. Diff в
границах issue 18; журнал не staged.
- 22:00 Issue 18 рабочий коммит: `77e4fb6`
(`docs(course): согласованы уроки со startup-history`).
- 22:02 Перед финальным review: добавлен follow-up issue 19 про единые
`make test`/`make lint`; PRD обновлён: 15-18 закрыты, открытыми остаются
13 (`needs-triage`) и 19 (`ready-for-agent`).
- 22:04 Финальное chain review запущено координатором: свежий
observer-субагент `Gibbs`, reasoning_effort=`xhigh`, без правок репозитория
и среды; цель — проверить цепочку 15-18 и PRD/трекер против замысла.
- 22:08 Финальное chain review `Gibbs`: `CHANGES_REQUESTED`. Finding:
docs still recommend plain `make generated-history-check` after backfill-only
`make generated-history-analytics`, but issue 17 changed default check to
require live seam. Affected docs: OPERATIONS, README, startup-history runbook.
Fixer-субагент запускается для проектных документов.
- 22:10 Final-review fixer `Peirce`: исправил README, OPERATIONS и
startup-history runbook: backfill/import-only пути используют
`CHECK_LIVE_SEAM=0`, live-проверка вынесена в
`make generated-history-runtime-check` или описана после `generator-continue`
+ `make transform`. Checks: targeted `rg` PASS; `git diff --check` PASS.
- 22:10 Финальное chain review narrow rerun отправлено `Gibbs`: проверить
закрытие finding по `generated-history-check` docs drift и новый локальный
drift рядом.
- 22:11 Финальное chain review narrow rerun `Gibbs`: `APPROVED`. Findings:
нет. Chain-level решение: финальный review закрыт, собрать handoff.
- 22:12 Handoff для кросс-линейного ревью собран:
`.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md`.
@@ -0,0 +1,236 @@
# Находки ручной HITL-проверки пути менти
Сессия 2026-07-19. Проверяем путь менти своими глазами: Airflow UI ->
Superset -> Kafka UI. Стенд чистый (`make clean` + `make up`), профиль `ci`.
## Рамочная модель: три режима менти (подтверждено, launch.py:117-137)
Основной режим менти — **загрузка из архива**, дальше две ветки от одного
восстановленного состояния (`GEN_STATE_RESET=false`). Это и есть каркас,
в который ложатся все находки ниже. Из каждой ветки можно сделать отдельную
лабораторную.
- **База: `import`** артефакта (целевой размер 3 дня, `daily-wave`) ->
мир на `T_end`. Общий фундамент обеих веток. См. F7, F8.
- **Ветка A: `next-day`** (`GEN_RUN_MODE=next-day`) — пакетно добавить
сутки. Лаба «инкрементальная обработка»: инкремент vs full-refresh,
чистота стыков, расписание. Сюда бьют F1, F3, F6, F9.
- **Ветка B: `continue`** (`GEN_RUN_MODE=live`) — непрерывный живой поток
от той же границы. Лаба «потоковый приём»: near-real-time ETL,
мониторинг Grafana, стык backfill/live. **Требует ×60** — оправдывает
скорость учебного профиля (уточнение к F8).
Важно: `next-day` и `continue` — разные педагогики, не схлопывать.
**Под каждую ветку — свой урок/лаба** (учебный контент, `docs/course/`):
одна про пакетную инкрементальную обработку (`next-day`), другая про
потоковый приём (`continue`). Общая база `import` — их совместное начало.
`backfill`/`reset` (`STATE_RESET=true`, «с нуля»): в **целевой** модели
`backfill` — инструмент мейнтейнера для сборки артефакта, а не первый шаг
менти. Сегодня ещё наоборот — backfill остаётся каноническим первым
прогоном менти (см. F1/F2); этот сдвиг и есть суть редизайна.
## F1. Форма `generator_control` помечает необязательные параметры обязательными (BUG)
- **Где:** Airflow UI -> `generator_control` -> Trigger DAG w/ config.
- **Симптом:** все поля формы (`duration`, `seed`, `model_time_speed`,
`artifact_path`, `expected_t_end`) показаны с красной `*` и обязательны.
Браузерная валидация `required` не даёт отправить форму с пустым полем.
Споткнулись первым на пустом `duration`.
- **Причина:** в `dags/generator_control_dag.py` эти `Param(...)` объявлены
с `type="string"` без `"null"`. Airflow для типа без `null` вешает на input
HTML-атрибут `required`.
- **Противоречие с документами:** `docs/OPERATIONS.md` и описания самих
Param говорят «пусто — взять из профиля / не сохранять». То есть поля
задуманы необязательными, но UI это запрещает.
- **Влияние на менти:** канонический первый прогон «backfill с профилем,
остальное пусто» через UI невозможен без обходного заполнения.
- **Кандидаты решения:** сменить тип необязательных Param на
`type=["null","string"]` (идиома Airflow для необязательной строки) —
проверить актуальность через Context7; либо, как минимум, поправить
формулировку в runbook. Предпочтителен первый.
- **Обход в этой сессии:** заполнили все поля значениями профиля `ci`
(`duration=6h`, `GEN_SEED=4242`, `GEN_MODEL_TIME_SPEED=1`,
`artifact_path=/opt/airflow/data/ci_backfill.json`,
`expected_t_end=2026-01-01T06:00:00+00:00` — для backfill игнорируется).
## F2. Superset: гео-карта заменена столбцами — ПОДТВЕРЖДЕНИЕ, не дефект
- **Наблюдение менти:** на дашборде вместо гео-карты — столбчатый
«Top Countries by Events». На первый взгляд неожиданно.
- **Проверка:** это осознанное решение задачи 10 (`done`). Legacy-виз
`world_map` (choropleth) не даёт настроить tooltip/легенду/шкалу, а
гео-распределение сильно перекошено (US-доминанта из статического сида
`geo_by_click_id`, своя генерация гео — отдельный шаг по ADR-0006).
Топ-N стран столбцами читается лучше карты. Критерии приёмки задачи 10
это фиксируют.
- **Вывод:** в UI задача 10 приземлилась корректно. Дефекта нет.
## Остальной дашборд (backfill-путь, профиль ci)
Всё сходится с данными и здорово:
- KPI 16 054 событий / 445 пользователей / Avg 10.6 / Conversion 7.1%.
- Events over Time ровный за `[00:00, 06:00)`; воронка монотонно убывает.
- «Rows by Layer» — 4 ровных столбика ~16k: события не теряются на
переходах STG->ODS->DDS->DM (наглядный контроль целостности).
- Связанные фильтры между визами работают (проверил менти глазами).
## F3. Пульт `generator_control` неудобен для менти и не годится в расписание (DESIGN)
- **Наблюдение менти:** `next-day` спрятан в универсальном DAG с дефолтом
`backfill` и тяжёлой формой; `expected_t_end` для менти — лишнее
усложнение (на автосхеме мира границу знать неоткуда).
- **Предложение менти:** сделать `next-day` отдельным DAG, который
«достаточно триггернуть, ничего не нажимая лишнего».
- **Почему важно:** это прямой вход в задачу про расписание. У планового
запуска не должно быть формы с обязательными полями и дефолта `backfill`
(см. постановку про расписание). Отдельный беспараметрный DAG `next-day`
решает и удобство менти, и пригодность к `schedule`.
- **Связка:** усиливает F1 (форма требует необязательные поля) — общий
корень в том, что один DAG обслуживает и ручной backfill, и то, что
хочется автоматизировать.
## F4. Airflow Grid: Auto-refresh Error (JS)
- **Симптом:** всплывающая ошибка `can't access property "find",
p.dagRuns is undefined` при авто-обновлении Grid во время запуска.
- **Оценка:** похоже на известный косметический баг UI Grid (гонка
авто-refresh, пока у DAG ещё нет прогонов). Работе не мешал. Проверить
версию Airflow и известные issue; при подтверждении — низкий приоритет.
## F5. Быстрый разлогин в Airflow И Superset (BUG, корень TBD)
- **Симптом:** обе веб-морды стремительно разлогинивают в рамках сессии.
- **Что исключено:** ротация секрета. `AIRFLOW__WEBSERVER__SECRET_KEY`
фиксирован (литерал по умолчанию, `AIRFLOW_SECRET_KEY` в `.env` не задан),
`SUPERSET_SECRET_KEY` — жёстко зашитый литерал. Значит, «каждый gunicorn
worker подписывает своим ключом» — не причина.
- **Куда копать:** время жизни сессии (Airflow
`session_lifetime_minutes`, Superset `PERMANENT_SESSION_LIFETIME` — в
`configs/superset` не задан), настройки cookie (SameSite/Secure на
localhost с разными портами), окружение браузера.
- **Влияние на менти:** сильно портит опыт — заставляет постоянно
перелогиниваться. Кандидат в отдельную задачу.
## F6. `next-day` собирается долго (~10,6 мин на дне 2), CPU-bound (PERF/DESIGN)
- **Наблюдение менти:** «как долго собирается следующий день… и это на
мощном процессоре».
- **Замер по метаданным Airflow (этот прогон):** задача `run_next_day` =
**638 с (~10,6 мин)** (18:03:26 -> 18:14:04). Соседние задачи мелкие:
`precheck_next_day` 15 с, `trigger_etl` (ETL full-refresh по 110k
событий) 30 с, `check_after_etl` 6 с. Узкое место — именно генерация
плюс накопительный пересчёт внутри `run_next_day`, не ETL.
- **Три причины:**
1. модельный день = 24 ч против 6 ч у backfill -> ~вчетверо больше
событий за прогон;
2. генерация — однопоточный Python, CPU-bound: много ядер не помогают,
упор в скорость одного ядра;
3. задокументированный накопительный пересчёт по всей истории Kafka
растёт с числом дней (на дне 2 мал, но копится).
- **Связка с задачей про расписание:** это ровно та причина, по которой
перед включением `schedule` нужно решить retention/формат накопительного
состояния (иначе плановый ежедневный `next-day` — растущий многоминутный
CPU-burn). Менти пощупал стоимость руками. См. F3.
## F7. Опыт менти беден на 6h; «история из файла» упирается в размер (DESIGN)
- **Мысль менти:** 6 часов backfill — мало для опыта; хотели хранить
больше и не тратить время менти на генерацию, а грузить из файла.
- **Состояние:** механизм есть — глагол `import` и runbook
`docs/runbooks/startup-history.md` (задача 07 `done`). Но готового
артефакта в репозитории нет: менти всё равно либо генерирует (медленно,
см. F6), либо ищет «где взять файл».
- **Замер:** артефакт за 6 ч = **49 МБ** (плоский JSON). Экстраполяция:
день ~200 МБ, неделя ~1,4 ГБ, месяц ~6 ГБ. В git такое не кладут.
- **Причина раздутости:** артефакт содержит и `state`, и `raw_topics` —
похоже, тащит сырые сообщения Kafka целиком (~8 МБ на модельный час).
- **Развилка (пересекается с F6/расписанием):**
1. целевой размер демо-мира (день/неделя?);
2. формат/сжатие артефакта (`xz` — см. замер ниже; нужен ли `import`-у
`raw_topics`, или хватит компактного `state`);
3. где хранить раз не git (git-lfs / релизный ассет / внешнее хранилище /
«сгенерировать один раз и закэшировать локально»).
- **Вывод:** «богатый мир из файла» и «растить мир расписанием» — один
общий вопрос: как дёшево хранить и переносить много истории. Решать
вместе. Runbook про размер/хранение сейчас молчит — дополнить.
### Решение по целевому размеру (менти, 2026-07-19, обсуждаемо)
- **Целевой стартовый размер демо-мира — 3 модельных дня.** «Больше
одного, но не слишком далеко». Срок обсуждаем.
- **Почему 3:** периодичность видна от 2 дней; 3 дают чёткий паттерн +
один «средний» день без краевых эффектов; появляется сравнение
день-к-дню в Superset. Размер ~600 МБ плоского JSON, gzip ~30–60 МБ.
- **Следствие про профиль:** на `ci` (ровная интенсивность, jitter=0)
три дня будут плоскими и скучными. Суточную волну даёт `daily-wave`.
Эталонный артефакт для менти собирать на `daily-wave`, а не `ci`.
Генерация 3 дней разово мейнтейнером — ок (backfill без сна, минуты),
менти только `import`.
### Замер сжатия (2026-07-19)
- Артефакт 6h: raw 49 МБ. **`xz -9e` → 1,1 МБ (48×), 13,5 с.** gzip -9 →
3,0 МБ (17×). xz почти втрое лучше.
- Пересчёт на 3 дня: raw ~590 МБ → **xz ~13 МБ** — кладётся в обычный git
без lfs.
- Паттерн-образец: `~/sources/airflow-greenplum-solution` — `xz -9` жмёт
сид один раз в `bookings/seed/demo.sql.xz` (в git), на загрузке
`xz -dc | ...` стримит. Переносим один в один: одноразовое медленное
сжатие мейнтейнером, потом простой git.
- Вывод менти: сжимать долго один раз не страшно; когда сид отладим —
артефакт кладём в обычный git.
## F8. Два профиля (`ci`/`daily-wave`) путают; менти нужен один (DESIGN)
- **Наблюдение менти:** планировался один удобный профиль с учебной
ценностью; зачем два — непонятно.
- **Что есть (`generator/src/clickstream_generator/launch.py`):**
- `ci` — 6h, скорость ×1, jitter=0. Профиль **автотестов/CI**: быстрый,
маленький, плоский (суточной волны не видно).
- `daily-wave` — 2d, скорость ×60 (сутки ~24 мин), задача 14
«суточная волна за минуты занятия». **Учебный** профиль с волной.
- **Где протекло:** форма backfill перечисляет профили по алфавиту,
первым идёт `ci` → менти по умолчанию подсовывается тестовый плоский
профиль. Сегодняшний прогон шёл на `ci`, оттого дашборд ровный.
- **Куда вести:** при `import` готового артефакта менти профиль не
выбирает вовсе — выбор профиля уходит мейнтейнеру (сборка артефакта на
учебном профиле) и CI. Технически можно оставить один базовый учебный
профиль (волна, ×60), а CI переопределяет длительность на 6h (backfill
без сна, скорость на генерацию артефакта не влияет). Тогда отдельный
`ci` менти не нужен.
- **Проверить перед слиянием:** не зависит ли live-тест (runtime-seam)
от скорости ×1. Если нет — профили честно схлопываются в один.
## F9. Стоимость `next-day`: параллелить не то, инкремент — то (PERF/DESIGN)
Разбор по коду (`generator/src/clickstream_generator/service.py:_run_next_day`).
- **Три куска стоимости:**
1. Генерация нового дня (строки 545–583) — O(события дня), постоянна,
не растёт.
2. Полная перечитка Kafka (строка 591, `KafkaDataTopicReader.load()` +
`ManifestCounters.add_batch`) — читает все data-топики с начала в
RAM, пересчитывает счётчики/суммы с нуля. **Растёт с историей.**
3. ETL `full_refresh` (отдельный DAG) — перестраивает DDS/DM по всему
миру. Тоже растёт.
- **Параллелить — неправильный рычаг:**
- Генерацию нельзя без потери детерминизма (один поток ГПСЧ,
переходящие сессии, шагающее модельное время; воспроизводимость по
seed — базовая ценность). И она не растёт.
- Перечитку можно раскидать по потокам, но это лишь постоянный
множитель. Корень — O(N²) по N дням (день k перечитывает k дней).
- **Оптимизировать — правильный рычаг:**
- Счётчики инкрементальные: хранить накопленное состояние
`ManifestCounters` в manifest/state, добавлять только новый день
(его `sent_counts` уже посчитаны при генерации). O(N²) -> O(N).
- Убирает и рост RAM: сейчас `reader.load()` держит всю историю в
памяти (3 дня ~неск. ГБ распарсенного JSON — риск OOM).
- Формат: уникальность (`click_ids`/`user_ids`) сейчас через `set` по
всей истории — перенести множества в state; контрольную сумму
сделать катящейся (комбинировать посуточные), не хэш всего заново.
- **Связка:** это и есть решение «новый формат накопительного состояния
manifest», которое runbook называет обязательным перед расписанием
(см. F6). Оптимизация = сердцевина retention-задачи, не отдельная.
- **Режимы:** разовая сборка артефакта — медленно не страшно;
плановый ежедневный `next-day` — инкремент обязателен (через месяц
каждый запуск перечитывал бы 30 дней).
@@ -0,0 +1,65 @@
Status: done
# Контракт модельного времени и стартовой истории
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Зафиксировать рабочий контракт для реализации модельного времени и стартовой
истории. Это HITL-срез: до кода нужно решить внешний интерфейс, формат
стартовой истории и правила восстановления, чтобы worker-и не угадывали
поведение через тесты.
Контракт должен остаться коротким и понятным: что задаёт `T0`, как задаётся ×K,
что такое `T_end`, как выглядит стартовая история, какие метки времени
модельные, а какие операционные.
## Acceptance criteria
- [x] Выбраны имена и формат настроек для `T0`, скорости ×K и режима промотки
прошлого.
- [x] Описан живой ход часов: модельное время идёт фиксированным шагом
`tick_seconds * K`; измеренный настенный интервал между тиками не входит в
текущий контракт.
- [x] Разделены уровни повторяемости: точная повторяемость промотки прошлого и
точная повторяемость живого режима при тех же настройках и числе успешных
тиков.
- [x] Описано, как после сбоя вычисляется модельная точка возобновления при ×K:
из сохранённой модельной метки, сохранённой настенной метки и скорости.
- [x] Описано, что короткий и долгий простой считаются по модельному времени:
при большом ×K короткий настенный простой может стать долгим модельным.
- [x] Описан манифест стартовой истории: минимум `GEN_SEED`, `T0`, `T_end`,
настройки генерации, версия state и контрольные числа.
- [x] Описана граница `T_end`: где заканчивается прошлое и с какой метки
начинается живое продолжение, без дублей и дыр.
- [x] Разделены модельные метки событий и настенные операционные метки сервиса.
- [x] Зафиксирован часовой пояс модельных часов для дневного коэффициента.
- [x] Решено, как координатор будет получать повторяемую ClickHouse-проверку:
через очистку данных или идемпотентный прогон.
- [x] Если выбран чистый прогон, перечислены поверхности сброса: таблицы
ClickHouse, Kafka-топики данных и состояние генератора (`generator_state` или
явный сброс состояния при старте).
- [x] Контракт записан в durable-документ: обновление `PRD.md`, короткий
design note в `.scratch/generator-model-time-startup-history/` или уточнение
спеки. Сам issue 01 только ссылается на источник истины.
## Решение
Контракт записан в durable-документ:
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`, раздел
«Рабочий контракт реализации».
PRD ссылается на этот раздел в сквозных инвариантах. Для следующих задач важная
граница: стартовая история покрывает `[T0, T_end)`, а живое продолжение стартует
из слепка на `T_end`.
Дополнительную матрицу «фиксированный шаг / измеренная настенная дельта» не
вводим: ADR-0005 не требовала такого публичного выбора, а текущей учебной
цепочке нужен один повторяемый живой ход часов.
## Blocked by
None - can start immediately
@@ -0,0 +1,51 @@
Status: done
# Модельное время до ClickHouse
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Сделать минимальный живой поток, где генератор стартует от `T0`, пишет
`event_timestamp` по модельному времени и доводит события до ClickHouse
обычным путём стенда. Срез должен доказать не внутренний класс часов, а
наблюдаемое поведение: данные в ClickHouse начинаются от модельной точки и
повторяются при тех же настройках.
## Acceptance criteria
- [x] При фиксированных `GEN_SEED` и `T0` первый короткий прогон пишет события с
модельными `event_timestamp`, начинающимися около `T0`.
- [x] Повторный чистый прогон с теми же настройками даёт те же контрольные
числа в ClickHouse по правилу повторяемости из контракта задачи 1.
- [x] Повторный чистый прогон явно сбрасывает или обходит старое состояние
генератора, чтобы сервис не продолжил прошлый запуск из `generator_state`.
- [x] Расчёт дневного коэффициента в этом срезе больше не зависит от реального
часа запуска процесса.
- [x] Локальные тесты проверяют поведение через публичный интерфейс генератора
или сервиса, без привязки к внутреннему устройству часов.
- [x] Есть команда или короткая инструкция для координатора: поднять стенд,
прогнать поток, выполнить SQL-проверку в ClickHouse.
- [x] Документация запуска не утверждает, что `event_timestamp` равен
настенному времени.
## Решение
Живой сервис получает модельную точку из `GEN_MODEL_T0`, считает дневной
коэффициент по модельному времени и передаёт эту же точку в тиковый поток. После
успешного тика модельная точка сдвигается на
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`.
Проверка ClickHouse выполнена двумя чистыми прогонами с `GEN_STATE_RESET=true`.
Оба раза получены одинаковые контрольные числа: 5 событий, диапазон
`event_ts = 2026-01-01 10:00:00.000000`, 5 уникальных `event_id` и 5 уникальных
`click_id`.
Риски по восстановлению state v2 оставлены для задачи 04: текущий срез
доказывает чистый live-старт, а не возобновление после сбоя.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md`
@@ -0,0 +1,86 @@
Status: done
# ×K и дневной коэффициент по модельному времени
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Добавить ускоренный живой ход модельных часов. При ×K за один реальный тик
должна проходить большая модельная длительность, а событийный бюджет должен
считаться по этой модельной длительности. Дневной коэффициент считается по
модельному часу.
Результат должен быть виден в ClickHouse: модельные метки уходят вперёд быстрее
реального времени, а объём событий соответствует пройденному модельному
интервалу.
## Acceptance criteria
- [x] На ×1 поведение остаётся совместимым с обычным живым режимом.
- [x] На ×K модельные `event_timestamp` за короткий реальный прогон покрывают
примерно `K` раз большую модельную длительность.
- [x] Событийный бюджет считается по модельной длительности тика, а не по
реальному времени сна процесса.
- [x] Дневной коэффициент меняется при переходе модельного времени через
дневные/ночные часы в зафиксированном часовом поясе, независимо от реального
часа запуска.
- [x] ClickHouse-проверка показывает повторяемые контрольные числа при тех же
`GEN_SEED`, `T0`, скорости и настройках.
- [x] Worker даёт промежуточный статус, если статистический прогон или стендовая
проверка занимает заметное время.
- [x] В `generator/README.md` или `docs/OPERATIONS.md` кратко описано, что ×K
ускоряет именно модельное время стенда.
## Решение
`calculate_events_count()` теперь считает λ тика по модельной длительности:
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. При `GEN_MODEL_TIME_SPEED=1`
формула остаётся прежней.
Для больших λ старый Knuth-алгоритм заменён нормальным приближением, чтобы
ускорение ×K не упиралось в underflow `exp(-λ)` и событийный бюджет продолжал
расти вместе с модельной длительностью.
Добавлены проверки:
- средний бюджет ×10 примерно в 10 раз больше бюджета ×1;
- большой λ не залипает на старом underflow-ограничении;
- `hour_factor()` берёт час из заданного `GEN_MODEL_TIMEZONE`;
- сервисный live-тик при ×10 сдвигает `event_timestamp` с `10:00` на `10:10`;
- старый live-сценарий ×1 остаётся зелёным.
Документация в `generator/README.md` и `docs/OPERATIONS.md` уточняет, что ×K
ускоряет модельную длительность тика и событийный бюджет.
## Проверка
- `make generator-test` — `120 passed`.
- ClickHouse, быстрый прогон ×60 с `GEN_STATE_RESET=true`,
`GEN_MODEL_T0=2026-01-01T05:58:00+00:00`, `GEN_TICK_SECONDS=1`: за короткий
реальный прогон `event_ts` покрыл `2026-01-01 05:58:00` …
`2026-01-01 06:09:00`; после 06:00 UTC поминутные числа выросли с `40/42` до
примерно `58..63`, что показывает смену ночного коэффициента.
- ClickHouse, повторяемость: два чистых запуска с одинаковыми
`GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T10:00:00+00:00`,
`GEN_MODEL_TIME_SPEED=60`, `GEN_TICK_SECONDS=60`,
`GEN_STATE_RESET=true` дали одинаковые контрольные числа:
`events=73`, `unique_events=73`, `unique_clicks=73`,
`min_event_ts=max_event_ts=2026-01-01 10:00:00`.
## Риски и границы
- State v2 resume, backfill, manifest и startup history не менялись. Известные
риски по ним остаются задачами 04/05.
- Сервис всё ещё пишет операционные метки state и истории пачек по настенному
времени; это соответствует текущему контракту issue 03.
- Нормальное приближение для больших λ не является точным Poisson, но для
событийного бюджета учебного демо достаточно сохраняет масштаб, среднее и
дисперсию. Точную статистическую выборку можно выделить в отдельную задачу,
если она станет учебной целью.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md`
@@ -0,0 +1,114 @@
Status: done
# Восстановление state v2 от модельной точки
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Привести восстановление state v2 к модельному времени. Один и тот же слепок
должен уметь восстанавливаться в двух разных случаях: после сбоя, где время
действительно прошло, и при старте из стартовой истории, где продолжение идёт
от `T_end` без искусственного разрыва.
Срез должен доказать поведение не только локальными тестами состояния, но и
данными в ClickHouse: активные визиты продолжаются или закрываются по правилам
модельного времени.
## Acceptance criteria
- [x] State сохраняет достаточно данных, чтобы после сбоя вычислить модельную
точку возобновления при ×K.
- [x] Восстановление после короткого модельного сбоя продолжает активные визиты
и досылает созревшие события с исходными модельными метками.
- [x] Восстановление после долгого модельного сбоя закрывает сильно просроченные
активные визиты без досылки остатка.
- [x] Восстановление из стартовой истории использует `T_end` как точку
возобновления и не обрывает активные визиты из-за настенного простоя.
- [x] Визит, переживший восстановление, остаётся однородным: события до и после
восстановления не меняют контекст визита и не создают второй путь генерации
внутри одного `click_id`.
- [x] Повреждённый или несовместимый state не валит сервис: генератор стартует
с чистого листа и пишет предупреждение.
- [x] ClickHouse-проверка подтверждает, что на стыке восстановления нет дублей
событий и нет разрыва `click_id` внутри продолжающегося визита.
- [x] После реализации выполнено саморевью worker-а и отдельное reviewer-ревью,
потому что задача меняет state/serialization и сервисное восстановление.
- [x] Если исправление после ревью меняет формат state или способ восстановления,
выполнен повторный reviewer-круг.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md`
- Review gate из `PRD.md`: сквозной инвариант времени после задачи 3
## Решение
- State v2 теперь хранит `model_timestamp`, `wall_timestamp`,
`model_time_speed`, `model_timezone`, `model_t0` и `gen_seed`.
- Старый v2 без этих полей считается несовместимым: `from_dict_safe` возвращает
`None`, сервис пишет предупреждение и стартует с чистого состояния.
- Live-восстановление считает точку продолжения по формуле
`model_timestamp + max(0, wall_now_utc - wall_timestamp) * model_time_speed`.
- Перед live-восстановлением сервис сверяет state с текущим `Config` по
`gen_seed`, `model_t0`, `model_timezone` и `model_time_speed`. Несовпадение
считается несовместимым state: сервис пишет warning и стартует с чистого
состояния.
- Восстановление из стартовой истории вынесено в минимальный метод
`GeneratorService.restore_from_startup_history(state, model_t_end)`: он берёт
переданный `T_end` напрямую и не применяет wall-дельту.
- Сохранение state после успешного тика пишет модельную точку следующего тика,
а не настенное время. Это убирает повторный старт с уже обработанной модельной
точки.
## Проверки
- Точечный прогон после проверки reviewer-а:
`docker run --rm -v ... generator:test pytest tests/test_state.py::TestGeneratorState tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_state_on_valid tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_none_on_invalid_gen_seed tests/test_service.py::TestGeneratorServiceStateV2 -v`
— 15 passed.
- `make generator-test` — 126 passed.
- `uv run pytest ...` и `uv run python -m pytest ...` не запускались до тестов:
в текущем uv-окружении нет `pytest`.
- `make lint` недоступен: в `Makefile` нет такой цели.
## ClickHouse-проверка
Выполнены два чистых стендовых сценария. Оба начинались со сброса ClickHouse,
Kafka и state генератора.
1. Короткое восстановление, `GEN_MODEL_TIME_SPEED=1`.
Первый запуск с `GEN_STATE_RESET=true` записал один тик, второй запуск с
`GEN_STATE_RESET=false` восстановился из state и продолжил активные визиты.
Проверка ClickHouse:
- `dds.event`: `rows=332`, `uniq_events=332`, `duplicate_events=0`.
- `continued_from_first_tick=67` для визитов, начатых на
`2026-01-01 10:00:00`.
- Для этих `click_id` нет смены `user_domain_id`, `device_type`, `os_name`,
`geo_country`, `geo_region_name` и `geo_timezone`.
2. Долгое восстановление, `GEN_MODEL_TIME_SPEED=3600`.
Первый запуск с `GEN_STATE_RESET=true` записал один тик на
`2026-01-01 10:00:00`, второй запуск с `GEN_STATE_RESET=false`
восстановился на модельной точке `2026-01-03T22:51:51.552000+00:00`.
Проверка ClickHouse:
- `dds.event`: `rows=200`, `uniq_events=200`, `duplicate_events=0`.
- диапазон `event_ts`: от `2026-01-01 10:00:00.000000` до
`2026-01-03 22:51:51.552000`.
- `continued_old_clicks=0`: старые `click_id` с первого тика не продолжились
после долгой модельной паузы.
## Риски
- Старые записи state v2 без модельной связки больше не восстанавливаются. Это
намеренно: без этих полей нельзя корректно посчитать точку продолжения.
- Полноценный манифест и backfill не реализованы: добавлен только минимальный
интерфейс восстановления от переданного `T_end`.
- `restore_from_startup_history` пока принимает state и `T_end` напрямую, без
проверки manifest. Это риск issue 05, где появится полноценная стартовая
история и запрет смешивания артефактов.
- `GeneratorState.__post_init__` оставляет in-memory совместимость для прямого
создания state в тестах и коде. JSON/Kafka путь остаётся строгим: отсутствие
новых полей не маскируется и ведёт к fresh start.
- После reviewer-fix формат state снова изменился (`gen_seed`). Повторный
reviewer-круг выполнен, новых блокирующих находок нет.
@@ -0,0 +1,218 @@
Status: done
# Стартовая история до ClickHouse
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Сделать промотку прошлого: генератор быстро проходит от `T0` до `T_end`, создаёт
события за этот отрезок, сохраняет слепок состояния и манифест стартовой
истории. Эту историю нужно загрузить в ClickHouse и доказать, что живой поток
может продолжить её с `T_end`.
Это главный срез стартовой истории. Он должен проверять не только факт наличия
данных, но и форму данных: пирамиду, возвраты, длину визита, воронку и стык
между прошлым и живым продолжением.
## Acceptance criteria
- [x] Промотка прошлого создаёт события за `[T0, T_end)`, слепок состояния и
манифест с контрольными данными.
- [x] При одинаковых `GEN_SEED`, `T0`, `T_end` и настройках артефакт промотки
прошлого повторяем точно; проверки в ClickHouse следуют правилам допуска из
контракта задачи 1.
- [x] История загружается в ClickHouse штатной или явно описанной командой.
- [x] SQL-проверка показывает здоровую пирамиду: пользователей меньше, чем
визитов, визитов меньше, чем событий.
- [x] SQL-проверка показывает возвраты: у части пользователей больше одного
визита.
- [x] SQL-проверка длины визита проверяет форму, а не только среднее: долю
коротких визитов, медиану и долю срезов о потолок.
- [x] SQL-проверка воронки `/home -> товары -> /cart -> /payment ->
/confirmation` монотонно убывает, а доля дошедших до `/confirmation` в
согласованном коридоре.
- [x] Живое продолжение после `T_end` не создаёт дублей на границе и не выглядит
как независимый второй мир.
- [x] Визиты, которые переходят через `T_end`, остаются однородными: контекст
визита не меняется на стыке стартовой истории и живого продолжения.
- [x] Подготовлены данные, команды и SQL-проверки, достаточные для внешнего
review gate по распределениям и двум путям генерации из `PRD.md`.
- [x] Worker даёт промежуточный статус, если промотка, загрузка в ClickHouse или
распределительные проверки занимают заметное время.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md`
## Фактический прогон worker-а
Дата проверки: 2026-06-14.
Настройки стенда:
- `GEN_SEED=4242`
- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`
- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`
- `GEN_MODEL_TIMEZONE=UTC`
- `GEN_MODEL_TIME_SPEED=1`
- `GEN_TICK_SECONDS=60`
- `GEN_LAMBDA_BASE_PER_MIN=120`
- `GEN_JITTER_PCT=0`
- `GEN_MIN_EVENTS_PER_TICK=1`
- `GEN_MAX_EVENTS_PER_TICK=1000`
Команды проверки:
```bash
make clean
docker compose up -d clickhouse kafka
make ddl
docker compose build generator
docker compose run --rm --no-deps \
-e GEN_RUN_MODE=backfill \
-e GEN_STATE_RESET=true \
-e GEN_SEED=4242 \
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
-e GEN_MODEL_TIMEZONE=UTC \
-e GEN_MODEL_TIME_SPEED=1 \
-e GEN_TICK_SECONDS=60 \
-e GEN_LAMBDA_BASE_PER_MIN=120 \
-e GEN_JITTER_PCT=0 \
-e GEN_MIN_EVENTS_PER_TICK=1 \
-e GEN_MAX_EVENTS_PER_TICK=1000 \
generator
sleep 10
bash scripts/run_batch.sh
```
Повторяемость проверена двумя чистыми прогонами с полным сбросом
ClickHouse/Kafka/state через `make clean`. В обоих прогонах manifest и ClickHouse
дали одинаковые контрольные числа:
- manifest `browser_events.checksum_sha256`:
`3b1946bd7fd3d669e2461fcbae88d8aed6980bb5f126779fb4e7480ce15a3269`
- manifest `location_events.checksum_sha256`:
`370816c8d0b0311592282b9b75372e18862db9ef4984f7032885e935f9436522`
- manifest `device_events.checksum_sha256`:
`37e157ac7d5a3203c1c27b2a11abc1b9cb9fc7f74f79c77645faa09412e08b33`
- manifest `geo_events.checksum_sha256`:
`ec0e36e4613e05b9a5e72490c7565cf7e5f767d39a7a58b383e56e2eb191fc20`
- ClickHouse digest:
`CC1A73E65E897D1F1FC982CFA4237A07`
Backfill в ClickHouse:
- `events=31825`
- `unique_events=31825`
- `visits=3020`
- `users=1048`
- `min_event_ts=2026-01-01 00:00:00.000000`
- `max_event_ts=2026-01-01 05:59:59.521599`
- `pyramid_ok=1`
- `half_open_ok=1`
Проверка возвратов:
- `users=1048`
- `returning_users=708`
- `returning_share=0.6755725190839694`
- `max_visits_per_user=11`
Форма длины визита:
- `visits=3020`
- `short_visit_share=0.16490066225165562`
- `median_events_per_visit=8`
- `avg_events_per_visit=10.538079470198676`
- `capped_visit_share=0.0619205298013245`
- `median_duration_sec=202`
- `p95_duration_sec=823`
- `max_events_per_visit=30`
Воронка:
- строгая упорядоченная:
`home=2743`, `products=1626`, `cart=995`, `payment=673`,
`confirmation=394`, `monotonic_ok=1`,
`ordered_confirmation_share=0.14363835216915785`;
- калибровочная по наличию страницы в визите:
`home=2743`, `products=2706`, `cart=2003`, `payment=1368`,
`confirmation=824`, `monotonic_ok=1`,
`confirmation_share_all_visits=0.2728476821192053`,
`confirmation_share_from_home=0.3004010207801677`.
Live-продолжение:
```bash
docker compose run -d --name issue05-live --no-deps \
-e GEN_RUN_MODE=live \
-e GEN_STATE_RESET=false \
-e GEN_SEED=4242 \
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
-e GEN_MODEL_TIMEZONE=UTC \
-e GEN_MODEL_TIME_SPEED=1 \
-e GEN_TICK_SECONDS=60 \
-e GEN_LAMBDA_BASE_PER_MIN=120 \
-e GEN_JITTER_PCT=0 \
-e GEN_MIN_EVENTS_PER_TICK=1 \
-e GEN_MAX_EVENTS_PER_TICK=1000 \
generator
```
Лог live-запуска подтвердил восстановление из стартовой истории:
`model_time=2026-01-01T06:00:00+00:00`, затем прошли тики `361`-`364`.
После live и повторного `bash scripts/run_batch.sh`:
- `users=1079`
- `visits=3069`
- `events=32145`
- `min_event_ts=2026-01-01 00:00:00.000000`
- `max_event_ts=2026-01-01 06:03:00.000000`
- `history_events=31825`
- `live_events=320`
- `duplicate_events=0`
- `boundary_events=13`
`boundary_events=13` — это live-события ровно на `T_end`; они не входили в
backfill `[T0, T_end)`.
Визиты через `T_end`:
- `crossing_visits=36`
- `visits_with_both_sides=36`
- `homogeneous_visits=36`
- `context_ok=1`
- `min_before_events=2`
- `min_after_events=1`
- `max_after_events=10`
## Риски для review gate
- Manifest, state и события связаны метаданными (`GEN_SEED`, `T0`, `T_end`,
настройки генерации, `last_batch_id`) и checksum событий по топикам. Полной
криптографической связки `state + events + manifest` пока нет.
- Для воронки есть две SQL-формы. Строгая ordered-форма проверяет порядок
`/home -> товары -> /cart -> /payment -> /confirmation`. Калибровочная
contains-форма проверяет, была ли страница в визите. Для review gate
`confirmation_share` сравнивается с коридором мат-модели по contains-форме.
## Review gate
- Саморевью worker-а нашло блокирующий риск: backfill не должен сохранять
state/manifest при частичной публикации. Исправлено fail-fast поведением и
тестом.
- Первый reviewer gate нашёл два блокера: startup-history state мог остаться без
manifest и восстановиться как live-state, а startup-history restore не сверял
поля самого state. Оба пункта исправлены до коммита.
- Повторный reviewer gate: `gate pass`, новых блокирующих находок нет.
- Проверки после исправлений: `make generator-test` — 134 passed.
- Остаточный риск процесса: reviewer был со свежим контекстом, но той же
родословной, а не внешней моделью.
@@ -0,0 +1,227 @@
Status: done
# Стартовая история как источник аналитики
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Перевести штатный путь стенда на стартовую историю как источник аналитики.
Чистый стенд должен получать данные из генерации: стартовая история загружается,
STG→ODS→DDS→DM строится на ней, а Superset работает с этими витринами. Архивный
статический сид остаётся только временной кладовкой фактуры для генератора, а не
источником аналитического контура.
Срез не требует финальной ручной оценки красоты дашбордов, но должен дать
техническое доказательство: витрины и датасеты не пустые, контрольные числа
берутся из генерации.
Границы среза: штатный путь стенда и документы запуска. Переписывание уроков,
новые панели и улучшение формы дашбордов остаются follow-up, если по ходу не
окажутся маленькой обязательной правкой для запуска.
## Acceptance criteria
- [x] Штатная команда запуска чистого стенда создаёт или загружает стартовую
историю и прогоняет её до DM-витрин.
- [x] `kafka_load_dag` или заменяющий его путь больше не использует архивный
`data/*.jsonl` как источник аналитики.
- [x] ClickHouse-проверки из задачи 5 доступны как повторяемая команда для
координатора или CI.
- [x] Основные DM-витрины, на которых стоят дашборды, непустые и показывают
данные генерации.
- [x] Superset-датасеты и дашборды технически открываются на данных генерации;
ручная оценка формы графиков остаётся финальной HITL-приёмкой.
- [x] `README.md`, `docs/OPERATIONS.md` и `generator/README.md` больше не
описывают архивный сид как основной источник аналитики.
- [x] Если уроки или дашборды требуют нетривиальной переделки, создан follow-up
issue вместо расширения этой задачи.
- [x] Если учебные материалы ещё описывают архивный сид как основной источник,
создан отдельный follow-up на миграцию уроков по ADR-0006.
- [x] Описан повторный чистый прогон: какие данные очищаются и какие команды
выполняются, чтобы координатор мог надёжно перепроверить результат.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
- Review gate из `PRD.md`: распределения и два пути генерации после задачи 5
## Фактический прогон worker-а
Дата проверки: 2026-06-14.
Штатная команда:
```bash
make generated-history-analytics
```
Команда выполняет чистый прогон: `docker compose down -v --remove-orphans`,
запуск ClickHouse и Kafka, DDL, `GEN_RUN_MODE=backfill`, batch
STG -> ODS -> DDS -> DM, инициализацию Superset и итоговую проверку
`make generated-history-check`.
Быстрая команда issue 06 проверяет путь backfill -> DM -> Superset. Стык
backfill/live, отсутствие дублей на границе и однородность визитов через
`T_end` проверены в issue 05 и не входят в быстрый штатный check issue 06.
Быстрый проверочный профиль по умолчанию:
- `GEN_SEED=4242`
- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`
- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`
- `GEN_MODEL_TIMEZONE=UTC`
- `GEN_MODEL_TIME_SPEED=1`
- `GEN_TICK_SECONDS=60`
- `GEN_LAMBDA_BASE_PER_MIN=60`
- `GEN_JITTER_PCT=0`
- `GEN_MIN_EVENTS_PER_TICK=1`
- `GEN_MAX_EVENTS_PER_TICK=1000`
Суточный профиль остаётся доступен явно:
```bash
GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics
```
Backfill завершился:
- `events=16054`
- `visits=1516`
- `users=445`
Batch STG -> ODS -> DDS -> DM:
- STG, суммарно по 4 потокам: `64216`
- `ods.browser_event=16054`
- `ods.location_event=16054`
- `ods.device_by_click=1516`
- `ods.geo_by_click=1516`
- `dds.event=16054`
- `dds.click=1516`
- `dds.event_without_click=0`
- `dm.v_events_enriched` в `dm.dq_summary`: `16054`
Повторная команда проверки:
```bash
make generated-history-check
```
Результат ClickHouse:
- `events=16054`
- `visits=1516`
- `users=445`
- `min_event_ts=2026-01-01 00:00:00.000000`
- `max_event_ts=2026-01-01 05:59:59.353407`
- `digest=B7E183DD1835E6593CC4B33C7F5B2817`
Возвраты:
- `users=445`
- `returning_users=351`
- `returning_share=0.7887640449438202`
- `max_visits_per_user=8`
Форма длины визита:
- `visits=1516`
- `short_visit_share=0.17678100263852242`
- `median_events_per_visit=8`
- `avg_events_per_visit=10.589709762532982`
- `capped_visit_share=0.06398416886543536`
- `median_duration_sec=204`
- `p95_duration_sec=835`
- `max_events_per_visit=30`
Ordered funnel:
- `home=1387`
- `products=849`
- `cart=511`
- `payment=332`
- `confirmation=182`
- `monotonic_ok=1`
- `confirmation_share=0.13121845710165825`
Contains funnel:
- `home=1387`
- `products=1254`
- `cart=880`
- `payment=568`
- `confirmation=324`
- `monotonic_ok=1`
- `confirmation_share=0.2335976928622927`
Основные DM-витрины:
- `dm.v_events_enriched=16054`
- `dm.v_daily_traffic=797`
- `dm.v_top_pages_daily=6`
- `dm.v_utm_effectiveness=33`
- `dm.v_session_overview=1516`
- `dm.dq_summary=20`
Superset technical check:
- `superset_datasets=6`
- `superset_dashboards=1`
- `superset_dashboard_charts=10`
- `dashboard_url=http://localhost:8088/superset/dashboard/ecommerce-analytics/`
- `login_api_status=200`
- `dashboard_api_status=200`
- `dashboard_title=🛒 E-commerce Analytics Dashboard`
- зарегистрированная Superset database `clickhouse_dwh` читает
`dm.v_events_enriched` через SQLAlchemy engine:
`superset_clickhouse_events=16054`.
Проверки:
- `bash -n scripts/run_generated_history_analytics.sh` — ok.
- `bash -n scripts/check_generated_analytics.sh` — ok.
- `make generated-history-analytics` — ok.
- `make generated-history-check` — ok.
- `make generator-test` — 134 passed.
Документы запуска обновлены:
- `README.md`
- `docs/OPERATIONS.md`
- `generator/README.md`
- `docs/REPO_MAP.md`
- `docs/SUPERSET_DASHBOARD.md`
Follow-up:
- `.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md`
— миграция учебных материалов с архивного сида на генерацию.
## Риски и что не проверено
- Ручная оценка формы dashboard глазами не выполнялась: это финальная HITL-приёмка.
- Суточный backfill не прогонялся до конца в этом срезе: он доступен через env,
но для координатора выбран быстрый 6-часовой профиль.
- `kafka_load_dag.py` физически оставлен как архивный ручной путь; штатные
документы больше не ведут через него как основной источник аналитики.
- `scripts/check_generated_analytics.sh` рассчитан на штатный UTC-профиль
(`GEN_MODEL_T0/T_END` с `+00:00`). Для произвольного timezone offset в этих
переменных нужна отдельная нормализация границ.
- Проверка `no_2022_rows` доказывает отсутствие архивного сида внутри быстрого
чистого модельного диапазона. Она не доказывает отсутствие старых строк на
грязном стенде вне этого диапазона; штатная команда закрывает это через
`docker compose down -v`.
## Review gate
- Саморевью worker-а нашло слабые доказательства по проверкам issue 05 и Superset:
добавлены возвраты, форма длины визита, ordered/contains funnel, Superset login
API, dashboard API и чтение ClickHouse через Superset database.
- Независимый reviewer gate: `gate pass`, блокирующих находок нет.
- Minor-находка reviewer-а по старой подсказке `make data` в `scripts/run_batch.sh`
исправлена до коммита.
- Проверки после исправлений: `make generated-history-check` — ok,
`bash -n scripts/*.sh` — ok.
@@ -0,0 +1,117 @@
Status: done
# Портативный артефакт стартовой истории и runbook по стенду
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Сейчас стартовая история персистится только в Kafka compact-топиках
(`generator_state`, `generator_startup_history_manifest`) и в ClickHouse. Чистая
пересборка стенда (`make generated-history-analytics` делает `down -v`) каждый раз
**заново генерирует** backfill. Портативного файла-артефакта, который можно
сгенерировать один раз и быстро восстановить на чистом стенде без запуска
генератора, нет.
Из-за этого неудобно: мгновенно сбросить стенд, держать длинную стартовую
историю (2+ суток, чтобы суточная волна повторялась на графике) без повторной
генерации. Сейчас «дёшево» только live-возобновление из слепка и перезапуск без
`down -v`; полный сброс требует регенерации.
Этот пункт работает на главную цель: если стенд поднимается одной командой и есть
короткий runbook, генератор остаётся скрытой инфраструктурой и менти не нужно
знать его устройство. Развилка курса «урок про генератор vs скрытая
инфраструктура» уже закрыта в пользу скрытой инфраструктуры
(`docs/course/PRD.md` §7, 2026-07-04) — эта задача обеспечивает решению опору.
## What to build
- **Экспорт** стартовой истории в портативный файл-артефакт: события (в формате
сообщений топиков) плюс слепок состояния плюс манифест — один связный набор,
чтобы не смешать `GEN_SEED`, `T0`, `T_end` и настройки генерации.
- **Импорт** (решение 2026-07-04): воспроизвести события артефакта в Kafka-топики
и вернуть слепок с манифестом в служебные compact-топики. **Напрямую в
ClickHouse импорт не пишет ничего**: стенд наполняется штатным путём
(Kafka engine + MV -> STG, батч-ETL -> витрины). Так не появляется обходного
пути данных, каждый импорт заодно прогоняет весь пайплайн, а менти видит,
как пустой стенд наполняется изучаемыми механизмами. Live продолжает с `T_end`.
- **Громкий отказ при несовместимом state** (решение 2026-07-04, пересмотр
правила спеки). Различать два случая. Нет состояния или оно повреждено ->
чистый старт с предупреждением (как сейчас, оставить). Состояние есть и
читается, но настройки несовместимы при `GEN_STATE_RESET=false` (оператор
намерен продолжить) -> жёсткое падение с указанием разошедшихся полей
(`seed`/`T0`/`timezone`/`speed`) и подсказкой выставить `GEN_STATE_RESET=true`,
если новый мир нужен осознанно. Текущее тихое поведение — `service.py:217-220`.
Правка идёт вместе с обновлением
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`.
- **Runbook «как пользоваться стендом на генерации»**: как сгенерировать,
сохранить, восстановить, выбрать длительность стартовой истории; что дёшево
(live-возобновление, перезапуск без чистки), а что требует регенерации.
- Новые команды экспорта/импорта делать в стиле глаголов (явное действие одной
командой), а не новыми комбинациями env-переменных.
## Acceptance criteria
- [x] Есть команда экспорта: стартовая история -> портативный файл-артефакт
(события + слепок + манифест) одним связным набором.
- [x] Есть команда импорта: на чистом стенде артефакт воспроизводится в Kafka
(события + служебные compact-топики) **без запуска генерации**; напрямую в
ClickHouse импорт не пишет. После штатного ETL контрольные числа в ClickHouse
совпадают с манифестом и исходной генерацией.
- [x] После импорта live продолжает с `T_end`: без дублей на границе и без
смешения миров.
- [x] Сохранено антисмешивание: импорт отвергает артефакт, несовместимый по
манифесту (`GEN_SEED`, `T0`, `T_end`, настройки генерации, версия state).
- [x] Громкий отказ: живое читаемое состояние + несовместимые настройки при
намерении продолжить -> падение с перечислением разошедшихся полей и
подсказкой; нет состояния или повреждено -> чистый старт с предупреждением
(как сейчас). Спека обновлена в этом же изменении.
- [x] Runbook описывает генерацию один раз, дешёвое восстановление, выбор
длительности и то, что переживает перезапуск, а что требует регенерации.
- [x] Runbook — про **использование**, устройство генератора в нём не
объясняется; за конструкцией он отсылает к `generator/README.md` и
`docs/specs/`.
- [x] Документы запуска (`README.md`, `docs/OPERATIONS.md`,
`generator/README.md`) ссылаются на runbook.
## Notes
- Опирается на спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`,
разделы «Манифест стартовой истории» и «Повторяемая проверка в ClickHouse».
- Спека уже упоминала будущий runbook «проверка генератора на стенде» — этот
issue его и закрывает, расширяя до полного цикла «генерация — сохранение —
восстановление».
- Откуда экспорту брать события — решить при реализации и зафиксировать в
спеке/runbook. Кандидаты: писать файл артефакта прямо при backfill (вторая
копия рядом с публикацией в Kafka), вычитать топики событий (учесть retention)
или выгрузить из STG (следить за точностью формата сообщений). Критерий
выбора: артефакт должен байт в байт воспроизводить сообщения топиков.
- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация,
формат данных, правка спеки — всё из порогов риска).
- Связано с `08-migrate-course-from-archive-seed.md`: миграция уроков идёт после
этой задачи и будет ссылаться на runbook отсюда (номера отражают порядок,
переставлены 2026-07-04).
## Идеи интерфейса — решения (2026-07-04)
Бывший раздел «на будущее, не решено» разобран с пользователем. Судьба идей:
- **Громкий отказ при несовместимом state** — включён в эту задачу
(см. What to build и критерии).
- **Глаголы / длительность / профили** — отдельная задача
`11-generator-launch-verbs-and-profiles.md`, делать **после этой и до
DAG-пульта**: чистый интерфейс «под капотом» делает DAG тонкой обёрткой с
простыми и понятными параметрами.
- **Airflow-DAG как пульт генератора** — приоритет поднят (пользователь,
2026-07-04): это будущий основной человеческий интерфейс стенда — «слишком
сложно» лечится формой в веб-UI, а не только runbook'ом. Отдельная задача
`12-generator-control-dag.md`, делать после задачи 11.
- **Доливка прошлого кусочком** — отдельная задача
`13-backfill-top-up-from-snapshot.md`, без приоритета.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
- `.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md`
@@ -0,0 +1,90 @@
Status: done
# Миграция учебных материалов с архивного сида на генерацию
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
После перевода штатного аналитического контура на стартовую историю генератора
часть учебных материалов всё ещё описывает `data/*.jsonl` как основной источник
данных стенда. Это нельзя править внутри issue 06: потребуется пройти уроки и
сохранить понятный учебный путь.
Развилка «отдельный урок про генератор vs скрытая инфраструктура» закрыта
(2026-07-04, `docs/course/PRD.md` §7): **отдельного урока не будет, генератор —
скрытая инфраструктура**. Устройство генератора — это разработка бэкенда и
математика, не инженерия данных; в цели курса не попадает. Уроки адаптируем,
не вводя марковские цепи и сложный Python в путь менти.
## What to build
Обновить курс и связанные материалы (тест-план) так, чтобы основной путь был:
```text
startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset
```
Архивный `data/*.jsonl` оставить только как временную фактуру генератора.
Генератор в уроках подаётся как готовый источник данных стенда («откуда берутся
данные»), без погружения в его устройство.
## Acceptance criteria
- [x] `docs/course/` больше не ведёт ученика через `make data` или `kafka_load`
как основной путь получения аналитических данных.
- [x] Уроки явно объясняют, что `data/*.jsonl` пока остаётся кладовкой значений
для генератора, а не источником аналитического контура.
- [x] Уроки не вводят устройство генератора (марковская модель, внутренний
Python) в путь менти: генератор упоминается только как готовый источник данных.
- [x] Тест-план согласован с новым штатным путём запуска.
- [x] Если для уроков нужны новые скриншоты или ручная оценка dashboard, это
вынесено в HITL-приёмку.
## Notes
Реальный объём больше, чем кажется: `make data` вплетён во **все** уроки 00–05,
а не только в урок 06 (проверка 2026-07-04, `grep -rn "make data" docs/course/`):
- `docs/course/README.md` — быстрый старт через `LIMIT=50 make data`;
- `lessons/00_kafka_intro.md` — `make data` играет роль трекера, на нём построен
весь разбор партиций и offset'ов;
- `lessons/01–05` — `LIMIT=50 make data` в запуске, «полном сбросе» и таблицах
проверок;
- `docs/TEST_PLAN.md` — сценарии на старом пути.
Тонкое место — урок 00: там ручная заливка используется как учебный приём
(наглядно видно сообщения в топике). Решить при миграции, чем её заменить, не
потеряв наглядность; критерий приёмки запрещает `make data` как **основной путь
аналитики**, а не как локальный демонстрационный приём внутри урока.
Схема потока в `docs/course/PRD.md` §1 уже обновлена (поправка 2026-07-04) — PRD
дополнительно править не нужно.
Демо-материалов в скоупе нет: демо-употребление стенда устарело (демо выросло в
отдельный проект), шпаргалки удалены из репозитория 2026-07-04 (см. поправку в
шапке `docs/course/PRD.md`).
Рекомендуемый режим ревью по coordinator-loop: обычный (документы и уроки, кода
генератора и state задача не трогает); человеческая приёмка — проход по урокам
через пульт (HITL).
## Blocked by
- Жёстких блокеров нет: штатный путь (`make generated-history-analytics`) уже
существует, уроки можно вести через него.
- Мягкая зависимость от
`07-startup-history-portable-artifact-and-usage-docs.md`: runbook из issue 07
изменит команды запуска и восстановления стенда, а уроки будут на него
ссылаться. Делать эту задачу после него, иначе уроки придётся править дважды.
- Мягкая зависимость от `12-generator-control-dag.md` (решение 2026-07-04):
человеческая приёмка миграции — это проход по урокам 00–05, и вести её удобнее
через DAG-пульт, а не через консоль. Поэтому эта задача идёт последней в
очереди — после 12. Порядок всей очереди — в PRD, раздел «Задачи».
- Мягкая зависимость от `14-fast-teaching-profile.md` и
`09-seam-browser-fixture-not-preserved.md` (дополнено 2026-07-04): обе меняют
мир — 14 переводит профиль `daily-wave` на быстрый темп, 09 меняет схему
state. Артефакт стартовой истории для уроков должен родиться **после** них,
иначе он протухнет по антисмешиванию и проход по урокам придётся повторять.
@@ -0,0 +1,159 @@
Status: done
# Браузерная фактура не сохраняется на стыке восстановления визита
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Что нашли
Визит, переживший стык backfill->live (активен на `T_end` и продолжается живьём),
меняет браузерную фактуру в live-продолжении (фактура — конкретные значения полей
события: какой `browser_name`, user agent, язык стоят в строках). Внутри одного `click_id` человек как
будто «пересел» с одного браузера на другой посреди сессии.
Эмпирика. Чистый стенд, 2 суток backfill + 6 live-тиков за `T_end`,
`GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`,
`GEN_MODEL_T_END=2026-01-03T00:00:00+00:00`, `speed=1`:
- `crossing_visits=22`; `browser_name` отличается у `17/22`, `browser_language`
у `22/22`; каждая сторона внутренне постоянна (`22/22`).
- Чисто-исторические визиты (>=3 событий, целиком до `T_end`): браузер постоянен
у `0/14556`.
- device / os / geo на стыке НЕ расходятся: `0/22` конфликтов на уровне ODS.
- `duplicate_events=0`: дублей на границе нет.
- Пример `click_id`: в истории `Chrome / or_IN`, в live `Firefox / el_CY`.
## Почему это дефект
Нарушает заявленный критерий задач 04 и 05: «визит, переживший восстановление,
остаётся однородным; контекст визита не меняется на стыке и не создаёт второй путь
генерации внутри одного `click_id`». Браузер — часть контекста визита.
Критерий был помечен выполненным, но проверялся неполно: саморевью и ревьюер
смотрели только поля `dds.click` (user / device / geo), которые по одной строке на
`click_id` и потому однородны структурно. Per-event браузерные поля в `dds.event` не
проверялись. Это ровно слепая зона «двух путей генерации», под которую PRD требовал
внешний review gate другой родословной после задачи 5 — а он был выполнен той же
родословной, и дефект прошёл.
## Причина (подтверждена чтением кода, 2026-07-04)
Первоначальная гипотеза «рассинхрон `event_index`» не подтвердилась: восстановление
перебирает визит с нуля, индекс события выровнен правильно (точнее, берётся
`event_index % len` — по модулю длины списка, см. `runtime.py:332`). Разошёлся сам
**источник** фактуры:
- При рождении визита (`generation.py`, `generate_batch`) браузерные строки берутся
у **случайного** click_id из словаря — «донора» (`rng.choice(visit_candidates)`,
затем `browser_by_click_id[base_click_id]`). Кто донор — нигде не сохраняется,
он живёт только в памяти процесса.
- При восстановлении из state (`runtime.py`, `_compact_visit_batch`) фактура
пересобирается из **другого** click_id — того, к которому привязан пользователь
(`browser_by_click_id[user.seed_click_id]`).
Донор и seed — разные click_id, отсюда разные браузеры. Внутри одного click_id
сида браузер практически постоянен (реальный клик одного человека), поэтому
«каждая сторона внутренне постоянна». `browser_language` разошёлся у 22/22
(случайные click_id почти всегда с разными языками), `browser_name` — у 17/22
(популярные браузеры иногда совпадают случайно). device и geo не расходятся,
потому что оба пути берут их из `UserProfile`.
Расхождение шире, чем браузер (уточнение по ревью 2026-07-04). Шаблон location
ищется по event_id браузерной строки (`location_by_event_id[...]` в обоих путях),
то есть тоже зависит от того, чей click_id взят источником. Из state
восстанавливаются только `page_url` / `page_url_path`; остальные per-event поля
location — `referer_url`, `referer_medium`, `utm_*` — приходят от источника и на
стыке расходятся так же, как браузер. Эмпирика выше их просто не замеряла.
Тот же `restore_state` -> `_visit_from_state` -> `_compact_visit_batch` работает и
при восстановлении после сбоя (задача 04) — дефект касается и этого пути, не
только стыка backfill->live. Стык и рестарт на уровне генератора — один и тот же
код, различие только в отбрасывании просроченных визитов.
## Масштаб и важность
- Затрагивает только визиты, активные ровно на `T_end` (здесь 22 из 17397 визитов,
~0.13%). Чистый backfill и чистый live — без дефекта.
- Тот же механизм (фактура пересобирается при restore) вероятно затрагивает и
восстановление после сбоя (задача 04), не только backfill->live; проверка задачи 04
его не ловила, потому что смотрела только `dds.click`.
- Для учебного демо урон низкий, но это реальное нарушение стыковой однородности и
сигнал, что браузерная фактура не входит в восстановимое состояние визита.
## Решение по направлению фикса (пользователь, 2026-07-04)
**Хранить донора в state.** В сериализацию визита (`_visit_to_state`) добавить поле
с click_id донора фактуры (`base_click_id`), рядом с `page_url_paths` /
`timestamps`. При восстановлении `_compact_visit_batch` берёт строки из
`browser_by_click_id[base_click_id]` — по уже существующему абсолютному индексу
восстановленная часть точно продолжает выпущенную.
Рассмотренные альтернативы (отклонены):
- Брать браузер от `seed_click_id` пользователя уже при рождении визита — schema
state не меняется и семантика красивее («один человек — один браузер»), но
меняется весь выпуск генератора: контрольные суммы, распределение браузеров,
артефакты пришлось бы перегенерировать. Это уже не багфикс, а смена поведения;
если захочется — отдельная задача.
- Сериализовать браузерные строки визита целиком — надёжно, но раздувает state и
дублирует словарь. Перебор: донор + абсолютный индекс дают тот же результат.
Детали для исполнителя:
- В основной ветке рождения донор покрывает визит целиком: кандидаты фильтруются
по `len(browser_events) >= len(visit_path)` (`generation.py:170`), так что
индекс не выйдет за список строк донора и `% len` становится безобидным
холостым ходом (оставить или убрать — на усмотрение при фиксе).
- **Запасная ветка рождения** (`generation.py:182-185`): если кандидатов нет,
берётся одна случайная браузерная строка и повторяется на весь визит — тогда
«восстановить по донору и индексу» даст другие строки. С реальным сидом ветка,
судя по фильтру, не срабатывает — проверить при фиксе и либо превратить её в
громкую ошибку, либо сериализовать так, чтобы восстановление её воспроизводило.
Молча оставить как есть нельзя.
- Неизвестный `base_click_id` при восстановлении — ошибка (по образцу проверки
`seed_click_id` в `_user_from_state`). Сейчас в `_compact_visit_batch` два
тихих fallback'а (`.get(...)` со словарём целиком для браузера и
`location_events[0]` для location) — для донора их заменить на ошибку, не
копировать.
- **Известное расхождение вне скоупа**: event_id при рождении — случайный uuid4
(`generation.py`, `_new_uuid`), при восстановлении — детерминированный uuid5 от
`click_id:index` (`runtime.py:140-141`). Фикс это не трогает: коллизий нет,
дублей не создаёт. Тест поэтому сравнивает события по всем полям, **кроме
`event_id`** — иначе он падает не из-за нашего бага.
- Расширение схемы state — поднять `STATE_VERSION` (`state.py`) и валидацию
нового поля. Старые state становятся несовместимы: поведение при этом — по
действующему правилу (сейчас — предупреждение и чистый старт; громкий отказ
при намерении продолжить делает задача 07, здесь его не реализовывать).
## Acceptance criteria
- [x] Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state
посреди визита, восстановить и сверить оставшиеся события с продолжением без
рестарта — по **всем** per-event полям, кроме `event_id` (браузерные и
location: referer, utm — не только browser_name; см. «Расхождение шире» выше).
Это один код восстановления для стыка backfill->live и crash-recovery
(задача 04) — одного теста на него достаточно.
- [x] Проверка стыка на стенде расширена per-event полями: на сценарии из «Что
нашли» (2 суток backfill + live за `T_end`, `GEN_SEED=4242`) визиты через стык
однородны в `dds.event` по браузерным полям (`browser_name`,
`browser_language`) **и** полям источника перехода (referer, utm) — 0
расхождений из `crossing_visits`. Запрос/скрипт проверки сохранён как
повторяемый, а не разовый.
- [x] Без регрессий на том же сценарии: `duplicate_events=0`, конфликтов
device / os / geo на уровне ODS по-прежнему 0.
- [x] `STATE_VERSION` поднята, новое поле валидируется; несовместимый старый
state обрабатывается по действующему правилу (см. детали выше).
## Notes
- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация —
из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз:
критерий задач 04/05 проверяли только по `dds.click`.
- Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть
исправлен до того, как на него навешивать доливку.
## Blocked by
- Нет. Это bug по результатам независимой проверки задач 05 и 06.
@@ -0,0 +1,89 @@
Status: done
# Гео-карта на дашборде нечитаема
## Parent
`.scratch/generator-model-time-startup-history/PRD.md` (раздел «Финальная ручная
приёмка»: если панелей не хватает для просмотра — отдельная задача на панель).
## Что нашли
На финальной ручной приёмке (шаг 2 спеки) виджет «🌍 Geography Map» показывает
что-то, но прочитать нельзя:
- нет всплывающих подсказок (tooltip) — навёл на страну, значения не видно;
- нет легенды и подписи шкалы — непонятно, что кодирует цвет и в каких единицах;
- цветовая шкала неочевидна — подсвечено мало стран, остальное пусто.
График рисуется, но непонятно, что именно он показывает.
## Две разные причины (не путать)
1. **Читаемость визуализации (эта задача).** Сделать так, чтобы у графика были
tooltip, легенда и подпись метрики/единиц и понятная цветовая шкала — либо
настройкой текущего чарта, либо заменой карты (choropleth — карта, где страна
закрашена по значению) на более читаемую форму, например топ-N стран
столбцами, потому что распределение сильно разрежено. Важно: сейчас это
legacy-виз `world_map` (`superset/create_dashboard.py:149-164`), и у него,
судя по параметрам, легенда и tooltip вообще не настраиваются — скорее всего,
основной путь именно смена типа визуализации, а не подкрутка текущего.
Возможности виз-типов уточнить по актуальной документации Superset через
Context7 (правило AGENTS.md) и зафиксировать выбор.
2. **Перекос гео-данных (отдельно).** Сама гео-фактура (значения стран в
событиях) берётся из статического сида (`geo_by_click_id`), и на приёмке
распределение стран оказалось сильно перекошенным — закрашено мало стран.
Опора на чужой сид — осознанно временная (ADR-0006: своя гео-фактура —
отдельный будущий шаг); лечится это генерацией гео, а не настройкой чарта.
Здесь не решаем, только отмечаем как смежную причину.
## Acceptance criteria
- [x] У гео-графика есть tooltip со значением по стране (на выбранном типе
визуализации — карте или замене).
- [x] Есть легенда и подпись: какая метрика и в каких единицах кодируется
(для текущей метрики `COUNT(*)` честный ответ — «событий, штук»).
- [x] Выбранный тип визуализации читаем на текущем (разреженном) распределении;
выбор типа (оставить карту или заменить) зафиксирован с коротким «почему».
- [x] Зафиксировано, что перекошенное распределение стран — свойство гео-фактуры
из сида (своя генерация гео — отдельный шаг по ADR-0006), а не настройки
чарта.
- [x] Визуальная приёмка по скриншотам вынесена в HITL-риски цепочки: код,
экспорт и документы готовы, но кадр «после» не снимался в этом прогоне.
## Как принимать (дописано 2026-07-04)
Проверка визуальная, через скриншоты playwright-cli (так уже делали в спеке
редизайна дашборда `docs/specs/2026-06-06-superset-dashboard-redesign.md`):
- Скриншоты «до» и «после»: кадр виджета целиком, плюс кадр с наведением курсора
на закрашенную страну / столбец (виден tooltip).
- «Читаемо» значит: по кадру виджета целиком (без обращения к SQL и коду чарта)
можно ответить на три вопроса — какая метрика показана, в каких единицах, у
какой страны значение больше. Если по кадру ответить нельзя — не принято.
- Tooltip — по отдельному кадру с наведением: видно название страны и значение
метрики. Кадр с tooltip в headless-прогоне может стабильно не ловиться
(tooltip следует за курсором); тогда допустим другой способ показать tooltip
(например, короткая запись экрана) — согласовать с человеком, молча не
ослаблять.
Состояние стенда: стенд не трогали с 2026-06-14 и он мог умереть. Это нормально:
полная пересборка (`make generated-history-analytics`) заново генерирует backfill.
По умолчанию она даёт **6 часов** модельной истории
(`GEN_MODEL_T_END` в `scripts/run_generated_history_analytics.sh`) — для приёмки
читаемости этого достаточно. Если нужен вид как на исходной находке (2 суток),
переопределить `GEN_MODEL_T_END` на `T0`+2 суток при запуске; профиль «2 суток
одной командой» — это ещё не сделанная задача 11, здесь её не делать.
Портативный артефакт (задача 07) для этой задачи не нужен и не блокирует её.
## Notes
- Всплыло на просмотре дашборда на данных генерации (2 суток backfill).
- Смежные документы: `superset/create_dashboard.py` (определение чарта),
`docs/SUPERSET_DASHBOARD.md` — при смене типа визуализации обновить в этом же
изменении.
- Возможно, стоит расширить до общего прохода по читаемости дашборда (легенды и
подсказки у других виджетов), но базово задача — про гео-карту. Если проход
делается, он не должен раздувать задачу: заметил — запиши отдельным issue.
- Рекомендуемый режим ревью по coordinator-loop: обычный (риска для state и
данных нет, правка ограничена определением чарта и документацией).
@@ -0,0 +1,58 @@
Status: done
# Глаголы, длительность и профили запуска генератора
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Запуск генератора недружелюбный: поведение собирается из ~10 связанных
env-переменных, а смысл запуска — из их комбинации (`GEN_RUN_MODE` +
`GEN_STATE_RESET`). Конец истории задаётся абсолютной меткой `GEN_MODEL_T_END`,
которую оператор считает в уме от `T0`.
Задача — дать чистый интерфейс «под капотом» перед DAG-пультом
(`12-generator-control-dag.md`): если команды простые и говорящие, DAG
становится тонкой обёрткой с понятными параметрами, а не переводчиком
формы в матрицу переменных (решение пользователя, 2026-07-04).
## What to build
- **Глаголы вместо матрицы флагов:** явные команды `backfill` / `continue` /
`reset` (make-цели или аргументы запуска), а не комбинация
`GEN_RUN_MODE` + `GEN_STATE_RESET`. Старые переменные могут остаться как
низкоуровневый механизм, но документированный путь — глаголы.
- **Длительность вместо абсолютной метки:** задавать конец истории как
длительность от `T0` (например, `2d`), расчёт `T_end` — внутри.
- **Именованные профили** вместо повторения блока переменных: минимум два —
быстрый проверочный (6 часов, текущий дефолт CI) и «с суточной волной»
(2+ суток).
## Acceptance criteria
- [x] Стартовую историю на 2 суток можно получить одной командой с глаголом и
длительностью/профилем, без ручного расчёта `T_end` и без выставления
`GEN_RUN_MODE`/`GEN_STATE_RESET` вручную.
- [x] Глаголы не меняют семантику режимов: за `backfill`/`continue`/`reset`
стоит тот же контракт модельного времени и state, что в спеке
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`.
- [x] Профили покрывают быстрый проверочный прогон и прогон с суточной волной;
выбранный профиль виден в логах/манифесте.
- [x] Runbook (из задачи 07) и документы запуска переведены на глаголы и
профили; старый способ через переменные упомянут как низкоуровневый.
- [x] Существующие тесты генератора проходят; поведение по умолчанию
(CI-профиль) не изменилось.
## Notes
- Это интерфейсный слой над существующей механикой: генерацию, state и
антисмешивание не менять.
- Вместе с глаголами не забыть команды экспорта/импорта из задачи 07 — они
уже в стиле глаголов, привести всё к одному стилю.
## Blocked by
- `07-startup-history-portable-artifact-and-usage-docs.md` — runbook и команды
экспорта/импорта появляются там; эта задача переводит их на единый стиль.
@@ -0,0 +1,169 @@
Status: done
# Airflow-DAG — пульт управления генератором
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Даже с runbook и глаголами управление генератором остаётся консольным. Идея
(пользователь, 2026-06-14; приоритет поднят 2026-07-04): параметризованный DAG
в Airflow как основной человеческий интерфейс стенда — форма в веб-UI, где
выбираются операция, профиль, длительность. Плюсы: валидация настроек против
манифеста ещё до запуска, наглядный статус, понятные ошибки.
Учебный бонус: такой DAG сам по себе учебный материал — живой пример
параметризованной оркестрации (тема урока 4), что ближе к цели курса, чем
устройство генератора.
## Решения (дооформление 2026-07-04)
- **Без Docker-доступа из Airflow.** Docker socket в контейнеры Airflow не
прокидываем. Конечные операции — это обычный Python-код генератора, которому
нужны только настройки и Kafka; таски DAG выполняют его сами, в worker'е
Airflow. Урок из проекта `airflow-greenplum-solution` (пользователь): DAG
говорит с управляемой системой по сетевому протоколу, жизненный цикл
контейнеров — только хост/Makefile.
- **Границы пульта.** `make up`, `make clean` (полный сброс) и старт/стоп
live-сервиса — консоль; операции `continue` в пульте нет намеренно.
Backfill и import работают только на чистом стенде (пустые топики данных);
грязный стенд лечится `make clean` с консоли — пульт при отказе прямо
подсказывает это.
- **Генератор не автостартует.** Сейчас `make up` поднимает live-генератор
вместе со стендом (`docker-compose.yml`, `restart: unless-stopped`) — с
пультом это неверно: любой backfill был бы сразу отбит предпроверкой, а
выключить live из UI нельзя. Live становится явным действием
(`make generator-continue`); механизм — например, compose-профиль для
сервиса `generator`, выбрать при реализации и поправить make-цели.
- **Три операции, а не четыре.** `backfill` | `import` | `check`. Отдельного
`export` нет: артефакт в коде пишется только по ходу backfill
(`StartupHistoryArtifactBuilder` внутри `_run_backfill`), «выгрузки задним
числом» не существует — поэтому у backfill есть поле `artifact_path`
(«сохранить артефакт», пусто — не сохранять).
- **Один DAG** с параметром «операция», а не несколько DAG по операциям.
- **После backfill/import DAG сам запускает ETL** (`TriggerDagRunOperator` на
существующий ETL-DAG) и **дожидается его завершения** перед check:
`wait_for_completion` в Airflow 2.10.5 по умолчанию `False` (сверено по
Context7, ревью Codex 2026-07-04) — простой trigger запустил бы check
раньше конца ETL. Менти видит цепочку «мир → пайплайн → витрины».
## What to build
- **Код генератора доступен таскам Airflow.** Варианты: смонтировать
`generator/src` томом (как уже смонтирован `./sql`) и добавить в
`PYTHONPATH`, либо ставить пакет в `Dockerfile.airflow`. Монтировать нужно
во все Airflow-сервисы (scheduler, webserver, worker): список профилей в
форме читается из `PROFILES` при разборе DAG. Критерий выбора — правка
генератора не должна требовать лишних пересборок.
- **Выравнивание зависимостей:** версии сейчас расходятся — kafka-python
2.0.5 у генератора против 2.0.6 в `airflow/requirements.txt`; привести к
одной. `prometheus-client` в образе Airflow нет — либо добавить, либо (лучше)
точка входа backfill не поднимает HTTP-сервер метрик (`service.py:65-66`
вызывается безусловно — понадобится небольшая склейка; это единственное
место, где честно появляется новый код, зафиксировать его в PR).
- **DAG `generator_control`** в `airflow/dags/`: `schedule=None`,
`max_active_runs=1`, форма запуска на `Param`:
- `operation`: `backfill` | `import` | `check` (выпадающий список);
- `profile`: из `PROFILES` (`launch.py`), список брать динамически;
- `duration`: строка вида `6h`/`2d`, пусто — из профиля;
- переопределения мира для backfill (минимум скорость и seed) — через
механизм `overrides` в `build_launch_env`;
- `artifact_path`: для backfill — куда сохранить артефакт (пусто — не
сохранять), для import — что импортировать; дефолт в общем томе `./data`.
- **Ветвление по операции** — `BranchPythonOperator`, в стиле существующих
DAG стенда.
- **Каждая операция начинается с валидации, падение — до любых записей:**
- backfill и import: топики данных пусты (переиспользовать
`KafkaTopicInspector.assert_data_topics_empty`) **и STG-таблицы
ClickHouse пусты** — батч-ETL пересобирает ODS из всего STG
(`sql/ods/20_stg_to_ods.sql`), поэтому пустых топиков мало: старый мир в
STG смешался бы с новым. При отказе в логе — подсказка про `make clean`;
- import дополнительно: артефакт читается, манифест совместим с выбранными
настройками (готовые проверки `startup_history_artifact.py`), в лог —
перечень разошедшихся полей;
- вспомогательно: проверка «live не работает» по метрикам
`generator:9109` внутри сети compose (см. Notes об ограничениях).
- **Тела операций — вызовы существующего кода:** `build_launch_env` + прогон
генерации до `T_end` (backfill), функции `startup_history_artifact`
(import), сверка контрольных чисел с манифестом (check). Источник манифеста
для check — compact-топик (он есть и после backfill, и после import);
артефакт — запасной вариант.
- `retries=0` у содержательных тасков: оператор должен сразу видеть ошибку
(проверенный приём из greenplum-проекта).
- **Документация:** в runbook `docs/runbooks/startup-history.md` — раздел
«Пульт в Airflow» как основной путь и явные границы пульта (что остаётся
консолью и почему, включая «continue в пульте нет намеренно»); тонкость
нестандартного мира: `make generator-continue` для мира с переопределёнными
настройками требует тех же настроек, громкий отказ подскажет разошедшиеся
поля. Ссылки из `docs/OPERATIONS.md` и `README.md`; правки compose и
Makefile описать в том же PR (правило репозитория).
## Acceptance criteria
- [x] После `make up` (генератор не автостартует) стартовую историю выбранного
профиля/длительности можно создать из веб-UI Airflow, не открывая консоль и
не выставляя переменных окружения; следом ETL запускается из той же цепочки
и `check` зелёный.
- [x] Импорт артефакта из веб-UI: несовместимый артефакт отклоняется **до**
записи в топики, в логе таска — перечень разошедшихся полей.
- [x] Backfill/import на непустом стенде отклоняются предпроверкой с
подсказкой про `make clean`; миры не смешиваются.
- [x] Артефакт, сохранённый при backfill из веб-UI, пригоден для консольного
импорта (формат один и тот же), и наоборот.
- [x] Операция check сверяет контрольные числа ClickHouse с манифестом и
падает при расхождении.
- [x] Docker недоступен из контейнеров Airflow (socket не монтируется) — это
граница решения, а не упущение.
- [x] Консольные глаголы работают как раньше; `scripts/*` и DAG сходятся в
одном `launch.py`, дублирования логики запуска нет.
- [x] `make up` больше не запускает live; `make generator-continue` запускает
его явно; существующие сценарии (`generated-history-analytics`, CI) не
сломаны.
- [x] Runbook и `docs/OPERATIONS.md` описывают пульт как основной путь и его
границы.
- [x] DAG остаётся читаемым менти: витрина параметризованной оркестрации,
сложность живёт в коде генератора.
## Notes
- Перед реализацией сверить API формы (`Param`, enum, описания полей) и
`TriggerDagRunOperator` для Airflow 2.10 через MCP Context7 — правило
репозитория.
- `Config` генератора читает env процесса при создании: в таске собирать
окружение через `build_launch_env` и применять к процессу таска (executor —
LocalExecutor, таск живёт в своём процессе). Не забыть `GEN_DATA_DIR`:
`build_launch_env` его не задаёт, дефолт `/data`, а в контейнерах Airflow
данные смонтированы в `/opt/airflow/data` — без явной установки `Config()`
упадёт, словари `*.jsonl` не найдутся.
- Права на файлы: артефакт из worker'а пишется uid'ом Airflow (50000); в
контейнере генератора `./data` смонтирован **read-only**, консольные
скрипты монтируют каталог артефактов отдельно. Чтение работает везде,
перезапись чужого файла — не всегда; одну строку об этом — в runbook.
- Проверка `generator:9109` — вспомогательная эвристика, не защита: ошибка
DNS означает «сервис не поднят» (не ошибку таска); при крашлупе после
громкого отказа порт мигает (HTTP-сервер стартует до валидации state);
эфемерные контейнеры `docker compose run` под этим именем не видны. Основная
защита от смешивания — пустота топиков данных.
- Backfill — блокирующий таск на минуты; при `max_active_runs=1` для ручного
стенда это нормально.
- Грабли greenplum-проекта, применимые здесь: DAG создаются на паузе — в
инструкции приёмки не забыть unpause перед trigger; автоматические прогоны
гонять через REST API, а не CLI внутри контейнера.
- Возможное развитие (не в скоупе): операция «очистить данные» из пульта
(прецедент удаления/пересоздания топиков из DAG уже есть в
`airflow/dags/utils/kafka_helpers.py`) — сняла бы требование `make clean`
перед новым миром.
- Артефакты `daily-wave`, сделанные до задачи 14, протухнут после неё
(антисмешивание отработает громко) — при пересечении работ это ожидаемо.
- Рекомендуемый режим ревью по coordinator-loop: обычный — state и
сериализацию задача не трогает, код генератора переиспользуется как есть.
## Зависимости (проверенные предпосылки)
- `07-startup-history-portable-artifact-and-usage-docs.md` — сделана.
- `11-generator-launch-verbs-and-profiles.md` — сделана.
- Мягкая связь с `14-fast-teaching-profile.md`: быстрый профиль появится в
выпадашке сам, если список профилей брать из `PROFILES` динамически;
блокером не является.
@@ -0,0 +1,208 @@
Status: done
# Глагол next-day: собрать следующий модельный день
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Рамка пересмотрена 2026-07-07 (обсуждение с пользователем). Было: «удлинить
готовую историю» (2 суток -> 7) — этот мотив закрыл портативный артефакт
(задача 07), приоритет был низкий. Стало: **режим кормления стенда порциями**.
Ценность не в длине истории, а в дискретности подачи:
- **Урок.** Менти триггерит «следующий день» -> в Kafka падает батч за новый
модельный день -> прогон `etl_pipeline` -> витрины и дашборды сдвинулись.
Полный цикл DWH виден за один шаг; повторяемо и управляемо, в отличие от
непрерывного live.
- **Имитация жизни.** Тот же механизм по расписанию — стенд «живёт».
В этой задаче делаем глагол готовым к расписанию (идемпотентность,
громкие отказы), но само включение расписания — отдельная задача
(решение по скоупу 2026-07-12, см. «Решения по ревью постановки»).
- Удлинение истории остаётся как частный случай (несколько next-day подряд).
## Интерфейс
Новый глагол `next-day` в существующем DAG `generator_control`
(`airflow/dags/generator_control_dag.py`), не отдельный DAG: менти уже знает,
где ручки генератора. Pause-check `etl_pipeline` переиспользуется
(`generator_control_dag.py:153-164` — подтверждено ревью). Guard чистого
стенда **не** переиспользуется — у next-day своя предпроверка границы
(см. решение 3 ниже).
## Развилка реализации — решена (пользователь, 2026-07-07)
**Выбран вариант 2: генерация следующего дня от слепка `T_end`.**
Обоснование. Диагноз задачи 20 показал, что генератор на стыке ничего не
ломает — красный гейт был гонкой в самой проверке. Оба варианта одинаково
требуют цепочку границ и расширение проверок, а вариант 1 сверх того требует
новый режим среза в импорте («всё или ничего» сейчас). Вариант 2 не дороже,
честнее (жизнь стенда не ограничена длиной артефакта) и переиспользует
закалённый restore-механизм (задача 09: донор в state, громкие ошибки,
детерминированная запасная ветка).
Отклонено: вариант 1 (резать готовый артефакт на дневные порции) — не даёт
неограниченной жизни и требует своей новой механики импорта.
## Решения по ревью постановки (2026-07-12, два слепых ревью)
Оба ревью (Codex и Claude, свежие сессии) вернули CHANGES_REQUIRED.
Все находки внесены решениями ниже; полные индексы — в обменном каталоге
сессии (в репо не хранятся).
1. **next-day — новый ограниченный режим запуска, а не переиспользование
существующего глагола.** Сейчас `backfill` — свежая генерация от `T0`
с чистым завершением, `continue` — бесконечный live
(`generator/src/clickstream_generator/launch.py:96-128`,
`service.py:96-128`). next-day = восстановление мира из state
+ генерация ровно одного дня с самозавершением + сдвиг манифеста.
Это основная работа задачи, «переиспользуется» только restore-механизм.
2. **Один модельный день = ровно 24 часа, полуоткрытый диапазон
`[T_end, T_end + 24h)` в модельном времени (UTC).** Календарные сутки и
`GEN_MODEL_TIMEZONE` в границах не участвуют; литералы времени в
проверках — по образцу `clickhouse_datetime_literal` (урок задачи 20).
3. **Предпроверка границы вместо guard'а чистого стенда.**
`assert_stand_clean` в DAG требует пустые Kafka и STG
(`airflow/dags/lib/airflow_control.py:99-109`) — next-day по построению
доливает в непустой стенд. Своя предпроверка: манифест существует,
state согласован с `T_end` манифеста, live-генератор остановлен.
Образец «непустого» режима — `assert_stand_clean.sh continue`
(в Python-версии guard'а такого режима нет — добавить именно для
next-day, `clean`-режим не менять).
4. **Идемпотентность — через явный параметр.** У DAG-запуска next-day есть
необязательный параметр `expected_t_end`: если задан и не совпадает с
`T_end` манифеста — громкий отказ, в тексте обе границы (ожидаемая и
фактическая). Повторный триггер «того же дня» с прежним `expected_t_end`
после успешного прогона ловится этим же сравнением. Без параметра —
генерация от текущей границы (режим «жизни»); от двойного параллельного
запуска защищает `max_active_runs=1`.
5. **Точка фиксации — публикация манифеста, автоотката нет.** Порядок
публикации дня: топики данных -> state -> манифест последним. Сбой до
манифеста оставляет «хвост» за границей — его ловит проверка цепочки
(непарные счётчики), восстановление — переимпорт артефакта
(задокументировать в OPERATIONS.md). Отклонено: инкрементальный откат
через `rollback_import_topics` — он удаляет топики целиком
(`startup_history_artifact.py:250-270`) и для доливки непригоден.
6. **Цепочка границ живёт внутри единственной записи манифеста.**
Манифест остаётся одной записью compact-топика с ключом `default`
(`kafka_io.py:205-274`); добавляется необязательное поле `boundaries` —
накопительный список границ `[T0, d1, ..., T_end]`. Старый манифест без
поля читается как `[T0, T_end]`. Итоговые счётчики и контрольные суммы
остаются накопительными за всю историю. Формат артефакта меняется только
этим необязательным полем — без новой версии и без изменения импорта.
7. **Проверка цепочки — новый батчевый режим, live-гейт не трогать.**
Текущий seam-гейт (`scripts/check_generated_analytics.sh:334-531`)
привязан к одной границе и живому генератору — литерально «расширить»
его нельзя. Новый режим (отдельная make-цель): по каждой границе из
`boundaries` — непарные счётчики и однородность per-event полей у
визитов, переживших границу (класс проверки задачи 09). Существующий
сценарий `make generated-history-runtime-check` остаётся зелёным как был.
8. **Расписание — вне этой задачи.** `schedule=None` у DAG не меняется;
задача гарантирует только готовность глагола к расписанию (пункты 4–5).
Включение «жизни» по расписанию — отдельная задача-продолжение (в ней же
решить: как расписание задаёт `operation=next-day`, ведь параметр DAG по
умолчанию — `backfill`, и планового запуска с дефолтом быть не должно).
9. **Исправлены ссылки:** запасная ветка рождения визита —
`generation.py:196-209` (строка 194 — основная ветка); донор
сериализуется в `runtime.py:178-226`.
## Acceptance criteria
- [x] Глагол `next-day` в `generator_control`: батч ровно за
`[T_end, T_end + 24h)` от `T_end` манифеста; после прогона манифест
содержит новую границу в `boundaries` и новый `T_end`.
- [x] Предпроверка границы: на пустом стенде (нет манифеста) next-day
громко падает; при работающем live-генераторе — громко падает;
`clean`-guard и существующие глаголы `backfill|import|check` не изменены.
- [x] Идемпотентность: запуск с `expected_t_end`, не равным `T_end`
манифеста, — громкий отказ с обеими границами в тексте; повторный запуск
того же дня после успеха — тот же отказ, дублей и второго мира нет.
- [x] Проверка цепочки (новая make-цель): проходит по всем границам из
`boundaries`; после N прогонов next-day в цепочке N новых границ;
непарные счётчики нулевые, per-event поля визитов через каждую границу
однородны (браузер, referer, utm).
- [x] Учебный цикл целиком и повторяемо: next-day -> прогон `etl_pipeline`
-> в DM-витринах появились строки ровно за новый день (счётчики до/после,
прирост только в диапазоне нового дня); проверено на двух next-day подряд.
- [x] Регрессий нет: тесты генератора и контракты корня зелёные
(`make test`), сценарий `make generated-history-runtime-check`
задачи 20 остаётся зелёным.
- [x] Документация: OPERATIONS.md — глагол, предпроверка, поведение при
сбое до фиксации манифеста (восстановление переимпортом артефакта).
## Границы (что не трогать)
- Поведение существующих глаголов `backfill`, `import`, `check` и
`clean`-guard'а.
- Формат артефакта — кроме необязательного поля `boundaries` (решение 6);
механику импорта не менять.
- Live seam-гейт задачи 20 (`generated-history-runtime-check`) — не
редактировать, только прогонять как регрессию.
## Blocked by
- Нет. Последний блокер снят 2026-07-12: `20-flaky-runtime-seam-check.md`
закрыта, гейт стыка стабилен (3 подряд зелёных прогона, красный сценарий
ловится).
- Исторические блокеры закрыты: `15-world-boundary-after-cross-review.md`
(граница миров) и `17-trusted-checks-startup-history-superset.md`
(доверенные проверки) — done.
Исторический контекст: `09-seam-browser-fixture-not-preserved.md` — шов
рождается при любом прохождении мира через сериализованный state (рестарт,
сбой, доливка — один код восстановления); фикс 09 положил донора фактуры в
state и закрыл тихие fallback'и. Прежняя редакция этой задачи называлась
«Доливка стартовой истории кусочком от слепка». Проверенные 2026-07-07
факты (import «всё или ничего», донор задачи 09) подтверждены обоими ревью
2026-07-12 по коду ветки.
## Решения по слепым ревью реализации (2026-07-12)
### Полная перечитка Kafka и конечный срок хранения — отклонено в задаче 13
Накопительные `visits` и `users` требуют точного объединения идентификаторов,
а текущая контрольная сумма — SHA-256 последовательности событий. Из одних
итоговых чисел и готовой контрольной суммы нельзя точно добавить новый день. Для
инкрементального расчёта пришлось бы хранить множества идентификаторов и новое
состояние hash в manifest. Это изменило бы формат артефакта сверх разрешённого
поля `boundaries` и нарушило бы решение 6.
Поэтому в задаче 13 остаётся точная пересборка по доступной истории Kafka.
Её проверяет
`test_next_day_publishes_24h_then_state_then_cumulative_manifest`: два дня,
точные накопительные счётчики и суммы. Риск принят только для ручного учебного
цикла. Ограничение по времени, памяти и сроку хранения записано в OPERATIONS.md.
Задача про расписание из решения 8 до включения обязана выбрать бессрочное
хранение data-топиков или новый согласованный формат накопительного состояния.
### Device/geo min/max timestamps после пересборки — отклонено
Эти два диапазона являются диагностическими полями статистики топика и не
участвуют в `compare_clickhouse_stats_to_manifest`, проверке цепочки или
критериях приёмки. Контракт задачи требует накопительные строки и контрольные
суммы; они пересчитываются точно и проверяются тестом двух `next-day`.
Выравнивание
device/geo min/max с потактовым backfill потребовало бы сохранять связь каждой
повторной строки с исходным browser-событием, которой в этих сообщениях нет.
Менять формат ради неиспользуемых диагностических полей в задаче 13 не следует.
### Нулевой стык — допустим с явным статусом
Диагноз текущего стенда подтвердил естественный ночной провал, а не потерю
визитов при восстановлении. На границе `2026-01-02T01:00:00+00:00` DDS и STG
дали `crossing_visits=0`. За предыдущий час завершилось 27 визитов, за
следующий началось 24. Последний визит закончился в `00:56:57.766327`, новый
начался в `01:00:00`; пауза составила 183 секунды. State на границе содержал
`active_visits=0`, поэтому restore не мог отбросить активный визит. Для
контроля: на предыдущей границе state содержал один активный визит, и DDS/STG
нашли один переходящий визит.
Поэтому нулевой стык проходит с явным статусом «однородность неприменима».
Пустая порция, хвост за последней границей и непарные строки по-прежнему дают
отказ. Остаточный слепой участок принят: однородность нельзя проверить без
переходящего визита. Restore закреплён тестом двух последовательных `next-day`
и детерминизмом задачи 09, а live-гейт задачи 20 отдельно проверяет стык, где
переходящий визит гарантирован сценарием.
@@ -0,0 +1,94 @@
Status: done
# Быстрый учебный профиль: суточная волна за минуты занятия
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Мир по умолчанию живёт со скоростью настенных часов (`speed=1`): чтобы
наблюдать суточную волну вживую, стенд должен непрерывно работать сутки.
Столько стенды включёнными не живут, и люди не вытерпят (пользователь,
2026-07-04). Стартовая история закрывает только половину проблемы: она даёт
закономерности **в статике**, но правый край графика ползёт 1:1 — на занятии
live выглядит мёртвой картинкой.
Решение пользователя (2026-07-04): учебному миру нужна скорость 60 — модельный
час за настенную минуту, полная суточная волна проживается за ~24 минуты
занятия.
## What to build
- Перевести профиль `daily-wave` (`launch.py`) на `GEN_MODEL_TIME_SPEED=60`
при `GEN_TICK_SECONDS=1`. Модельный тик остаётся прежним: 1 настенная
секунда × 60 = 60 модельных секунд, поэтому фактура потока (крупность пачек,
бюджет тика до 72 событий в пике: 60/мин × 1.2) не отличается от
сегодняшней, и колпак `GEN_MAX_EVENTS_PER_TICK` трогать не нужно.
- Наивный вариант «speed=60 при тике 60 с» отвергнут при разборе
(2026-07-04): модельный тик стал бы часом; первые события всех визитов тика
получают один таймстемп — старт тика (`runtime.py:433`,
`generation.py:198`) — история слиплась бы в почасовые гребёнки, а пиковый
тик (4320 событий) упёрся бы в колпак 1000 и срезал бы верхушку волны.
- **Оценить цену тика в 1 Гц.** Сейчас каждый live-тик пишет state в
compact-топик, запись в историю пачек и несколько INFO-строк
(`service.py:566, 584-585, 591-600`): на тике 1 с это 86 400 сохранений и
~полмиллиона строк лога за настенные сутки против 1 440 сегодня. Пер-тиковые
INFO приглушить до DEBUG (или логировать раз в N тиков); частоту сохранения
state менять только с оглядкой на спеку — она входит в контракт
возобновления после сбоя.
- Профиль `ci` не трогать: это дефолт CI, его мир и контрольные суммы должны
остаться прежними.
- Обновить документы, где живут предположения о старом `daily-wave`:
runbook `docs/runbooks/startup-history.md` (таблица профилей; продолжение
мира — `PROFILE=daily-wave make generator-continue`; старые артефакты
профиля становятся несовместимыми — антисмешивание отработает громко, это
ожидаемо), `docs/OPERATIONS.md` (smoke-последовательность со `sleep 130` —
ожидание двух минутных тиков — на тике 1 с теряет смысл), `README.md`,
`generator/README.md`.
- Спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`
дополнить: выбранная пара tick/speed и почему именно она.
## Acceptance criteria
- [x] После backfill/импорта `daily-wave` и `PROFILE=daily-wave make
generator-continue` модельное время идёт ~в 60 раз быстрее настенного; на
дашборде суточная волна проживается за ~24 настенные минуты.
- [x] Пиковые тики не упираются в `GEN_MAX_EVENTS_PER_TICK` — волна не
срезана.
- [x] Стык «история → live» бесшовный: без дублей и дыр, антисмешивание
работает как раньше.
- [x] Многочасовой live на тике 1 с не заливает журналы и compact-топики:
пер-тиковые INFO приглушены или их объём обоснован в PR; объём
state-записей (86 400/сутки) измерен и обоснован — либо частота сохранения
state осознанно изменена вместе с правкой спеки (контракт возобновления).
- [x] Профиль `ci`, его контрольные суммы и поведение по умолчанию не
изменились; существующие тесты генератора проходят.
- [x] Runbook, `docs/OPERATIONS.md`, README'и и спека обновлены.
## Notes
- Нагрузка по событиям: ~42–72 события в секунду (ночь–пик) — незаметно для
Kafka и ClickHouse; backfill не дорожает (число тиков на 2 суток то же, что
сейчас: модельный шаг тика не изменился).
- Если генерация тика займёт больше 1 настенной секунды, тики пойдут реже и
фактическая скорость окажется чуть ниже 60 (`service.py:623-627`) — это
допустимо, точная скорость не обещается.
- Несовместимость со старыми мирами ловится по-разному: импорт артефакта и
continue от стартовой истории сверяют полный набор настроек (включая
`tick_seconds` в манифесте), а обычный live-continue — только
seed/T0/timezone/speed (`service.py:213-230`); для этой правки хватает и
его — speed изменился.
- Альтернатива «третий профиль fast-live» отвергнута: у медленного
`daily-wave` не остаётся сценария использования, а лишний профиль усложняет
выпадашку пульта (задача 12).
- Рекомендуемый режим ревью по coordinator-loop: обычный (значения профиля и
логирование). Исключение: если по ходу решат менять частоту сохранения
state — это уже контракт возобновления, эскалировать в **гейт**.
## Blocked by
Нет: профили из задачи 11 сделаны. Мягкие связи: задача 12 (профиль появится
в форме пульта автоматически) и задача 08 (артефакт курса рождается из этого
профиля — эту задачу лучше сделать до него).
@@ -0,0 +1,46 @@
Status: done
# Граница миров после кросс-ревью
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Закрыть семейство находок кросс-линейного ревью про «смешение миров»: после
обновления кода, сброса стенда или консольного запуска старое состояние и старый
live-генератор не должны тихо писать события в новый мир данных.
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
разделы `Issue 12/14` и `Issue 09`.
Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`,
`12-generator-control-dag.md`, частично `14-fast-teaching-profile.md`.
## Acceptance criteria
- [x] Известная старая версия state при `GEN_STATE_RESET=false` даёт громкий
отказ с понятным сообщением, а не тихий чистый старт поверх существующей
истории.
- [x] Сброс и остановка стенда убирают ручной live-генератор из compose-профиля;
он не переживает `down`/`clean` и не воскресает после перезагрузки хоста.
- [x] Консольные пути backfill/import/continue используют ту же идею
предпроверки чистого стенда, что Airflow-пульт, но реализованы способом,
пригодным для запуска с хоста.
- [x] Документация объясняет миграционный случай: почему старый state может
теперь дать громкий отказ и что делать оператору или менти.
- [x] Есть проверка, которая доказывает каждый из трёх путей смешения миров:
старая версия state, compose-профиль live-генератора, консольный запуск на
грязном стенде.
## Blocked by
Нет — можно начинать сразу.
## Notes
- Рекомендуемый режим `coordinator-loop`: гейт. Worker `high`, ревью задачи
`high`, ревью кода `high`.
- Не чинить здесь ложнозелёные проверки и курс целиком: это отдельные задачи
17 и 18. Здесь только граница миров и доказательства именно этой границы.
@@ -0,0 +1,50 @@
Status: done
# Свежий стенд и Airflow-пульт без догадок
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Сделать так, чтобы человек на свежем клоне мог пройти быстрый старт до
`generator_control` без неявных шагов, сырых ошибок и зависаний Airflow.
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
разделы `Issue 12/14` и `Issue 08`.
Связанные старые issue: `12-generator-control-dag.md`,
`14-fast-teaching-profile.md`.
## Acceptance criteria
- [x] Quick start и курс явно называют `uv` и остальные обязательные
пререквизиты до первой команды, которая их требует.
- [x] Быстрый старт на чистом стенде подготавливает DDL до проверок генератора;
отсутствие таблиц не падает сырым `UNKNOWN_TABLE`.
- [x] После обновления репозитория Airflow получает новые зависимости и DAG-и
без ручных догадок: через пересборку, явную команду или ясно описанный шаг.
- [x] `generator_control` не висит навсегда, если зависимый `etl_pipeline`
стоит на паузе: оператор получает понятный отказ или инструкцию до запуска
долгого ожидания.
- [x] Решение по Airflow API, проверенное через Context7 для текущей версии,
зафиксировано в коде или документации кратко и рядом с местом выбора.
- [x] Runbook предупреждает, что долгий простой `daily-wave` на скорости 60
создаёт большую дыру в модельном времени, и советует reset или повторный
импорт стартовой истории.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md`
## Notes
- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, ревью
кода `high`. Если правка уйдёт в устройство DAG-зависимостей шире
предпроверки паузы, эскалировать до гейта.
- Формальная зависимость от 15 нужна из-за общего участка предпроверки чистого
стенда: свежий стенд должен опираться на уже выбранный способ отказа и
диагностики грязного мира.
- Не включать сюда учебные тексты уроков 00/01/05, кроме пререквизитов и
quick start: основной проход курса закрывает задача 18.
@@ -0,0 +1,48 @@
Status: done
# Проверки startup-history и Superset без ложного зелёного
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Сделать проверки доверенными: зелёный результат должен означать, что проверен
фактический стык стартовой истории и живого продолжения, а Superset export
соответствует ожидаемому контракту чарта.
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
разделы `Issue 09`, `Issue 10` и `Issue 08`.
Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`,
`10-dashboard-geo-map-readability.md`, частично `14-fast-teaching-profile.md`.
## Acceptance criteria
- [x] Проверка startup-history берёт границу стыка из манифеста фактического
прогона, а не из значения окружения по умолчанию.
- [x] ODS-часть проверки не проходит на пустом ODS: отсутствие строк считается
ошибкой проверки, а не успешным результатом.
- [x] Профиль запуска доходит до проверки: сценарий с `daily-wave` проверяет
именно мир `daily-wave`, а не диапазон `ci`.
- [x] Ожидание live-строк доказывает, что живое продолжение реально успело
записать данные; короткое ожидание не должно превращать проверку стыка в
пропущенную проверку.
- [x] Тест Superset сравнивает полный существенный контракт `params` между
конфигурацией и export, а не только небольшой набор полей.
- [x] Сортировка top countries задана явно по числу событий, чтобы порядок не
зависел от случайной формы ответа после pivot.
- [x] Повторная синхронизация Superset не удаляет одноимённые чарты вне целевого
источника данных или dashboard; деструктивность sync задокументирована.
## Blocked by
Нет — можно начинать сразу.
## Notes
- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`,
ревью кода `high`.
- Не смешивать с правками курса: задача 18 должна пользоваться уже доверенными
проверками, но текст уроков не является частью этой задачи.
@@ -0,0 +1,50 @@
Status: done
# Курс проходит по startup-history на чистом стенде
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## What to build
Довести учебный путь до воспроизводимого прохода на чистом стенде: уроки должны
работать с `startup-history`, не опираться на архивный сид и не обещать live-поток
там, где он больше не запускается по умолчанию.
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
раздел `Issue 08`.
Связанные старые issue: `08-migrate-course-from-archive-seed.md`, частично
`12-generator-control-dag.md` и `14-fast-teaching-profile.md`.
## Acceptance criteria
- [x] Упражнение урока 01 с добавлением `kafka_msg_ts` воспроизводимо на стенде:
повторная заливка данных не сбрасывает схему, которую только что изменил
менти.
- [x] Урок 00 объясняет служебные топики генератора настолько, чтобы список
топиков не выглядел как набор неожиданных артефактов.
- [x] Уроки 00 и 05 не противоречат друг другу в описании видимости consumer lag;
утверждение проверено на текущем стенде.
- [x] Урок мониторинга согласован с no-live default: либо явно запускает live,
либо не обещает, что алерт отсутствия сообщений будет тихим на здоровом
стенде без live.
- [x] Операционная документация не ведёт учебный путь первым делом к архивному
`make data`; startup-history остаётся основным источником аналитики.
- [x] Проход уроков 00, 01 и 05 на чистом стенде проверен командно или вынесен
в явный HITL-пункт с точной инструкцией, что должен подтвердить человек.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md`
- `.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md`
- `.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md`
## Notes
- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, ревью
кода и документов `high`.
- Если по ходу окажется, что упражнение 01 требует нового поддержанного режима
перезаливки, не прятать это в тексте урока: оформить как явное решение и
покрыть проверкой.
@@ -0,0 +1,63 @@
Status: done
# Единые make test и make lint для коммит-гейта
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Во время coordinator-loop по задачам 15-18 координатор несколько раз упёрся в
отсутствие общих целей `make test` и `make lint`. Из-за этого коммит-гейт
собирался вручную из частных команд: `make generator-test`, `docker compose
config --quiet`, `py_compile`, `bash -n`, `git diff --check` и отдельных
стендовых проверок.
Для учебного проекта это плохой пример: у менти и агента должен быть один
понятный путь проверки, а не набор догадок.
## What to build
Добавить поддержанные цели `make test` и `make lint`, которые покрывают текущие
реальные проверки репозитория и не печатают лишний подробный вывод по умолчанию.
## Acceptance criteria
- [x] `make test` существует и запускает полный быстрый набор проверок, нужный
перед коммитом: тесты генератора, контрактные тесты верхнего уровня и
ключевые проверки конфигурации.
- [x] `make lint` существует и запускает статические проверки, которые сейчас
выполняются вручную: синтаксис Python, `bash -n`, `docker compose config
--quiet`, `git diff --check` или обоснованно выбранный эквивалент.
- [x] Цели используют `uv` там, где нужен Python вне Docker, и не зависят от
локального `pytest`, случайно установленного на хосте.
- [x] Шумные команды не вываливают полный список тестов при успешном прогоне;
подробный вывод доступен через явный режим или отдельную команду.
- [x] `docs/OPERATIONS.md` или `README.md` кратко объясняет, когда запускать
`make test`, `make lint` и какие более дорогие стендовые проверки остаются
отдельными.
## Blocked by
Нет — можно начинать сразу.
## Notes
- Не включать в `make test` долгие или разрушительные проверки вроде
`make generated-history-runtime-check` без явного решения: они чистят volumes
и управляют стендом.
- Отдельно учесть проблему окружения coordinator-loop 2026-07-05: локальный
`uv run ... pytest` падал до запуска тестов из-за `snap-confine`, поэтому
цель должна либо идти через устойчивый Docker-путь, либо явно диагностировать
такую ошибку.
- По ревью Claude дополнительно подключены корневые контрактные тесты, усилены
ключевые проверки поведения `CHECK_LIVE_SEAM`/профиля/DM-витрин, а также
закрыты подтверждённые обходы через `generator-reset` и безверсионный state.
## Проверка
- `make contract-test` — 16 passed.
- `make generator-test` — 190 passed.
- `make lint` — passed.
- `make test` — 190 generator tests, 16 contract tests, compose config passed.
@@ -0,0 +1,158 @@
Status: done
# Runtime-проверка стыка нестабильна: фактура меняется через раз
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Что нашли
Два подряд прогона `make generated-history-runtime-check` на одном и том же
стенде (2026-07-07, проверка пути менти) дали разный результат:
- Прогон 1 (20:18–20:20): **красный** —
`Ошибка: per-event фактура меняется на стыке: 8/19`
(у 8 из 19 визитов, переживших границу backfill->live, поменялась
per-event фактура). При этом `duplicate_events=0`, events=2776.
- Прогон 2 (21:5x, та же команда, без изменений кода): **зелёный** —
«runtime-проверка startup-history/live seam прошла», конфликтов 0,
events=2815.
Конфигурация проверки: `GEN_LAUNCH_PROFILE=daily-wave`,
`GEN_HISTORY_DURATION=1h`, `GEN_MODEL_T_END=2026-01-01T01:00:00+00:00`
(зашита в `scripts/run_generated_history_runtime_check.sh`).
## Почему это важно
- Это гейт, которому мы доверяем стык backfill->live (задача 17); флаки-гейт
ничего не гарантирует: красный пугает зря, зелёный ничего не доказывает.
- Смена фактуры на стыке — класс дефекта задачи 09, который считается
закрытым (донор фактуры сохранён в state, тихие fallback'и заменены на
ошибки). Либо фикс неполон, либо есть второй источник расхождения.
- Задача 13 (глагол next-day) навешивает на этот же механизм цепочку границ —
ей нужен доверенный, стабильный гейт.
## Диагноз (Codex, 2026-07-07; ключевой факт перепроверен координатором)
**Корень: гонка остановки live-генератора, а не смена фактуры.** Цепочка:
`run_generated_history_runtime_check.sh` останавливает live сразу после первых
новых STG-строк -> генератор публикует batch по четырём топикам
последовательно, без атомарности -> `docker compose stop generator` может
оборвать процесс между топиками -> часть browser-событий остаётся без парных
location/device/geo -> DDS строит `dds.event` через `LEFT JOIN location`,
и такие строки получают `NULL` в referer/utm -> seam-SQL считает это «сменой
фактуры внутри click_id».
Эмпирика: в красном прогоне `browser_raw=2776`, но `location_raw=2736` —
40 live-событий без пары; в зелёном `browser_raw=location_raw=2815`, поэтому
проверка прошла (хотя device/geo и там отстали: 2781).
Судьба гипотез:
1. Запасная ветка рождения — **опровергнута**: ветка уже хранит донора
(`generation.py:196-209`, комментарий «Запасная ветка тоже восстановима»)
и громко падает на неполных locations. Перепроверено координатором по коду.
2. Недетерминизм live — **подтверждена частично**: решает не сид, а настенный
момент остановки процесса и какие топики успели дописаться.
3. Проверочный SQL — **опровергнута в формулировке**: поля не «легитимно
различаются», SQL маскирует пропущенный location под «смену фактуры».
Уточнение механизма обрыва (адверсарное ревью постановки, 2026-07-12;
перепроверено координатором по коду):
- Генератор не обрабатывает SIGTERM: ловится только `KeyboardInterrupt`
(`service.py:175`), а `docker compose stop` шлёт именно SIGTERM. В compose
у сервиса generator нет `init:`/`stop_signal:`, python работает PID 1 —
SIGTERM игнорируется, и через grace-период прилетает SIGKILL: жёсткий
обрыв посреди batch, без `finally` и без flush.
- Почему отставание device/geo не валит гейт, а location валит: device/geo
привязаны к `click_id` (одно значение на визит) — при пропуске весь визит
однородно NULL, массив уникальных значений длины 1, проверка проходит;
location привязан к `event_id` — частичный пропуск даёт смешанный массив
(реальное значение + NULL) и «смену фактуры». Догонять device/geo фикс
не обязан — важна граница batch.
## Направление фикса (развилка решена после ревью постановки 2026-07-12)
1. **Основное: корректная обработка SIGTERM в генераторе.** Минимальный
хендлер: по SIGTERM выставить `_running = False`, дать текущему tick'у
дописаться (все четыре топика + flush + запись batch history), затем
штатный `finally`/`stop()`. Runtime-check после `docker compose stop`
дожидается фактического завершения контейнера. Так генератор завершается
только на границе batch — гонка снята по построению, а не вероятностно.
- **Отклонено: «ожидание записи `generator_batch_history` перед stop»
как самостоятельный фикс** — запись history подтверждает только
ПРОШЕДШИЙ batch (`service.py:586-603`, пишется после цикла публикации);
следующий batch к моменту stop уже может быть в полёте, и SIGKILL
оборвёт его так же. Довод — ревью постановки, перепроверен по коду.
- Bounded live mode (генератор сам останавливается по лимиту) — более
тяжёлая альтернатива; в скоуп не входит, возвращаться к ней только
если SIGTERM-хендлера окажется недостаточно (с доводом в задаче).
2. **Дополнительно:** precheck в seam-check на непарные
browser/location/device/geo live-строки — чтобы ошибка называла реальную
причину, а не «фактура поменялась». Precheck не подменяет основную
проверку: настоящая смена фактуры внутри `click_id` обязана падать
как и раньше.
3. **Отклонены любые вероятностные смягчения**, а не только «увеличить
sleep»: settle-sleep перед прогоном, рост `WAIT_LIVE_ROWS`/`LIVE_SECONDS`,
сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить
вероятность — гонку они не убирают и фиксом не считаются.
## Принятый остаточный риск (решение координатора, 2026-07-12)
Ревью отметило: `stop_grace_period: 1m` не ограничивает публикацию по
времени — при зависании Kafka дольше минуты SIGKILL всё ещё оборвёт batch.
Риск принят: штатный тик публикуется за секунды (минута — многократный
запас), а ограничивать публикацию таймером значило бы рвать batch уже по
построению. Ключевое отличие от исходного флаки: такой обрыв больше не
маскируется под «смену фактуры» — precheck непарных строк назовёт его
явно, гейт упадёт громко и честно.
## Acceptance criteria
- [x] Причина расхождения 8/19 найдена и названа (код, не догадка) —
см. «Диагноз» выше.
- [x] Генератор корректно завершается по SIGTERM: текущий batch дописывается
во все четыре топика целиком (flush + запись history), потом процесс
выходит; runtime-check дожидается фактической остановки контейнера
(направление фикса, пункт 1). Плюс `stop_grace_period: 1m` в compose.
- [x] Seam-check различает «непарные live-строки» и «смена фактуры»:
precheck называет реальную причину (пункт 2).
- [x] Красный сценарий по-прежнему ловится: контролируемое искажение
`referer_url` у события пересекающего визита в `dds.event` уронило гейт
с прежним сообщением «per-event фактура меняется на стыке: 18/19» при
нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12).
- [x] Стабильность обоснована структурно (SIGTERM ставит флаг, тик
дописывает все четыре топика + flush + history и выходит на границе
batch — тест `test_sigterm_during_publish_finishes_current_batch`);
дымовая проверка поверх довода: 3 подряд зелёных
`make generated-history-runtime-check` (2026-07-12, прогоны 19:25,
19:28, 19:30).
## Находки стендовой приёмки (2026-07-12, исправлены в этой же задаче)
Ревью по чтению кода их поймать не могло — вскрылись только прогонами:
1. **Гейт жил на побочном эффекте бага.** Пересекающие визиты для проверки
стыка появлялись только потому, что генератор игнорировал SIGTERM и
дописывал ~10 секунд данных до SIGKILL. После фикса live-хвост стал
коротким и пересекающих визитов могло не быть вовсе. Фикс: шаг 6
runtime-check ждёт не «любую новую STG-строку», а появления
пересекающего визита в STG (детерминированное предусловие проверки,
то же окно, что у seam-SQL).
2. **Часовой пояс в STG-запросах.** Сырые `event_timestamp` наивные и
означают UTC, сервер ClickHouse — Europe/Moscow: сравнение с границей,
заданной с `+00:00`, уезжало на 3 часа, precheck был зелёным вакуумно
(не видел ни одной live-строки). Фикс: явный `'UTC'` в
`parseDateTime64BestEffort*` с обеих сторон сравнения (4 места),
закреплено контрактными тестами.
## Blocked by
- Нет. Диагноз можно начинать сразу; стенд воспроизводит через раз.
Связано: `09-seam-browser-fixture-not-preserved.md` (класс дефекта и решение
про донора), `13-backfill-top-up-from-snapshot.md` (нуждается в доверенном
гейте на цепочке границ), `17-trusted-checks-startup-history-superset.md`
(появление этого гейта).
@@ -0,0 +1,73 @@
Status: done
# Подготовка ветки к слиянию: .gitignore для артефактов и необязательные поля формы
## Parent
`.scratch/generator-model-time-startup-history/PRD.md` — закрывающая
гигиена перед слиянием `feature/data-generator` в `main`. Находка F1 — из
`.scratch/generator-model-time-startup-history/hitl-findings.md`.
## Что не так
1. **Побочные артефакты backfill не покрыты `.gitignore`.** После HITL в
`data/` лежит `ci_backfill.json` (49 МБ) — риск случайного коммита.
Сиды проекта в `data/` — это `*.jsonl`, они под git и должны остаться.
2. **F1 (BUG):** в форме `generator_control` (Trigger DAG w/ config) все
пять необязательных полей (`duration`, `seed`, `model_time_speed`,
`artifact_path`, `expected_t_end`) показаны обязательными: красная `*`,
браузер не даёт отправить форму с пустым полем. Канонический запуск
«выбрать профиль, остальное пусто» через UI невозможен.
## Причина F1 (проверено)
В `airflow/dags/generator_control_dag.py:230-265` эти `Param(...)`
объявлены с `type="string"`. Шаблон формы Airflow вешает `*` и HTML-атрибут
`required` на каждое поле, у которого в `type` нет `"null"`.
Проверено по Context7 (`/apache/airflow/2.10.5`, 2026-07-19): идиома
необязательного строкового параметра — `Param(None, type=["null", "string"])`;
поле с `"null"` в `type` форма не помечает обязательным.
Чтение параметров в DAG уже терпимо к `None`: везде
`str(_param(...) or "").strip()` (строки 71-161), поэтому смена пустого
значения с `""` на `None` ничего не ломает — но это надо перепроверить
глазами по каждому месту чтения.
## Что сделать
- [x] В `.gitignore` добавить правило `data/*.json` (раздел «Тестовые
данные и артефакты») с комментарием: артефакты генератора; сиды
`data/*.jsonl` остаются под git.
- [x] Пять необязательных `Param` в `airflow/dags/generator_control_dag.py`
перевести на `Param(None, type=["null", "string"])`. Обязательные
(`operation`, `profile`) не трогать. У изменённых Param коротким
комментарием зафиксировать: `"null"` в type = необязательное поле
формы (проверено по Context7, Airflow 2.10.5).
- [x] Убедиться, что каждое место чтения этих параметров в DAG переживает
`None` (сегодня везде `or ""` — сверить полный список).
- [x] В `generator/tests/test_generator_control_dag_contract.py` дополнить
контракт: у пяти необязательных Param в объявлении есть `"null"`,
у `operation`/`profile` — нет.
- [x] `docs/OPERATIONS.md`: сверить описание формы `generator_control`;
если там есть обход «заполняйте все поля» — убрать, поведение
«пусто = из профиля» оставить как есть.
- [x] Дешёвые проверки зелёные: `make test` и `make lint` (нужен docker;
если в песочнице исполнителя недоступен — явно сказать в отчёте,
прогонит оркестратор).
## Границы
- Логику генератора (`generator/src/`) не менять.
- Профили и их состав не трогать (это отдельная задача редизайна).
- Форму DAG не перестраивать (отдельный DAG `next-day` — тоже отдельная
задача, F3).
- `data/ci_backfill.json` не удалять и не коммитить.
- Коммиты не делать.
## Сначала прочитать
1. Этот файл.
2. `.scratch/generator-model-time-startup-history/hitl-findings.md` — F1.
3. `airflow/dags/generator_control_dag.py` — объявления и чтение Param.
4. `generator/tests/test_generator_control_dag_contract.py`.
@@ -0,0 +1,65 @@
# Handoff: спеки генератора готовы — передача на детальный план и реализацию
Дата: 2026-06-10
Ветка: `feature/data-generator`
Жанр: одноразовые леса́ (ADR-0003) — durable-рассуждение в спеках/ADR/CONTEXT, не здесь.
> Место: `.scratch/handoffs/` по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md)
> (перекрывает generic-дефолт скилла «temp dir»: мультимашинность + worktrees).
> Предыдущий handoff (2026-06-09-generator-rework.md) отработал и удалён.
## Где остановились
Дизайн-этап переработки генератора **завершён**. Спека математической модели
написана, прошла **два** адверсариальных ревью свежими агентами (второе ревью
ловило ошибки правок первого — практика себя оправдала), все находки закрыты,
ключевые цифры перепроверены замерами по полному сиду. Кода по-прежнему
не трогали.
Разделение ролей (см. память проекта): дизайн/спеки — Fable, **детальный план
и реализация — Codex 5.5**. Следующая сессия — скорее всего, подготовка задачи
для Кодекса или сама реализация.
## Документы (источники истины, не пересказываю)
- **Мат-модель (главный документ для исполнителя):**
`docs/specs/2026-06-10-generator-math-model.md` — марковская цепочка по
страницам, формула «популяция ↔ интенсивность ↔ пауза», кулдаун, правило
30 минут на рестарт, критерии приёмки.
- **Форма доработки:** `docs/specs/2026-06-09-generator-rework-hierarchical.md`
(Open questions закрыты ссылкой на мат-спеку).
- **Почему генератор, а не реплей:** `docs/adr/0004-steady-stream-synthetic-generator.md`.
- **Профиль сид-датасета (опора калибровки):** `CONTEXT.md`, раздел
«Профиль сид-датасета» — измерено по полным файлам 2026-06-10.
- **Диагноз дефекта старого кода:** `generator/KNOWN_ISSUES.md`.
## Ловушки (одной строкой; детали — в спеках)
- Ранний «факт» **«1..7 событий на визит» был неверен** (срез файла); реально
1..27, медиана 10. Если встретишь «1..7» где-то ещё в доках/коде — это
остатки ошибки, чинить по профилю сида.
- **Формула паузы в мат-спеке обязательна** при смене λ/популяции; кулдаун в
неё не прибавляется (уже учтён балансом). Вторая ревизия исправляла именно
двойной счёт — не откатить случайно.
- **device/geo публикуются на каждое событие** (как в сиде) — это контракт с
ETL, не деталь.
- `event_timestamp` событий — **запланированное** время, не момент отправки
тика (иначе метки прилипают к сетке тиков).
- Модель интенсивности (`_calculate_events_count`) сохраняем; дефолт
`GEN_LAMBDA_BASE_PER_MIN` меняется 200 → 30.
## Следующий шаг
Передать пару спек Кодексу: детальный план реализации → код. Уровень спек
сознательно «решения и инварианты, без алгоритмов» — конкретику исполнитель
достраивает сам. Возможная подготовка: оформить задачу в `.scratch/<feature>/`
по `docs/agents/issue-tracker.md` (скилл `to-issues`, если план дробить).
## Suggested skills (для следующей сессии)
- **`to-issues`** — если решим дробить реализацию на задачи в локальном трекере.
- **`tdd`** — для этапа реализации (pytest-набор `generator/tests/` существует,
но писался под старую модель — пересмотр под новую неизбежен).
- **`adversarial-review`** / **`code-review`** — ревью реализации против
мат-спеки перед вливанием.
- **`conventional-commits`** — коммиты по правилам репозитория.
@@ -0,0 +1,66 @@
# Handoff: генератор разбит на AFK-задачи
Дата: 2026-06-11
Ветка: `feature/data-generator`
Жанр: одноразовые леса по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md).
## Где остановились
После обсуждения с пользователем дизайн переработки steady-stream генератора
переведён из больших спек в локальные задачи для агентов. Код генератора не
меняли.
Создан каталог задач:
- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md`
- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md`
- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md`
- `.scratch/feature-data-generator/issues/04-user-population-and-returns.md`
- `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md`
- `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`
- `.scratch/feature-data-generator/issues/07-service-integration-and-docs.md`
Все задачи имеют `Status: ready-for-agent`. Разрез сделан как последовательные
вертикальные срезы, а не как слои архитектуры. Решение пользователя: идти по
этому варианту.
## Важный контекст
- Старый генератор считаем слабым прототипом, а не ценным кодом для сохранения.
Сохранять надо внешние контракты, если они полезны: топики Kafka, формат
сообщений для текущего ETL, команды запуска, метрики, идею compact-топика
состояния.
- Отдельную задачу "зафиксировать внешний контракт" не создавали: контракт
встроен в первый и последний срезы.
- Детали модели не дублировать отсюда. Источники истины:
- `docs/specs/2026-06-10-generator-math-model.md`
- `docs/specs/2026-06-09-generator-rework-hierarchical.md`
- `docs/adr/0004-steady-stream-synthetic-generator.md`
- `CONTEXT.md`
- `generator/KNOWN_ISSUES.md`
- Предыдущий handoff `.scratch/handoffs/2026-06-10-generator-spec-to-codex.md`
остаётся полезным как предыстория спек.
## Следующий шаг
Начать с задачи 01 через TDD:
1. написать один красный тест на публичное поведение "минимальный связанный
визит";
2. реализовать минимальное новое генеративное ядро без Kafka;
3. не тащить старую плоскую модель `generate_batch()`;
4. после зелёного теста переходить к следующему поведению, не писать все тесты
заранее.
Пример запуска задачи:
```text
/goal Реализовать .scratch/feature-data-generator/issues/01-minimal-connected-visit.md с использованием /tdd. Соблюдать цикл: один тест на наблюдаемое поведение → минимальная реализация → зелёный тест → остановиться и отчитаться. Не писать все тесты заранее, не реализовывать задачи 02-07.
```
## Suggested skills
- `tdd` — для реализации каждой задачи короткими циклами red-green-refactor.
- `adversarial-review` или `claude-team-review` — после реализации нескольких
срезов, чтобы сверить код с мат-спекой.
- `conventional-commits` — при фиксации следующих изменений.
@@ -0,0 +1,86 @@
# Handoff: ADR про модельное время генератора + отложенное ревью петли
Дата: 2026-06-11
Ветка: `feature/data-generator`
Жанр: одноразовый handoff по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md).
## Зачем этот handoff
Сессия с Fable шла в стиле brainstorm-with-docs про **модельное время
генератора**. Параллельно идёт эксперимент: автономная петля субагентов Кодекс
(координатор + worker + reviewer) доделывает срез генератора (задачи 06→07).
Эти две линии **намеренно разделены** — пересмотр времени не правит то, что петля
строит на реал-тайм-модели. Handoff фиксирует, что уже сделано и что осталось,
чтобы не потерять контекст в новой сессии.
## Что сделано в этой сессии (durable, не дублирую — см. файлы)
- Создан **[ADR-0005](../../docs/adr/0005-generator-model-clock.md)** «Модельные
часы генератора, отвязанные от настенного времени». Все решения там; кратко:
модельное время расцеплено с `now()`; режимы (живой ×1 / ускоренный ×K /
заливка `K → ∞`) — драйверы поверх одного шва; живой стенд масштабируется ×K
(дефолт ×1); правило 30 минут переопределяется в **модельном** времени с
параметрическим origin возобновления; «стартовая история стенда»
(сгенерированное прошлое + заморозка state v2) — **будущее направление, ещё не
строим**.
- Правки **[`CONTEXT.md`](../../CONTEXT.md)** (глоссарий): разведены три значения
слова «сид» (`GEN_SEED` / статический сид / стартовая история стенда) и добавлен
термин «модельное время и масштаб ×K».
**Эти изменения (ADR-0005 + CONTEXT.md) ещё НЕ закоммичены.** Их коммит — выход
брейншторма пользователя, его нельзя мешать с коммитами кода от петли (06/07).
Отдельный docs-коммит.
## Открытые線 для следующей сессии
### 1. Отложенное ревью результата автономной петли (приоритет)
Делать **после** того, как петля закоммитит задачу 07. Это read-only ревью.
Измерительный лист — в памяти проекта
`memory/ralph-loop-experiment-generator.md`. Главное:
- **A (главный индикатор эксперимента):** ссылочная целостность при
`restore_state`. `_validate_v2_payload` проверяет только форму, не ссылки.
Структурно-валидный state v2 с висячей ссылкой визит→пользователь
(`generator/src/clickstream_generator/runtime.py:283`) или пользователь→словарь
(`runtime.py:253/255`) проходит `from_dict_safe` и роняет `restore_state` без
обёртки try→fresh. Прогноз: петля это **пропустит** (слепые зоны внутри одной
линии Кодекс скоррелированы). Проверить гипотезу локально структурой с висячей
ссылкой (как пользователь проверял оценку снимка 4.7 MB и падение на битой
вложенности).
- **B/D (проверено OK ранее):** кулдауны по меткам, не тикам (`runtime.py:104`);
детерминизм компактного снимка (`_stable_event_id`, restore не жжёт ГПСЧ).
Подтвердить, что 06→07 их не сломали.
- **C (риск, вне скоупа 06):** всплеск досылки созревших событий с прошлыми
метками на первом тике после рестарта — всплыл ли хоть как риск в саморевью.
- Верхнеуровневое для 07: границы задачи, честность отметок acceptance, не
смешаны ли 06/07, `git status`.
Цель ревью двойная: (1) корректность 06/07; (2) мета-вывод — **какой класс
дефектов автономная петля систематически не видит без внешнего взгляда другой
модели**.
### 2. Реконсиляция мат-спеки с ADR-0005
После ревью петли. Разделы «Персистентность» и «Воспроизводимость» в
`docs/specs/2026-06-10-generator-math-model.md` написаны от настенных часов;
привести в соответствие с ADR-0005 (модельное время, правило 30 минут в
модельном времени). Не делать сейчас — файл смежен с тем, что петля трогала.
## Фон (память проекта, не дублирую)
- `memory/ralph-loop-experiment-generator.md` — схема Ральф-цикла + измерительный
лист.
- `memory/generator-full-rework.md`, `memory/fable-design-codex-implementation.md`
— направление переработки генератора и разделение ролей Fable(дизайн)/Codex(код).
- `.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md` — детальная
схема эксперимента с координатором и наблюдения по циклам 05/06.
## Suggested skills
- `conventional-commits` — для отдельного docs-коммита ADR-0005 + `CONTEXT.md`
(не мешать с кодом петли).
- `brainstorm-with-docs` — когда вернёмся к реконсиляции спеки или к проектированию
«стартовой истории стенда» (будущее направление из ADR-0005).
- `handoff` — после ревью петли зафиксировать рефлексию эксперимента (что петля
поймала/пропустила, особенно по находке A).
@@ -0,0 +1,242 @@
# Handoff: эксперимент с координатором субагентов
Дата: 2026-06-11
Ветка: `feature/data-generator`
Жанр: одноразовый handoff по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md).
## Зачем нужен этот handoff
Пользователь хочет провести эксперимент: не реализовывать следующие задачи
самому верхнеуровневому агенту, а использовать его как координатора цепочки
субагентов. После эксперимента планируется отдельно отрефлексировать, что
сработало, что не сработало и стоит ли закреплять такой процесс.
Этот документ фиксирует текущее понимание процесса перед запуском, чтобы не
потерять договорённости в новой сессии.
## Текущий рабочий контекст
Активная линия работы — переработка steady-stream генератора кликстрима.
Задачи лежат в `.scratch/feature-data-generator/issues/`.
Уже выполнены и ожидают человеческой приёмки:
- `01-minimal-connected-visit.md`
- `02-visit-page-path-and-monotonic-time.md`
- `02-5-generator-service-cleanup.md`
- `03-tick-stream-with-active-visits.md`
- `04-user-population-and-returns.md`
Оставшаяся последовательность:
1. `05-intensity-and-flow-calibration.md`
2. `06-state-v2-and-restart.md`
3. `07-service-integration-and-docs.md`
Задачи зависимы и, вероятно, меняют близкие файлы генератора, поэтому запускать
их надо строго последовательно, не параллельно.
В рабочем дереве на момент обсуждения была незакоммиченная правка только в
`.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`: добавлен
комментарий, что задачу 06 нельзя начинать до завершения предыдущих срезов,
особенно задачи 05.
## Договорённая схема эксперимента
Верхнеуровневый агент не включает для себя `/goal`. Его роль — координатор и
gatekeeper, а не непосредственный исполнитель.
Для каждой задачи:
1. Координатор запускает нового worker-субагента на ровно один issue.
2. Задание субагенту формулируется в стиле `/goal`:
- реализовать конкретный файл задачи;
- работать по методике `/tdd`;
- не реализовывать следующие задачи;
- не коммитить;
- не откатывать чужие изменения;
- в конце отчитаться по изменённым файлам, тестам, проверкам и рискам.
3. После реализации тот же субагент делает саморевью в отдельной роли:
- остановиться;
- проверить результат против acceptance criteria, спеки и тестов;
- искать ошибки, лишний объём, хрупкость и нарушение учебной читаемости;
- сначала выдать находки с важностью, не исправляя их сразу.
4. Координатор решает, какие замечания достаточно важные.
5. Тот же субагент исправляет только одобренные важные замечания.
6. Координатор проверяет верхнеуровневые вещи:
- границы задачи;
- список изменённых файлов;
- честность отметок acceptance criteria;
- тесты;
- `git status`;
- отсутствие смешивания нескольких задач.
7. Коммит делает координатор, не субагент.
8. Координатор закрывает агента и переходит к следующему issue новым
worker-субагентом.
Для сложных переходов, особенно после задачи 05 перед задачей 06, можно добавить
отдельного reviewer-субагента. Это не обязательный шаг на каждую задачу, а
контрольная мера, если есть риск, что саморевью исполнителя недостаточно.
## Роль координатора
Координатор не должен пытаться заново глубоко реализовывать или полностью
повторять работу субагента. Его польза — в управлении процессом:
- держать порядок задач;
- ограничивать область изменений;
- читать отчёты и принимать решения;
- запускать исправления только по существенным замечаниям;
- делать финальный обзор diff на уровне границ и рисков;
- выполнять проверки и коммитить.
Главный риск верхнеуровневого `/goal`: он может заставить координатора
оптимизировать работу под закрытие большой цели, а не под аккуратное управление
этапами. Поэтому для координатора `/goal` не использовать.
## Почему саморевью тем же субагентом допустимо
Пользователь отмечает, что на практике субагенты хорошо делают “ревью свежим
взглядом” внутри уже накопленного подробного контекста задачи. Это может быть
полезнее, чем полностью внешний поверхностный обзор координатора, которому не
хватит деталей реализации.
Ожидаемая модель:
- субагент глубоко понимает сделанную задачу и проверяет её на несостыковки;
- координатор не доверяет этому слепо, но оценивает адекватность выводов и
границы изменений;
- для особо рискованных мест можно подключить отдельного проверяющего агента.
## Suggested skills
- `tdd` — должен использовать worker-субагент при реализации каждой задачи.
- `conventional-commits` — использовать координатору перед созданием каждого
коммита.
- `claude-team-review` — опционально после задачи 05 или перед задачей 06, если
нужен дополнительный независимый взгляд на модель.
- `handoff` — после эксперимента зафиксировать рефлексию: что получилось, что
не получилось, какие правила стоит оставить.
## Возможная первая команда субагенту
```text
/goal Реализовать .scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md с использованием /tdd.
Работай только в границах этой задачи. Не реализовывай задачи 06-07.
Не коммить. Не откатывай чужие изменения.
Сначала прочитай сам issue и источники решений, указанные в нём. Работай
вертикальными TDD-срезами: один тест на наблюдаемое поведение -> минимальная
реализация -> зелёная проверка -> следующий тест.
В конце остановись и отчитайся:
- какие файлы изменены;
- какие тесты добавлены или изменены;
- какие проверки запускались и с каким результатом;
- какие acceptance criteria закрыты;
- какие риски или спорные места остались.
```
После этого координатор должен попросить того же субагента выполнить саморевью
результата, не исправляя замечания до отдельного решения координатора.
## Наблюдения во время эксперимента
### Цикл 05: интенсивность и калибровка потока
Стартовая задача оказалась дорогой по обратной связи: это не локальный инвариант,
а отладка статистической модели на длинных симуляциях. Первый worker долго
работал без промежуточного отчёта; координатору пришлось сначала поставить
мягкий статус-чек в очередь, затем прервать агента ради статуса. Это не сломало
работу, но показало: для статистических задач лучше заранее задавать контрольные
точки или ожидать длинный первый цикл.
Саморевью того же worker оказалось полезным. Оно нашло несколько реальных
несостыковок:
- `docker-compose.yml` оставлял старую интенсивность `GEN_LAMBDA_BASE_PER_MIN=200`;
- тест межсессионной паузы был слишком широким;
- в тестовой конфигурации оставались старые числа;
- README мог быть двусмысленным про `GEN_MIN/MAX_EVENTS_PER_TICK`.
Координаторская проверка тоже добавила ценность: после саморевью был найден
конфликт между новым `GEN_LAMBDA_BASE_PER_MIN=30` и старым нижним пределом
`GEN_MIN_EVENTS_PER_TICK=5`. При тике 5 секунд это давало минимум 60 событий в
минуту и ломало критерий интенсивности. Worker исправил это отдельной точечной
итерацией.
Полезная схема цикла:
1. worker реализует задачу;
2. координатор прерывает только если агент слишком долго молчит;
3. worker делает саморевью без правок;
4. координатор выбирает, какие замечания чинить;
5. worker чинит только выбранные пункты;
6. координатор добавляет свой узкий sanity-check по связям между дефолтами,
документацией и критерием задачи;
7. координатор коммитит.
Результат цикла 05:
- коммит `8f1e997 feat(generator): откалиброван поток steady-stream генератора`;
- полный прогон: `uv run --with-requirements generator/requirements.txt pytest generator/tests -q`
— 96 passed;
- `git diff --check` — без замечаний.
Промежуточный вывод: тот же субагент действительно хорошо использует подробный
контекст задачи для саморевью, но координатор всё равно нужен как внешний
проверяющий связей между настройками, обычным запуском и acceptance criteria.
### Reviewer-субагент между задачами
После задачи 06 пользователь предложил добавить отдельного reviewer-субагента
между задачами. Это выглядит особенно полезно на границах вроде 06 -> 07, где
следующая задача будет опираться на уже изменённые состояние, сервисный цикл и
документацию.
Важное ограничение: reviewer не является источником истины. Его находки нужно
рассматривать как гипотезы и классифицировать координатором:
- `чинить до коммита` — реальный дефект или риск закрытия acceptance criteria;
- `записать как риск` — важно знать, но не блокирует текущую задачу;
- `ложная тревога` — reviewer неверно понял код, тест или границы задачи;
- `вне скоупа` — может быть полезно позже, но не относится к текущему issue.
Только находки из первой группы возвращаются worker-агенту на исправление.
Иначе есть риск превратить reviewer-а в источник лишнего объёма и расползания
задачи.
В цикле 06 это правило сразу пригодилось. Саморевью исполнителя и отдельный
reviewer независимо нашли два существенных риска:
- битое state v2 с валидным ГПСЧ могло пройти `from_dict_safe`, а затем уронить
сервис уже в `restore_state`;
- снимок активных визитов сохранялся полными batch-словарями и на верхних
лимитах получался порядка мегабайт, хотя спека говорит про компактное
состояние.
Координатор проверил обе гипотезы локально: `restore_state` действительно падал
на битой вложенной структуре, а оценка JSON-снимка при 200 активных визитах и
популяции 300 дала около 4.7 MB. Эти находки классифицированы как `чинить до
коммита` и возвращены worker-агенту. Низкие замечания про совместимость
`generate_tick_batch` и пересечение сервиса с задачей 07 не стали правками:
первое оказалось ложной тревогой, второе — допустимым пересечением для
восстановления state v2.
После крупной переделки по замечаниям reviewer-а нужен второй круг ревью. В
цикле 06 это подтвердилось: исправление компактного состояния само изменило
дизайн снимка и восстановление активных визитов. Второй reviewer нашёл новый
дефект уже в исправленной версии: формально похожий v2-state с `population=[]`
или строковым `pending_visit_births` проходил первичную загрузку, но затем
оставлял поток без пользователей или ронял следующий тик. Координатор
подтвердил это локальной проверкой и вернул worker-агенту как единственный
обязательный пункт второго круга.
В цикле 07 reviewer оказался полезен уже не для поиска падений, а для силы
доказательства. Worker добавил сервисный тест с мок-публикацией, но внешний
reviewer заметил, что один опубликованный event доказывает четыре топика и
связи, но слабее доказывает невырожденную модель «один визит -> несколько
событий с одним `click_id`». Координатор классифицировал это как `чинить до
коммита`: финальный интеграционный тест должен доказывать именно уход от старой
плоской модели, а не только факт публикации.
@@ -0,0 +1,76 @@
# Handoff: ADR-0006 (источник аналитики) + спека модельного времени
Дата: 2026-06-14
Ветка: `feature/data-generator`
Режим работы: дизайн (brainstorm-with-docs), не реализация кода.
## Что сделано в этой сессии (закоммичено)
Содержание не дублирую — смотри сами файлы:
- **ADR-0006** `docs/adr/0006-generation-as-sole-analytics-source.md` — генерация
становится единственным источником аналитики; статический сид → архивный;
перевод процесса на новый (сгенерированный) сид — цель работы. Коммит `49512b1`.
- **Спека** `docs/specs/2026-06-14-generator-model-time-and-startup-history.md` —
модельное время на практике: точка отсчёта `T0`, повторяемость, заливка
прошлого, стартовая история, сохранение состояния, скорость ×K, проверка в два
шага. Коммит `49512b1`.
- **Указатели вперёд** в ADR-0004 и ADR-0005 на ADR-0006 и спеку. Коммит `49512b1`.
- **AGENTS.md** — ужесточено правило про русский язык + добавлено правило про
понятность и краткость документов. Коммит `c4cc01e`.
## Контекст и решения, которых нет в артефактах (важны для продолжения)
- **Роль сида = «кладовка значений» (палитра атрибутов).** Генератор сам придумывает
структуру (иерархию, время, воронку), но фактуру (браузер/гео/устройство/utm)
копирует из строк сида: `generator/src/clickstream_generator/generation.py`
(строки ~202–228, `{**base_browser, …}`), индексы в `dictionary.py`. Профиль
популяции хранится ссылкой на сид-сессию (мат-спека, §«Профиль пользователя»).
Поэтому сид нельзя удалить, пока генератор не научится фактуре сам.
- **Сид полностью чист** — проверено: 1000 записей × 4 файла, 0 падений в DQ
(`ods.*_errors`). Значит вывод сида не ломает учебный путь про грязные данные.
- **Двухшаговая проверка** (раздел «Проверка» спеки): шаг 1 — агент на чистом
стенде сверяет числа в ClickHouse (пирамида/воронка/возвраты), детерминированно;
шаг 2 — человек смотрит дашборды (распределение похоже на задуманное + стенд
«дышит» на ×K). Вскрытый пробел: дашборда с распределением сгенерированных
данных нет (Superset на сиде, Grafana показывает пропускную способность).
- **Известные расхождения калибровки** (из прошлого ревью, не в этих артефактах):
медиана длины визита ~8 против сидовых 10, всплеск на длине 2, нет
bounce-визитов длины 1, доля `/confirmation` ~27%. Когда генерация станет
источником, это станет «правдой» стенда — учесть при заполнении коридоров шага 1
и в будущей задаче калибровки/фактуры.
## Очередь (следующие шаги)
1. Выровнять `CONTEXT.md` (раздел «три значения слова сид») под ADR-0006:
статический сид → архивная кладовка значений, цель — полный вывод.
2. Пометить в мат-спеке `docs/specs/2026-06-10-generator-math-model.md` разделы
«Персистентность через рестарты» и «Воспроизводимость» как переописанные в
модельном времени новой спекой (ссылкой, без дублирования).
3. (Опционально) Этап 1 вживую: поднять стенд на текущем генераторе, снять
реальные числа в ClickHouse, заполнить коридоры в разделе «Проверка». Поднимает
docker-стек (меняет состояние) — только с согласия владельца.
4. Черновик спеки **синтеза фактуры** — то, что позволит удалить `data/*.jsonl`.
5. Черновик спеки **перестройки процесса** (загрузка `kafka_load_dag` → новый сид,
Superset/витрины, уроки) — на этапе реализации, по ADR-0006.
6. Реализация механизма (модельные часы/×K/стартовая история) — это код, зона Codex.
## Ограничения (продолжать соблюдать)
- Роль: Claude — ADR/спеки/ревью; Codex — код. Намерение, против которого
сверяем, живёт в ADR/спеках Claude.
- Язык: ясный русский без транслитераций (теперь зафиксировано в `AGENTS.md`).
- Коммиты: только по просьбе; формат conventional-commits; трейлер
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Git строго аддитивно: на ветке может параллельно писать Codex — не делать
amend/rebase/reset чужих коммитов. В `main` — merge `--no-ff`, не тащить
`.scratch/`.
- Context7 для спорных/меняющихся API (особенно Airflow).
- Публичный репозиторий — без персональных данных менти.
## Suggested skills
- **brainstorm-with-docs** — для продолжения дизайна (пункты 1, 4, 5 очереди:
`CONTEXT.md`, спека фактуры, спека перестройки процесса).
- **conventional-commits** — для коммитов.
- **Context7 (MCP)** — при касании API.
@@ -0,0 +1,16 @@
# Handoff: экономия контекста в coordinator-loop (указатель)
Дата: 2026-06-14 MSK
Полная, авторитетная версия этого handoff перенесена **внутрь навыка**, рядом с
handoff про наблюдаемость:
`~/dotfiles/agents/.agents/skills/coordinator-loop/handoffs/2026-06-14-coordinator-loop-context-economy.md`
Почему там: handoff про доработку навыка `coordinator-loop`, и сам навык, и его
handoff-побратим (`2026-06-14-observability-design.md`) живут в dotfiles. Чтобы будущая
сессия по доработке навыка нашла всё в одном месте, авторитетная версия лежит у навыка.
Здесь оставлен только указатель: диагностика, на которой стоит handoff, снята с прогона
именно этого репозитория (фичи `feature-data-generator` и
`generator-model-time-startup-history` под `/coordinator-loop`).
@@ -0,0 +1,68 @@
# Handoff: задачи по модельному времени и стартовой истории
Дата: 2026-06-14
Жанр: одноразовый handoff по ADR-0003.
## Что сделано
Создан рабочий набор артефактов для следующей фазы работ:
- `.scratch/generator-model-time-startup-history/PRD.md`
- `.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md`
- `.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md`
- `.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md`
- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md`
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
- `.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md`
Артефакты режут работу на 6 последовательных issue и 2 review gate. Главная
идея: каждый кодовый срез должен подтверждаться через ClickHouse, а не только
локальными тестами генератора. Финальная проверка дашбордов глазами остаётся в
конце.
## Важные решения
- Координатор не берёт `/goal` на всю цепочку: один worker получает один issue.
- Worker работает через `/tdd`, не коммитит и не реализует следующие задачи.
- После реализации worker делает саморевью без правок.
- Координатор классифицирует находки и коммитит сам.
- После задачи 3 нужен review gate по сквозному инварианту времени.
- После задачи 5 нужен review gate по распределениям и двум путям генерации.
- Для review gate после задачи 5 нужен reviewer другой родословной; без него
координатор останавливает цепочку и отдаёт риск человеку. Это следует из
`docs/research/2026-06-11-subagent-coordinator-experiment.md`.
## Что важно не потерять
- В задаче 1 нужно зафиксировать durable-контракт, а не оставить решение только
в комментарии к issue.
- При ×K восстановление после сбоя нельзя считать простым `datetime.now()`: нужна
сохранённая связка модельного и настенного времени.
- Короткий или долгий простой считается по модельному времени, а не по настенным
минутам.
- Чистый прогон должен сбрасывать не только ClickHouse, но и Kafka-топики данных
и состояние генератора.
- Стартовая история должна быть парным артефактом: события, слепок состояния и
манифест.
- Живой и восстановленный пути не должны менять контекст внутри одного визита;
это известная слепая зона из research.
- Задача 6 специально сужена до штатного пути стенда и документов запуска.
Уроки, новые панели и улучшения дашбордов уходят в follow-up, если окажутся
нетривиальными.
## Suggested skills
- `to-issues` — если понадобится переразбить или опубликовать дополнительные
issue.
- `tdd` — основной режим работы worker-а над каждым кодовым issue.
- `conventional-commits` — перед каждым коммитом координатора.
- `claude-team-review` или другой внешний reviewer — на двух review gate.
- `handoff` — если работа прерывается между issue или после review gate.
## Следующий шаг
Начать с
`.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md`.
Это HITL-задача: нужно закрепить интерфейс модельного времени, манифест
стартовой истории, правило границы `T_end` и способ повторяемой проверки в
ClickHouse.
@@ -0,0 +1,96 @@
# Handoff: независимое ревью модельного времени и стартовой истории
Дата: 2026-06-14
Жанр: одноразовый handoff по ADR-0003.
## Контекст
После прогона coordinator-loop по фичам `feature-data-generator` и
`generator-model-time-startup-history` (issues 01–06, реализация Codex) проведено
независимое ревью результатов — другой родословной (Claude). Это в том числе
закрыло пропущенный внешний review gate после задачи 5 из PRD: он был выполнен той
же родословной, что и worker, а PRD требовал другую.
## Что проверили независимо (не по отчётам)
- `make generator-test` — `134 passed` (своим прогоном).
- Код = «Рабочий контракт реализации» спеки буква в букву: настройки
`GEN_MODEL_*`, state v2 (`model_timestamp/wall_timestamp/model_time_speed/…`),
формула live-возобновления, полуоткрытая граница `[T0, T_end)` с
`drain_until(include_boundary=False)`, манифест и антисмешивание.
- Данные в ClickHouse реальные и сходятся с отчётами до цифры: 6 ч → 16 054;
перепрогнали 1 сутки → 93 952; 2 суток → 187 087 событий / 17 397 визитов /
2 765 пользователей.
- Пирамида (users < visits < events), возвраты, монотонность обеих воронок,
отсутствие архивного сида (все строки 2026 года).
- Повторяемость суточной волны на 2 сутках: часовые числа day1≈day2
(отношение в основном 0.9–1.1), форма «ночь–день» повторяется.
- Стык backfill→live (live-доливка от `T_end=2026-01-03`): дублей 0, граница
держится, device/os/гео на стыке однородны.
- Дашборд: визуальная приёмка (шаг 2 спеки) — человеком и через Playwright.
«Rows by Layer» (4 равных столбца = 187 312) корректен: пайплайн проносит
событие 1:1, потерь нет, дедуп at-least-once на чистом backfill не возникает.
## Что нашли
- **issue 09 — реальный дефект стыка.** Браузерная фактура не переживает
восстановление визита: у визитов, активных ровно на `T_end`, в live-продолжении
меняется `browser_name`/`browser_language` (17/22 и 22/22), хотя внутри чистого
backfill браузер постоянен (0/14556). device/гео не задеты. Нарушает заявленный
критерий однородности задач 04/05; проверялось неполно (смотрели только поля
`dds.click`, а не per-event браузер в `dds.event`). Масштаб ~0.13% визитов.
- Гео-карта на дашборде нечитаема (нет легенды/подсказок/понятной шкалы) — issue 10.
- Стартовый сид по умолчанию — 6 часов (быстрый профиль для CI); суточная волна на
нём не видна, для просмотра нужен ≥2-суточный профиль через env.
## Открытые пробелы проверки (НЕ закрыты)
Ревью прошло по корректности кода, форме данных, стыку и дашборду, но НЕ трогало:
1. **×K не гоняли.** Все прогоны на `speed=1`. Ускорение модельного времени,
событийный бюджет по модельной длительности и смена дневного коэффициента при
×K проверены только в коде/по отчёту issue 03.
2. **Live-crash recovery не воспроизводили.** Формула возобновления с настенной
дельтой и закрытие просроченных визитов — только в коде. Гипотеза: дефект
issue 09 бьёт и сюда (тот же restore-механизм), эмпирически не подтверждено.
3. **Распределения не сверяли с коридорами мат-спеки** (`2026-06-10-generator-math-model.md`).
Подтвердили форму (монотонность, возвраты, длина), но не попадание
`confirmation_share` и доли коротких визитов в спроектированные коридоры.
4. **Воспроизводимость сами не перепрогоняли** — идентичность checksum при
повторном чистом прогоне взята из отчётов issue 03/05.
5. **Второй review gate (после задачи 3) не делали** — систематический sweep на
утечку настенных часов в расчёт интенсивности и сохранение состояния.
Помельче: не проверяли обработку повреждённого/старого state (fresh-start с
предупреждением), `browser_user_agent` (тот же механизм, что issue 09), сам процесс
coordinator-loop по правилам PRD.
## Зафиксированные follow-up (все needs-triage, на потом)
- `issues/07-migrate-course-from-archive-seed.md` — миграция уроков.
- `issues/08-startup-history-portable-artifact-and-usage-docs.md` — портативный
артефакт стартовой истории + runbook + идеи интерфейса (глаголы вместо флагов,
длительность/профили, громкий отказ при несовпадении, доливка кусочком,
Airflow-DAG как пульт).
- `issues/09-seam-browser-fixture-not-preserved.md` — дефект браузерной фактуры.
- `issues/10-dashboard-geo-map-readability.md` — читаемость гео-карты.
- `docs/course/PRD.md` §7 — развилка «генератор как скрытая инфраструктура vs
отдельный урок про генератор» (не грузить менти марковскими цепями).
## Состояние стенда
Поднят на 2 сутках генерации (+ несколько live-тиков от seam-проверки): ClickHouse,
Kafka, Superset, postgres-metadata. Дашборд:
`http://localhost:8088/superset/dashboard/ecommerce-analytics/`.
## Suggested skills
- `tdd` — для фикса issue 09 (один поведенческий тест на однородность браузера через
стык, затем минимальная правка).
- `conventional-commits` — перед коммитами.
- `claude-team-review` / внешний reviewer другой родословной — на пробелы 1–5.
## Следующий шаг
Сегодня только фиксация, без правок. Дальше — приоритизировать issue 09 (баг) против
08/10 (удобство/виз) и закрыть пробелы проверки 1–5 (особенно ×K и crash recovery).
@@ -0,0 +1,91 @@
# Handoff: триаж бэклога генератора и целеполагание стенда
Дата: 2026-07-04
Жанр: одноразовый handoff по ADR-0003.
## Рабочая форма (зафиксирована сегодня)
С Claude — обсуждение проекта, планы, проектные документы и независимые ревью.
Исполнение кода — Codex, предпочтительно через `/goal` или `/coordinator-loop`.
Сохранено в памяти Claude (memory), новая сессия подхватит автоматически.
## Что решено и зафиксировано
- **Целеполагание сверено** и записано в `docs/course/PRD.md` §1 (поправка
2026-07-04): три части трека ClickHouse (теория — внешние курсы, лабы —
clickhouse-learning-cluster, этот стенд — интеграции «как в жизни»); иерархия
целей: курс -> стенд-носитель -> генератор как скрытая инфраструктура.
- **Развилка «урок про генератор» закрыта**: урока не будет, генератор — скрытая
инфраструктура (PRD §7).
- **Демо-употребление устарело**: `docs/DEMO_CHEATSHEET_5MIN.md` и
`docs/DEMO_SCRIPT_10_15MIN.md` удалены, демо выросло в отдельный проект.
- **Новая открытая развилка** в PRD §7: кластерная конфигурация ClickHouse
(пользователь склоняется к «всё в одном», решение не принято).
- **Конвенция трекера**: завершённая задача — `Status: done`
(`docs/agents/issue-tracker.md`); задачи 01–06 фичи переведены в `done`
(стояли ошибочные `ready-for-human` от глючившего координатора).
## Бэклог фичи `generator-model-time-startup-history`
Список задач и порядок — в PRD фичи (раздел «Задачи», дополнен сегодня):
- **07** (артефакт + runbook, `ready-for-agent`) — импорт строго через Kafka
(напрямую в ClickHouse не пишет — решение пользователя), громкий отказ при
несовместимом state (пересмотр правила спеки), граница runbook «использование,
не устройство». Режим ревью — гейт.
- **08** (миграция курса, `ready-for-agent`) — после 07; `make data` вплетён во
все уроки 00–05, объём больше, чем кажется; тонкое место — урок 00.
- **11** (глаголы/длительность/профили, `ready-for-agent`) — после 07, до 12.
- **12** (DAG-пульт, `needs-triage`) — дооформить после 07 и 11; приоритет
поднят пользователем: будущий основной человеческий интерфейс стенда.
- **13** (доливка, `needs-triage`) — без приоритета, после фикса 09.
- Перестановка: бывший 07 (миграция) и 08 (артефакт) поменяны местами.
## Следующий шаг
Весь бэклог готов к передаче Codex. Порядок пересмотрен (решение 2026-07-04,
«пульт вперёд»): узкое место — время человека на ручную приёмку, поэтому
основная цепочка теперь 07 -> 11 -> 12 (пульт появляется раньше), а миграция
курса 08 идёт последней — её приёмку (проход по урокам) человек ведёт уже через
пульт. 09 и 10 независимы, можно параллельно; 13 — после фикса 09. Граф и
подробности — в PRD фичи, раздел «Задачи»; там же зафиксировано, что номер
файла — идентификатор, а не порядок.
## Дооформление 09 и 10 — что изменилось против прежнего понимания
Сделано в этот же день, вторым заходом. 09 и 10 переведены в `ready-for-agent`;
детали — в самих issue, здесь только сдвиги в понимании:
- **Гипотеза по 09 не подтвердилась.** Дело не в рассинхроне `event_index`, а в
источнике фактуры: при рождении визита браузерные строки берутся у случайного
«донора» из словаря, при восстановлении — у `seed_click_id` пользователя.
Прежняя развилка «выбор по абсолютному индексу, не трогая схему state»
оказалась нерабочей — restore не знает донора. Решение пользователя: хранить
`base_click_id` донора в state (альтернативы и причины отказа — в issue).
- **Дефект 09 шире браузера** (нашло ревью): per-event поля location (referer,
utm) расходятся на стыке так же — критерии приёмки расширены, иначе частичный
фикс «только браузер» прошёл бы приёмку. Расхождение event_id (uuid4 при
рождении, uuid5 при restore) зафиксировано как известное и вне скоупа.
- **По 10 сняты две мины:** пересборка по умолчанию даёт 6 часов истории, а не
2 суток (профиль «2 суток одной командой» — это ещё не сделанная задача 11);
legacy-виз `world_map`, похоже, вообще не умеет легенду и tooltip — основной
путь, вероятно, смена типа визуализации, а не настройка (проверить через
Context7 при реализации).
- **Процессная заметка:** дооформленные issue прогнаны через тройное ревью
(самопроверка + два свежих агента, адверсарно, с проверкой каждого утверждения
по коду). Улов оправдал затраты — см. пункты выше; для issue с режимом «гейт»
так стоит делать и дальше.
## Не забыть (вне бэклога)
Открытые пробелы независимого ревью от 2026-06-14 (см. handoff
`2026-06-14-generator-model-time-verification-review.md`): ×K не гоняли,
live-crash recovery не воспроизводили, распределения не сверяли с коридорами
мат-спеки, воспроизводимость checksum не перепроверяли.
## Suggested skills
- `conventional-commits` — перед коммитами.
- `tdd` — для фикса issue 09 (тест на однородность браузера через стык, затем
минимальная правка).
- `coordinator-loop` — на цепочки 07 -> 08 и 07 -> 11 -> 12.
@@ -0,0 +1,185 @@
# Handoff: цепочка генератора 12 -> 14 -> 09 -> 10 -> 08
Дата: 2026-07-04.
Жанр: одноразовый handoff по ADR-0003.
Источник: `.scratch/generator-model-time-startup-history/coordinator-journal.md`.
## Статус
Цепочка `12 -> 14 -> 09 -> 10 -> 08` завершена через `coordinator-loop`.
Финальный chain review закрыт `APPROVED`; правки финального review
закоммичены в `f8b419d`.
Все issue текущей очереди имеют `Status: done`; в PRD фичи осталась только
дальнейшая задача 13 (`needs-triage`).
## Коммиты
- `dd4af82` — `feat(airflow): добавлен пульт управления генератором`.
- `200bb82` — `docs(issues): закрыта задача 12 по пульту генератора`.
- `9d1bcc4` — `feat(generator): ускорен учебный профиль daily-wave`.
- `0cbfe9b` — `docs(issues): закрыта задача 14 по быстрому профилю`.
- `e2d0684` — `fix(generator): сохранена фактура визита при восстановлении`.
- `0f435d7` — `docs(issues): закрыта задача 09 по фактуре визита`.
- `0c80e24` — `fix(superset): заменена нечитаемая гео-карта`.
- `de938ed` — `docs(issues): закрыта задача 10 по гео-графику`.
- `d76c036` — `docs(course): переведены уроки на стартовую историю`.
- `3d4e13c` — `docs(issues): закрыта задача 08 по миграции курса`.
- `f8b419d` — `docs(generator): закрыты находки финального ревью цепочки`.
## Что построено
- `airflow/dags/generator_control_dag.py` — ручной Airflow-пульт
`backfill/import/check`, без Docker socket, с ожиданием `etl_pipeline`.
- `generator/src/clickstream_generator/airflow_control.py` — общая логика
env, предпроверок чистого стенда и сверки manifest для DAG-пульта.
- `docker-compose.yml` / `Makefile` — `make up` не автостартует live-генератор;
live запускается явно через `make generator-continue`.
- `generator/src/clickstream_generator/launch.py` — `daily-wave` переведён на
`speed=60`, `tick=1`; `ci` сохранён.
- `generator/src/clickstream_generator/runtime.py` и `state.py` — state v3
хранит `base_click_id`, восстановление визита берёт ту же per-event фактуру.
- `scripts/check_generated_analytics.sh` — стыковочная проверка browser/source и ODS
device/os/geo включается в режиме `auto` после live-строк.
- `superset/create_dashboard.py` — гео-блок заменён на `Top Countries by Events`
(`echarts_timeseries_bar`) с tooltip, легендой и единицами.
- `docs/course/` и `docs/TEST_PLAN.md` — учебный путь переведён с архивного
сида на `startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset`.
## Ревью по issue
- Issue 12: обычный режим. Worker `medium` был запущен до уточнения уровней,
проверка кода сначала `medium`, повторный прогон `high`. Первая проверка нашла
отсутствие подсказки `make clean` на грязных Kafka-топиках; повторный прогон
`APPROVED`.
- Issue 14: обычный режим. Worker `medium`, проверка кода `high`; `APPROVED`.
- Issue 09: гейт. Worker `high`; проверка задачи `high` и проверка кода `high`.
Первый круг нашёл ODS-проверку device/os/geo, потерю 1 мкс в timestamp offset
и ложнозелёный `generated-history-check`; второй круг `APPROVED`.
- Issue 10: обычный режим. Worker `medium`, проверка кода `high`.
Проверка нашла рассинхрон Superset export/layout, ложнозелёный тест и риск дубля
при rename; узкие повторные прогоны закрыты, итог `APPROVED`.
- Issue 08: обычный режим. Worker `medium`, проверка кода `high`.
Проверка нашла старый путь отката в стандарте уроков и несогласованность урока 06
вокруг `superset-dashboard`; повторный прогон `APPROVED`.
## Финальное ревью
Финальное ревью цепочки шло на усиленном уровне `xhigh`.
До обновления скилла повторные прогоны запускались свежими сессиями ревьюеров:
`Bernoulli`, `Noether`, `Banach`, `Curie`, `Sartre`, `Bacon`.
Это отклонение от обновлённого процесса зафиксировано как дорогой вариант.
После обновления скилла проектные doc-находки чинил отдельный worker для правок
`Raman` (`medium`), а повторный прогон выполнял тот же ревьюер `James` через
`send_input`.
Закрытые классы находок:
- PRD фичи больше не `Draft` и не держит закрытые 12/09/10/14/08 в остатке.
- Все `Status: done` issue фичи без пустых `[ ]`.
- Superset docs/course/artifacts/code не содержат старые `World Map/world_map`
маркеры рядом с новым `Top Countries`.
- `docs/OPERATIONS.md`, `docs/course/PRD.md`, `docs/ARCHITECTURE.md`,
`docs/REPO_MAP.md` и `CONTEXT.md` согласованы со startup-history-путём и
`generator_control`.
## Отклонения процесса
- Ранние повторные прогоны ревью запускались свежими субагентами, а не продолжением
того же ревьюера. После обновления `coordinator-loop` точечные повторные прогоны
нужно делать через `send_input` тому же ревьюеру.
- До обновления скилла координатор сам правил часть project docs по финальному
ревью. После обновления project docs правил worker для правок; процессные файлы
`.scratch/...` остались зоной координатора.
- Визуальная приёмка Superset и живой проход Airflow UI не выполнялись в этой
цепочке. Это не скрыто: все такие пункты вынесены в HITL/риски.
## Открытые риски
- Нужен кросс-линейный ревью-проход другой родословной по фактическим изменениям
`c672ed0..HEAD`. Особый фокус: issue 12 Airflow-пульт, issue 09 state v3 и
стыковочная проверка, issue 10 Superset export/layout, issue 08 учебный путь.
- Нужна ручная HITL-приёмка:
- Airflow UI: `generator_control` backfill/import/check на чистом стенде.
- Superset: кадр `Top Countries by Events` целиком и кадр с tooltip.
- Курс 00-06: проход через новый startup-history-путь.
- Задача 13 (`backfill-top-up-from-snapshot`) остаётся `needs-triage`; после
фикса issue 09 её можно дооформлять отдельно.
## Быстрые проверки, которые уже проходили
- `uv run --with-requirements generator/requirements.txt pytest generator/tests -q`
— PASS на issue 12 и 09.
- `make generator-test` — PASS на issue 14.
- `uv run --with pytest pytest tests/test_superset_dashboard_config.py` — PASS
на issue 10.
- Поисковые проверки курса на старые `make data`/`kafka_load`/фиксированные
сид-ожидания — PASS на issue 08.
- Финальные `rg`-проверки по PRD, чекбоксам, Superset-маркерам и
startup-history-дрифту — PASS перед `f8b419d`.
## Независимый ревью-проход (2026-07-04, свежий взгляд)
Кто и как: Claude, свежим взглядом по диапазону `c672ed0..HEAD`, без субагентов
(правило проекта: «свежим взглядом» = без субагентов). Читались реальные диффы
всех пяти задач и сверялись с текстами issue; тесты прогонялись заново, а не со
слов handoff.
Вердикт: цепочка выполнена на уровне кода и тестов; правок для приёмки не нужно.
Доказательства (прогонял сам):
- `generator/tests` — 172 passed (в том числе контрактный тест DAG и тесты пульта).
- `tests/test_superset_dashboard_config.py` — 5 passed.
- Грепы: старых маркеров `world_map`/`Geography Map` в коде и доках нет (остались
только законный историч. след в спеке, `previous_slice_names` и тест
переименования); во всех done-issue 0 пустых чекбоксов; `state v2` в отгруженном
коде не осталось.
- `etl_pipeline` действительно принимает `conf` `full_refresh` (`Param(True,
type="boolean")`) — имя параметра из `TriggerDagRunOperator` пульта совпадает.
По задачам:
- Issue 09 (гейт): донор `base_click_id` хранится в state; восстановление громко
падает на неизвестном доноре (тихие `.get`-fallback'и убраны); запасная ветка
рождения сделана восстановимой и провалидирована. Отдельная реальная находка —
µs-фикс `_timestamp_to_state_offset` (целочисленная арифметика вместо
`total_seconds()*1e6`). Тесты бьют прямо в критерии приёмки.
- Issue 12 (пульт): `retries=0`, `is_paused_upon_creation`, join после ветвления
через `NONE_FAILED_MIN_ONE_SUCCESS`, `wait_for_completion=True`, env до
`Config()`, предпроверка пустоты Kafka и STG. Compose-профиль `live-generator`
оставляет `make up` чистым; явные пути включают профиль по имени сервиса.
- Issue 14: `daily-wave` speed 1→60, tick 60→1; `ci` не тронут; пер-тиковые INFO
приглушены до DEBUG.
- Issue 10: `echarts_timeseries_bar` с tooltip, легендой и единицами; дедуп дублей
при переименовании; экспорт-JSON защищён тестами и от рассинхрона с конфигом, и
от дрейфа раскладки.
- Issue 08: доки, старый путь `make data`/`kafka_load` убран как основной, грепы
чисты.
Мелочи (косметика, не блокеры):
- `test_default_version` (`test_state.py`): docstring говорит «версии 2», а ассерт
уже `"3.0"`.
- `build_control_env` для `import` тоже зовёт `build_launch_env("backfill", …)` —
безвредно (важен только «мир»-env), но может смутить читателя.
- `tooltipTimeFormat: "smart_date"` на категориальной оси — рудиментарный
параметр, безвреден.
## Открытые риски (обновление 2026-07-04)
- Кросс-линейный проход *другой родословной* (иная модель/агент) не выполнялся:
этот ревью сделан Claude свежим взглядом без субагентов. Для кода и тестов
этого достаточно; если нужна ещё одна родословная — отдельный шаг.
- Ручная приёмка (HITL) остаётся, код к ней честен (пропуски печатаются):
- Airflow UI: `generator_control` backfill/import/check на чистом стенде.
- Superset: кадр `Top Countries by Events` целиком и кадр с tooltip.
- Курс 00-06: проход по новому startup-history-пути.
- Задача 13 (`backfill-top-up-from-snapshot`) — `needs-triage`; предпосылка
(исправный restore из issue 09) выполнена, можно дооформлять.
## Следующий шаг
Независимый ревью кода и тестов пройден. Осталось: ручная HITL-приёмка (Airflow
UI, Superset-кадры, проход курса) и отдельное решение по задаче 13.
@@ -0,0 +1,160 @@
# Кросс-линейное ревью цепочки генератора 12 -> 14 -> 09 -> 10 -> 08
Дата: 2026-07-05 00:02.
Жанр: одноразовый handoff по ADR-0003; отчёт кросс-прохода другой родословной
(Claude) по диапазону `c672ed0..ec815ce`. Бриф — из
`.scratch/handoffs/2026-07-04-generator-chain-review.md`.
Метод: четыре параллельных адверсарных ревьюера с исполняемыми доказательствами
(мутационные тесты на копии, эксперимент с compose-профилем на реальной версии
Compose, зонды на реальном сиде, сверка Airflow API через Context7). Репозиторий
не менялся.
## Вердикт
**CHANGES_REQUESTED.** Счёт: 1 critical, 5 high, 10 medium, 4 low — после
`APPROVED` финального ревью той же линии. Третье подтверждение находки о
родословной: внутренние ревью закрыли локальные дефекты честно, кросс-линия
нашла сквозные сценарии.
## Сквозные семейства (главная ценность прохода)
1. **«Смешение миров» возвращается тремя дорогами.** Инвариант задачи 07
(«чистый стенд, громкий отказ») пробит: (а) state v2 после обновления кода
классифицируется как «повреждённый» → тихий чистый старт нового мира поверх
истории; (б) live-генератор ушёл в compose-профиль, и `make clean`/`down`
его больше **не убивают** — осиротевший генератор старого мира пишет в чистые
топики и переживает перезагрузку (`restart: unless-stopped`); (в) консольные
пути (`make generator-backfill`, `startup-history-import`) остались без
предпроверок, которые пульту сочли обязательными.
2. **Последствия «make up больше не стартует live» не выметены.** Алерт
`Kafka No Messages Produced` горит весь курс на здоровом стенде; уроки 0/5
ожидают поток; `sleep 5` в документированной проверке шва меньше времени
старта генератора — проверка стала пустышкой.
3. **Ложнозелёные проверки.** Superset-тесты пропускают дрейф параметров
(top-15 → 500 строк — 5 тестов зелёные); стыковая проверка сверяет шов там,
куда указал env, а не где он по манифесту; ODS-блок проходит на пустом ODS.
4. **Чистый клон/свежий стенд никто не прошёл.** `uv` не заявлен в
пререквизитах; README-быстрый старт падает без `ddl_init`; пульт вечно висит
на запаused `etl_pipeline`; после `git pull` образ Airflow не пересобирается
(DAG красный); центральное упражнение урока 1 физически невоспроизводимо.
Всё это лежало ровно в вынесенной в риски HITL-приёмке.
## Индекс находок по issue
### Issue 08 (курс) — CHANGES_REQUESTED
- **CRITICAL** `docs/course/lessons/01_kafka_to_clickhouse.md:192-239` —
упражнение с `kafka_msg_ts` невоспроизводимо: «перезаливка среза» заменена на
`make generated-history-analytics && make up`, который делает `down -v` и
накатывает эталонный DDL — добавленная менти колонка уничтожается, финальный
SELECT падает; в тексте остался висящий `TRUNCATE` (след нестыковки).
Нужен путь перезаливки без сброса схемы (смена `kafka_group_name` /
`CLEAN_START=0` + повторный backfill) и проверка на стенде.
- **HIGH** `docs/course/README.md:19-34`, `README.md:43-47` — первая команда
курса требует `uv` на хосте; ни пререквизиты, ни quick start его не называют.
- **HIGH** `docs/course/lessons/05_monitoring.md:254,259-262,339` — на штатном
пути (без live) `Kafka No Messages Produced` горит постоянно; урок обещает
«алерты не шумят»; панели описаны для потока, которого нет.
- **MEDIUM** `00_kafka_intro.md:56-65` — служебные топики `generator_state` и
`generator_startup_history_manifest` не объяснены (урок построен на
разглядывании списка топиков).
- **MEDIUM** `00_kafka_intro.md:117-122` vs `05_monitoring.md:156-161` —
взаимоисключающие утверждения о видимости consumer lag; проверить на стенде.
- **MEDIUM** `README.md:64-68`, `docs/OPERATIONS.md:175-199` —
`make generated-history-check` после `PROFILE=daily-wave` падает: в check
зашит диапазон ci, PROFILE не передаётся.
- **MEDIUM** `docs/OPERATIONS.md:95` — источником STG первым назван архивный
`make data`.
### Issue 12/14 (пульт, профиль) — CHANGES_REQUESTED
- **HIGH** `airflow/dags/generator_control_dag.py:227-235` +
`etl_pipeline_dag.py:263` — `etl_pipeline` создаётся на паузе; `trigger_etl`
(`wait_for_completion`, без `execution_timeout`) виснет навсегда,
`max_active_runs=1` блокирует повторные запуски. В Airflow 2.10 нет
`fail_when_dag_is_paused` (Context7). Нужна предпроверка «etl_pipeline не на
паузе» + шаг unpause в README/runbook.
- **HIGH** `docker-compose.yml:293-294,334`, Makefile (`down`, `clean`) —
профильный `live-generator` не удаляется `down -v` (доказано экспериментом,
Compose v5.1.3); `restart: unless-stopped` воскрешает его после перезагрузки.
В `down`/`clean`/CLEAN_START нужен `--profile live-generator`.
- **MEDIUM** `README.md:32-40` — быстрый старт без `ddl_init`: precheck падает
сырым `UNKNOWN_TABLE`; добавить шаг и человекочитаемую подсказку.
- **MEDIUM** `airflow/requirements.txt:9-10`, Makefile `up` — после `git pull`
образ Airflow не пересобирается → `ModuleNotFoundError: prometheus_client`,
DAG красный. Нужен `--build` или инструкция.
- **MEDIUM** `docs/OPERATIONS.md:237-241` vs `kafka_io.py:161,253` — `sleep 5`
меньше времени старта continue (≥10 с): live-строк ноль, `CHECK_LIVE_SEAM=auto`
молча пропускает шов. Было `sleep 130` при тике 60 с.
- **MEDIUM** `airflow_control.py:102-141` vs `run_generator.sh:56-77`,
`import_startup_history_artifact.sh` — предпроверки только в DAG; консольные
пути тихо смешивают миры. Вынести `assert_stand_clean` в общую точку входа.
- **LOW** `service.py:203-215` — простой стенда ×60: ночь = +30 модельных суток
дыры; предупредить в runbook, советовать reset/повторный импорт.
- **LOW** — фиксация Context7-проверки Airflow API только в `.scratch/`, не в
коде/доках (AGENTS.md требует); сами вызовы для 2.10.5 корректны.
### Issue 09 (state v3, фактура) — CHANGES_REQUESTED
- **HIGH** `state.py:236-241` → `kafka_io.py:181` → `service.py:100-102` —
поднятие STATE_VERSION молча превращает живой стенд во «второй мир»: v2 —
читаемый, несовместимый state, но version-гейт убивает его до семантики,
`from_dict_safe` даёт `None`, сервис тихо стартует новый мир с `model_t0`
поверх истории. Сверка `manifest.state_version != state.version`
(`service.py:246`) недостижима. Спека (строки 198-204, 245-247) требует
громкий отказ; `IncompatibleStateError` уже существует — использовать его при
известной старой версии и `GEN_STATE_RESET=false`; миграционной заметки в
доках нет.
- **MEDIUM** `check_generated_analytics.sh:13,273-289,342,387-393` — проверка
стыка сверяет шов по env-дефолту (`2026-01-01T06:00`), не по манифесту: после
`daily-wave` голый `make generated-history-check` «доказывает» однородность
фальшивого шва посреди backfill. LEFT JOIN `dds.click` структурно пуст; ODS-блок
проходит на пустом ODS. Брать `model_t_end` из манифеста, требовать ненулевые
ODS-строки.
- **MEDIUM** `generation.py:196-209` — предпосылка issue «fallback не
срабатывает» неверна: срабатывает у ~8% визитов (зонд, seed=4242, N=20000);
выпуск не изменился только потому, что у всех доноров сида строки внутри
click_id идентичны — свойство нигде не зафиксировано; при регенерации сида
с вариативностью ~8% визитов поедет. Зафиксировать свойство сида или
ограничить `GEN_MAX_SESSION_EVENTS` длиной максимального донора.
- Ядро фикса крепкое (проверено исполнением): инвариант «рождение ==
восстановление» держится, RNG не потребляется при restore, микросекунды
чинятся честно, форма распределений не сместилась (сверка с `e2d0684^`).
### Issue 10 (Superset) — CHANGES_REQUESTED
- **MEDIUM** `tests/test_superset_dashboard_config.py:64-76` — тесты сверяют
подмножество параметров: мутация «top-15 по убыванию» в экспорте (row_limit
15→500, order_desc→false и др.) проходит все 5 тестов. Нужно полное сравнение
`params` config-vs-export. Классы «возврат world_map» и «рассинхрон layout»
закрыты честно (мутации падают).
- **MEDIUM** issue `10-*:51-68`, `create_dashboard.py:158-185` — приёмка «по
кадру» выполнена декларативно, чарт никем не отрисован; порядок столбцов после
pivot не гарантирован — задать `x_axis_sort: "Events, pcs"`,
`x_axis_sort_asc: false`; снять кадр при HITL.
- **MEDIUM** `create_dashboard.py:505-520` — удаление дублей по глобальному
совпадению имени: повторный прогон молча удалит одноимённый чарт менти;
сузить по `datasource_id`, задокументировать деструктивность.
- **LOW** `create_dashboard.py:176` — `tooltipTimeFormat` бессмыслен для
категориальной оси; легенда дублирует ось при единственной серии.
- **LOW** `docs/SUPERSET_DASHBOARD.md:205-211` — путь ручного импорта экспорта
(legacy v0) ничем не проверяется.
## Маршрутизация (предложение)
- **Багфикс-цепочка Codex** (кандидат-порядок по риску): (1) семейство
«смешение миров» — 09-HIGH + 12-HIGH-clean + разъезд предпроверок одним
issue; (2) 12-HIGH-паузы + README/ddl_init/build — «свежий стенд» одним
issue; (3) 08-CRITICAL урок 1 + uv + алерт урока 5 — «курс на чистом клоне»;
(4) ложнозелёные проверки (09-MEDIUM-шов, 10-MEDIUM-тесты); (5) остальное.
- **HITL остаётся HITL** (кадры Superset, живой проход Airflow UI, проход по
урокам) — но пункты 08-CRITICAL и 12-HIGH показывают, что часть HITL дешевле
заменить проверками на чистом клоне в CI-манере.
- Отдельно: третий случай подтверждения находки о родословной — кандидат на
датированную поправку в `references/rationale.md` скилла coordinator-loop.
## Что подтверждено как корректное
Стыковка env DAG→генератор; ветвление DAG; отсутствие потребности в Docker
изнутри Airflow; make-цели/имена DAG/DDL-колонки/числа профилей в уроках; ядро
state v3; замена карты в обоих источниках; 172 unit-теста зелёные.
@@ -0,0 +1,122 @@
# Handoff: fixes after generator cross-line review
Дата: 2026-07-05 22:12.
Жанр: handoff по ADR-0003 после coordinator-loop 15 -> 16 -> 17 -> 18.
## Что закрыто
- 15: граница миров после обновления, clean/reset и host-запусков.
Коммиты: `080d5ae`, `108053b`.
- 16: свежий стенд и `generator_control` без неявных ручных шагов.
Коммиты: `27947ce`, `9804d0b`.
- 17: доверенные проверки startup-history и Superset.
Коммиты: `00c97eb`, `6731349`.
- 18: курс 00/01/05 проходит по startup-history на чистом стенде.
Коммиты: `77e4fb6`, `6d64f39`.
- Follow-up 19: отдельная задача на единые `make test` и `make lint`.
Коммит: `2778eae`.
- Финальный review fix: документация больше не советует обычный
`make generated-history-check` после backfill-only пути.
Коммит: `8e1c733`.
- Issue 19 закрыта после внешнего Claude review: единые `make test` и
`make lint`, подключение корневых контрактных тестов и усиление проверок
startup-history.
Коммит: `9b0b063`.
## Ключевые стыки реализации
- `generator/src/clickstream_generator/stand_clean.py` — общий guard чистого
стенда для Airflow и host-путей.
- `scripts/assert_stand_clean.sh` — host-проверка перед backfill/import/continue.
- `airflow/dags/generator_control_dag.py` — pause-check `etl_pipeline` до
мутирующих веток.
- `scripts/check_generated_analytics.sh` — manifest boundary, fail-closed
live seam, непустой ODS и fail-closed проверка DM-витрин.
- `scripts/run_generated_history_runtime_check.sh` — bounded runtime gate для
daily-wave + live seam.
- `scripts/run_generator.sh` — `reset` теперь проходит clean-stand guard до
запуска live-генератора.
- `Makefile` — `make test`, `make lint`, `contract-test`; обычный pytest-вывод
тихий, подробность можно включить через `PYTEST_ARGS`.
- `generator/src/clickstream_generator/state.py` — любая версия state не `3.0`
даёт громкий `UnsupportedStateVersionError`; `from_dict_safe` больше не
глотает ошибку старой/неизвестной версии.
- `generator/src/clickstream_generator/airflow_control.py` —
`target_dag_trigger_error()` покрывает три ветки pause-check без импорта
Airflow.
- `superset/create_dashboard.py` — sync ограничен чартами целевого dashboard.
- `docs/course/lessons/00_kafka_intro.md`,
`docs/course/lessons/01_kafka_to_clickhouse.md`,
`docs/course/lessons/05_monitoring.md` — курс согласован с startup-history,
no-live default и consumer lag.
## Проверки
- Issue 15: `make generator-test`, `docker compose config --quiet`,
`py_compile`, `bash -n`.
- Issue 16: `make generator-test`, `docker compose config --quiet`,
`py_compile`, `make clean`, `make up`, `make ddl`.
- Issue 17: Docker target tests, `make generator-test`, `py_compile`,
`bash -n`, `make generated-history-runtime-check`.
- Issue 18: doc `rg`, `git diff --check`,
`make generated-history-analytics`, `make up`.
- Issue 19 / commit `9b0b063`: `make contract-test` — 16 passed;
`make generator-test` — 190 passed; `make lint` — passed; `make test` —
190 generator tests, 16 contract tests, `docker compose config --quiet`.
- Финальный chain review `Gibbs`: initial `CHANGES_REQUESTED`, narrow rerun
`APPROVED`.
## Происхождение ревью
- 15: gate; task reviewer `Ptolemy` high, code reviewer `Linnaeus` high;
оба rerun `APPROVED`.
- 16: code reviewer `Laplace` high; primary review found paused-DAG order bug,
later reruns `APPROVED`.
- 17: code reviewer `Bacon` high; found Superset ownership and live-seam
defaults; bounded runtime gate rerun `APPROVED`.
- 18: docs/code reviewer `Hume` high; found Prometheus scrape-count drift;
rerun `APPROVED`.
- Финал: chain reviewer `Gibbs` xhigh; found docs drift around
`generated-history-check`; rerun `APPROVED`.
- После `9b0b063`: внешний Claude review подтвердил закрытие всех девяти
находок и дал `APPROVED`. Reviewer не перезапускал Docker-проверки сам, но
сверил код, тесты и документы; фактические прогоны были выполнены
координатором.
## Отклонения процесса
- Локальный `uv run ... pytest` у координатора падал до pytest из-за
`snap-confine` / `cap_dac_override`. Для координаторского gate целевые тесты
запускались через Docker-образ `generator:test`. Это вынесено в issue 19.
- Первичный runtime gate issue 17 через полный `daily-wave` был остановлен:
прогон был активным, но слишком долгим для коммит-гейта. После этого добавлен
bounded `make generated-history-runtime-check`.
- Несколько стендовых проверок чистили Docker volumes. Это ожидаемо для issue
16-18 и зафиксировано в журнале.
## Что вручную проверить дальше
- Чистый ручной путь через Airflow UI: `make up`, `make ddl` или `ddl_init`,
`airflow dags unpause etl_pipeline`, затем `generator_control` backfill.
- Backfill-only путь: `make generated-history-analytics`, затем
`CHECK_LIVE_SEAM=0 make generated-history-check`.
- Стык backfill/live: `make generated-history-runtime-check`.
- Superset: после готового DM открыть dashboard и сверить, что чарты созданы и
`Top Countries by Events` читается.
- Kafka UI: в уроке 00 должны быть понятны четыре data-топика и служебные
`generator_state`, `generator_startup_history_manifest`,
`generator_batch_history`.
## Suggested skills
- `triage` — если продолжать issue 13: он всё ещё `needs-triage`.
- `brainstorm-with-docs` — если перед issue 13 нужно заново согласовать
модель доливки истории от слепка.
- `coordinator-loop` — только после перевода issue 13 в `ready-for-agent`.
- `code-review` — для внешнего прохода по коммитам после ручной проверки.
## Открытые риски
- Ручная HITL-проверка после `9b0b063` ещё не выполнена пользователем.
- Issue 13 остаётся `needs-triage`: доливка истории от слепка должна опираться
на закрытые инварианты 15 и 17.
@@ -0,0 +1,70 @@
# Handoff: проверка пути менти, диагноз флаки-гейта, задача next-day
Дата: 2026-07-07 22:34.
Жанр: handoff по ADR-0003 после сессии «проверка пути менти + триаж 13/20».
## Что закрыто
- Проверка пути менти двумя субагентами (читатель документации на Opus,
прогонщик стенда на Sonnet):
- Документация: workable-with-friction, четыре мелких находки — все
исправлены. Коммит `900e949`.
- Стенд с нуля: clean -> generated-history-analytics -> up -> проверки ->
Superset (чарты с данными) -> Kafka (4 data-топика + служебные) ->
ClickHouse (ODS/DM непустые) — всё прошло, дубликатов ноль.
- Единственное падение: `make generated-history-runtime-check` красный
через раз — заведена задача 20.
- Диагноз задачи 20 (Codex, ключевой факт перепроверен по коду): генератор
фактуру на стыке НЕ ломает; корень — гонка остановки live-генератора в
самой проверке + seam-SQL, маскирующий непарные строки под «смену фактуры».
Детали и направление фикса — в задаче. Коммит `98be722`.
- Задача 13 переписана под глагол `next-day` в `generator_control`;
оба открытых вопроса закрыты; развилка реализации решена пользователем:
**вариант 2 — генерация следующего дня от слепка** (обоснование в задаче).
Коммиты `a3ac4b4`, `98be722`; решение развилки — в коммите с этим handoff.
- Созданы репо-локальные субагенты `.claude/agents/deep-reasoner.md` (Opus)
и `fast-worker.md` (Sonnet); `.gitignore` скрывает локальное в `.claude/`.
Коммит `984d6fc`.
## Состояние трекера
- `20-flaky-runtime-seam-check.md` — **ready-for-agent**: диагноз есть,
направление фикса выбрано (управляемая остановка live после полного flush
всех топиков + precheck непарных строк в seam-check). Идти первым.
- `13-backfill-top-up-from-snapshot.md` — **ready-for-agent**, blocked by 20.
Вариант реализации решён, критерии в задаче.
## Что дальше
1. Фикс задачи 20 по конвейеру `/claude-subagent-playbook`: постановка в
задаче самодостаточна, свежий адверсарный взгляд на неё до реализации,
реализация Кодексом (прямой `codex exec`, не плагин — см. handoff в
скилле), два слепых ревью.
2. После зелёного стабильного гейта (3+ прогона) — задача 13.
3. Ручная HITL-проверка пути менти пользователем всё ещё не сделана
(Airflow UI, Superset глазами, Kafka UI) — стенд остался в рабочем
состоянии после зелёного runtime-check.
## Проверки этой сессии
- `make generated-history-runtime-check`: красный (8/19), затем зелёный —
эмпирика флаки в задаче 20; полные логи в session-scratchpad (одноразовые,
сводка перенесена в задачу).
- Утверждение диагноза про запасную ветку перепроверено по
`generation.py:194-209`.
- Дерево чистое после каждого коммита; `.claude/settings.local.json`
в git не попал.
## Отклонения процесса
- Прогонщик стенда дважды засыпал без отслеживаемого фонового процесса и
в финале умер, не собрав отчёт, — вердикт собран координатором из логов.
- Диагност через плагин codex-rescue: форвардер отчитался «finished» при
живом фоновом вызове; результат забран напрямую из output-файла задания.
Уроки процесса — в handoff рядом со скиллом claude-subagent-playbook.
## Suggested skills
- `claude-subagent-playbook` — для фикса задачи 20 (одна нетривиальная
задача: постановка готова, нужен конвейер).
- `conventional-commits` — коммиты.
@@ -0,0 +1,82 @@
# Handoff: задача 13 закрыта конвейером, дальше — задача про расписание и HITL
Дата: 2026-07-12 23:59.
Жанр: handoff по ADR-0003 после сессии «задача 13 (глагол next-day) по
/claude-subagent-playbook».
## Что закрыто
- **Задача 13 — done.** Полный конвейер плейбука: двойное слепое ревью
постановки (оба CHANGES_REQUIRED, 9 решений внесены, коммит `540989d`)
-> реализация Codex (`gpt-5.6-sol`, high, тред
`019f57d2-ff7b-70d3-8143-3740324309d9`) -> два слепых ревью кода
(Codex: CHANGES_REQUIRED 3 MAJOR; Claude: APPROVED_WITH_MINORS) ->
триаж в 2 круга + узкий диагноз нулевого стыка -> перепроверки
ALL_CLOSED у обоих -> моя приёмка. Коммит `f5dee26`.
- Суть решений, диагноз нулевого стыка (естественный ночной провал, не
дефект restore) и аргументы отклонений — в самой задаче
`.scratch/generator-model-time-startup-history/issues/13-*.md`, здесь
не дублируются.
- Приёмка своими глазами: `make test` 204+31; `generated-history-chain-check`
зелёный (граница 1: crossing=1, граница 2: crossing=0 с явным статусом,
непарные — нули); регрессия `generated-history-runtime-check` задачи 20
зелёная (19 переходящих визитов). Учебный цикл: DM 322 -> 10 026 ->
19 196 за два next-day.
## Состояние
- Ветка `feature/data-generator`, дерево чистое после `f5dee26`
(+ handoff-коммит следом), запушено в origin.
- Стенд поднят, в мире 3 модельных дня (артефакт + два next-day от
прогонов исполнителя); live-генератор остановлен.
- Модель Codex — `gpt-5.6-sol` (high для реализации/ревью; xhigh не
окупается). Перед каждым фоновым запуском — сообщать пользователю, что
запускается и какой этап.
## Что дальше
1. **Завести задачу про расписание** («имитация жизни»): включение
`next-day` по расписанию. Обязана решить: как расписание задаёт
`operation=next-day` (дефолт DAG — `backfill`, планового запуска с
дефолтом быть не должно) и retention/формат накопительного состояния
манифеста (см. отклонение «полная перечитка Kafka» в задаче 13 —
оно блокирует её по записи в тикете).
2. **Ручная HITL-проверка пути менти** (Airflow UI, Superset глазами,
Kafka UI) — переносится пятый handoff подряд; исполнитель задачи 13
отметил, что Airflow UI руками не гонял (проводка next-day проверена
контрактными тестами и реальными запусками через `run_batch.sh`).
3. **Внести уроки в плейбук**: рефлексия по расходу контекста мастера
проведена в конце сессии 2026-07-12/13 (см. итоговое сообщение) —
согласовать с пользователем и записать в
`~/.claude/skills/claude-subagent-playbook/references/lessons.md`.
Ключевые кандидаты: ограничить раздел confirmations у ревьюеров
(одна строка на пункт); самый большой неконтролируемый расход —
эхо всего файла задачи в контекст мастера, когда исполнитель правит
файл, который мастер недавно писал сам.
## Проверки этой сессии
- `make test`: 204 (генератор) + 31 (контракты корня) — прогнаны мной,
исполнителем и ревьюерами независимо.
- Стенд: chain-check и runtime-check зелёные (лог приёмки одноразовый в
session-scratchpad, сводка — в задаче 13).
- Дерево сверялось с baseline после каждого ревью/перепроверки —
ревьюеры исходники не трогали.
## Отклонения процесса
- Перепроверка Codex принесла новую находку (MAJOR про микросекунды) —
это не нарушение узости, штатный случай из prompt-templates.md;
закрыта вторым кругом триажа тем же тредом исполнителя.
- Требование «громкий отказ на нулевом стыке» из обоих ревью покраснело
на честном стенде; решён диагнозом по данным (ветка «естественный
провал») и сменой семантики на явный статус — класс «артефакт
верификатора», как в задаче 20.
## Suggested skills
- `claude-subagent-playbook` — задача про расписание тем же конвейером
(постановку писать с нуля — этапы 1–2).
- `conventional-commits` — коммиты.
- `research` — если для задачи про расписание понадобится фактура по
Airflow (params при scheduled runs) — через Context7.
@@ -0,0 +1,103 @@
# Handoff: ручной HITL пути менти пройден — вырос в набросок редизайна
Дата: 2026-07-19 21:44.
Жанр: handoff по ADR-0003 после сессии «ручная HITL-проверка пути менти»
(долг переносился пятый handoff подряд — закрыт).
## Что сделано
- **HITL-проверка пути менти пройдена целиком, своими руками через UI.**
Чистый стенд (`make clean` + `make up`) -> Airflow UI: `ddl_init` ->
`generator_control` `backfill` (профиль `ci`) -> ETL -> `make superset-init`
-> дашборд глазами -> `generator_control` `next-day` -> проверка стыка.
Каждый шаг сверен по данным (ClickHouse, manifest, chain-check).
- **Результат мира:** backfill `[00:00, 06:00)` = 16 054 события; после
`next-day` = 110 602 события, визитов 10 228, пользователей 1 726;
`boundaries` = три границы, `model_t_end` = `2026-01-02 06:00`.
`generated-history-chain-check` — EXIT 0 (стык 06:00: 9 переходящих
визитов, непарных 0, однородность подтверждена).
- **9 находок + рамочная модель** — в
`.scratch/generator-model-time-startup-history/hitl-findings.md`.
Половина находок родилась из наблюдений менти. HITL из «проверить, что
не сломалось» перерос в **набросок редизайна пути менти**.
## Ключевые выводы (кратко; подробно — в hitl-findings.md)
- **Рамочная модель менти:** основной режим — `import` артефакта (база),
дальше две ветки от одного состояния: `next-day` (пакетно) и `continue`
(живой поток). Это три разных лабораторных. `next-day` и `continue`
не схлопывать. Подтверждено `launch.py:117-137`.
- **F7 + замер:** опыт на 6h беден; нужен готовый артефакт из файла.
Артефакт 6h = 49 МБ; `xz -9e` -> 1,1 МБ (48x, 13,5 с). На 3 дня ~13 МБ
— кладётся в обычный git. Паттерн-образец: `airflow-greenplum-solution`
(`bookings/seed/demo.sql.xz`).
- **Решение менти:** целевой стартовый мир — **3 дня на `daily-wave`**
(обсуждаемо). На `ci` три дня плоские — волну даёт `daily-wave`.
- **F8:** два профиля путают. Свести к одному учебному (волна, ×60);
`ci` (×1) — служебный для тестов. ×60 оправдан live-лабой (`continue`).
- **F9:** `next-day` растёт O(N²) из-за полной перечитки Kafka для
счётчиков (`service.py:591`). Параллелить не то (ломает детерминизм);
правильно — инкрементальные счётчики (O(N), снимает риск OOM). Это и
есть решение «новый формат состояния manifest», блокирующее расписание.
- **F1, F5 (баги UI):** форма `generator_control` держит необязательные
поля обязательными (`type="string"` без `null`); Airflow и Superset
быстро разлогинивают (ротацию секрета исключил, корень TBD).
## Состояние
- Ветка `feature/data-generator`. После этого handoff в дереве останутся:
`hitl-findings.md` + этот handoff (коммитятся), плюс **не коммитить**
`data/ci_backfill.json` (49 МБ, побочный от backfill; `.gitignore` его
не покрывает — риск, см. ниже).
- Стенд **оставлен поднятым** по просьбе пользователя; в мире 1,25 суток
(профиль `ci`), live-генератор не запущен. Выросший мир можно смотреть
в Superset (dashboard id 1).
## Что дальше
1. **Разложить находки в постановки по плейбуку** (этапы 1-2, двойное
слепое ревью). Ориентировочно ~4 задачи (номера 21+ в `issues/`):
- **Ядро:** эталонный 3-дневный артефакт (`daily-wave`) как база
менти через `import`; формат/сжатие (`xz`, нужен ли `raw_topics`);
место хранения (git после отладки сида). Снимает F7.
- **Профиль:** один учебный профиль, `ci` -> служебный тестовый.
Перед слиянием проверить, не зависит ли live-тест от скорости ×1
(F8).
- **Расписание + retention:** беспараметрный DAG `next-day` (F1/F3),
`schedule`, инкрементальные счётчики manifest (F9/F6). Это одна
связка — их корень общий.
- **Баг-фиксы:** F1 (форма) и F5 (разлогин); F4 (JS-баг Grid) низкий.
2. **Три режима -> свои уроки/лабы** — вход в учебный контент
(`docs/course/`). Под каждую ветку отдельная лаба: `next-day`
(пакетная инкрементальная обработка) и `continue` (потоковый приём);
общая база `import` — их совместное начало. Отдельно от
инфраструктурных задач.
3. **Мелочь по гигиене:** `data/*.json` не покрыт `.gitignore` —
побочные артефакты backfill (49 МБ) рискуют попасть в коммит. Решить:
правило в `.gitignore` или чистить артефакты после прогонов.
## Проверки этой сессии
- `ddl_init`: 4 слоя созданы (stg 12 / ods 8 / dds 2 / dm 6 таблиц).
- Форма мира после backfill: пирамида 445 < 1516 < 16 054, интервал
`[00:00, 06:00)` точный.
- После `next-day`: 3 границы в manifest, мир до `2026-01-02 06:00`.
- `generated-history-chain-check`: EXIT 0.
- Длительность `next-day` (метаданные Airflow): задача `run_next_day`
638 с (~10,6 мин); ETL `trigger_etl` 30 с — узкое место в генерации.
- Замер сжатия: `xz -9e` 48x за 13,5 с; gzip -9 17x.
## Отклонения процесса
- Ревью субагентами не запускали: пользователь просил «свежий взгляд»,
что по договорённости = без субагентов. Проверка проведена мной руками.
- Обход бага F1: форму backfill/next-day заполняли значениями профиля
`ci` целиком (пустые необязательные поля UI не пропускает).
## Suggested skills
- `claude-subagent-playbook` — разложить находки в постановки тем же
конвейером (этапы 1-2 с нуля).
- `conventional-commits` — коммиты.
- `research` / Context7 — для задачи про профиль/форму (Airflow Param
`type=["null","string"]`) и про формат артефакта.
+10 -2
View File
@@ -4,10 +4,18 @@
## Цель репозитория ## Цель репозитория
Реализовать [тестовое задание DE](./docs/DE-task.md): поднять в `docker compose` стек Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana и получить витрины для первичного анализа. Данный проект - учебный для менти. Учебная ценность разработок - одна из его базовых ценностей.
## Обязательные правила ## Обязательные правила
Пиши на ясном русском языке. Иностранные слова оставляй только там, где у термина нет устоявшегося русского аналога: имена технологий и инструментов (Kafka, ClickHouse, Airflow) и названия из кода. Если для понятия есть обычное русское слово — используй его, не выдумывай транслитерации (пиши «приведение в соответствие», а не «реконсиляция»; «точка отсчёта», а не «origin»).
Документы, комментарии и объяснения держи короткими и понятными читателю, который их не писал: простые слова, короткие фразы, сложную мысль поясняй при первом упоминании (помни про менти-неспециалиста). Если понятность и буквальная точность спорят — выбирай понятность. Правило касается и текста, и заголовков разделов.
Внутренние рассуждения и промежуточные пометки по ходу работы веди на английском — он экономнее по токенам (кириллица занимает примерно в 1,5–2 раза больше). На русском остаётся всё, что видит и хранит проект: итоговые ответы пользователю, документы, комментарии в коде и сообщения коммитов.
Для работы с python использовать uv.
### Данные ### Данные
- Не загружать `*.jsonl` целиком без необходимости: по умолчанию использовать малый срез (`head -n 20..50`). - Не загружать `*.jsonl` целиком без необходимости: по умолчанию использовать малый срез (`head -n 20..50`).
@@ -52,7 +60,7 @@ Single-context: `CONTEXT.md` и `docs/adr/` в корне репозитория
- [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов. - [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов.
- [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md). - [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md).
- [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины). - [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины).
- `.scratch/handoffs/YYYY-MM-DD-<slug>.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md). - `.scratch/handoffs/YYYYMMDD-HHMM-<slug>.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md).
## Ограничения по структуре ## Ограничения по структуре
+84 -8
View File
@@ -22,8 +22,11 @@
`domain_sessionid`). Поэтому в этой модели **сессия ≡ визит ≡ `click_id`** — `domain_sessionid`). Поэтому в этой модели **сессия ≡ визит ≡ `click_id`** —
группа событий под одним идентификатором, а не окно активности по тайм-ауту. группа событий под одним идентификатором, а не окно активности по тайм-ауту.
Если в будущем понадобится «настоящая» сессия по 30-минутному окну неактивности — Если в будущем понадобится «настоящая» сессия по 30-минутному окну неактивности —
это отдельная производная над `user_domain_id` + `event_ts`; на текущих данных это отдельная производная над `user_domain_id` + `event_ts`, и на сиде она дала
она дала бы те же группы (разброс времени внутри `click_id` ≤ 1 мин). бы **другие** группы: в полном сиде 11 внутрисессионных пауз превышают 30 минут,
а разброс времени внутри `click_id` доходит до 49 минут (см. профиль
сид-датасета ниже; ранее здесь ошибочно значилось «разброс ≤ 1 мин» — это был
замер по малому срезу файла).
«Сессию» и «визит» используем как синонимы; в UI предпочитаем «визит», когда важно «Сессию» и «визит» используем как синонимы; в UI предпочитаем «визит», когда важно
подчеркнуть, что это не сессия-по-тайм-ауту. подчеркнуть, что это не сессия-по-тайм-ауту.
@@ -44,9 +47,61 @@ user_domain_id (пользователь, постоянный)
Здоровые данные дают пирамиду `users ≤ sessions ≤ events`. Здоровые данные дают пирамиду `users ≤ sessions ≤ events`.
## Почему на демо `Unique Users == Unique Sessions` ## Термины потоковой генерации (steady-stream)
В демо-датасете (`data/*.jsonl`) каждый пользователь имеет **ровно один** Относятся к синтетическому потоку из генератора (режим `steady-stream`), не к
статическому сиду. Решение и контекст — [ADR-0004](./docs/adr/0004-steady-stream-synthetic-generator.md),
форма доработки — [спека генератора](./docs/specs/2026-06-09-generator-rework-hierarchical.md).
### Популяция пользователей (user population)
Множество пользователей с **постоянными** `user_domain_id`, которые генератор
держит во времени и из которых разыгрывает активность. В отличие от сида (где
пул `user_domain_id` плоский и 1:1 с визитами), популяция — это источник
возвратов: один и тот же пользователь порождает несколько визитов.
### Возвращающийся пользователь (returning user)
Пользователь, открывающий **более одного** визита (`click_id`) во времени, с
межсессионными паузами. Именно возвраты дают расхождение `users < sessions` —
то, чего нет на сиде (`users == sessions`) и что отличает поток от статики.
### Три значения слова «сид»
Слово перегружено — в разговоре про генератор это **три разные сущности**, их
нельзя путать (см. [ADR-0005](./docs/adr/0005-generator-model-clock.md)):
- **`GEN_SEED`** — зерно ГПСЧ генератора (детерминизм случайных решений). Не
данные, а число.
- **архивный статический сид** (короткое имя — «статический сид», файлы
`data/*.jsonl`) — кладовка готовых значений для генератора: браузеры,
страны, устройства, метки кампаний. По
[ADR-0006](./docs/adr/0006-generation-as-sole-analytics-source.md)
он **выведен из аналитики и стал архивным**: витрины, дашборды и курс идут через
startup-history/backfill → Kafka → STG → ODS → DDS → DM → Superset. Цель —
**совсем убрать файл**, когда генератор научится придумывать фактуру сам
(отдельная спека). Профиль ниже — теперь опорные цифры и список
известных расхождений, а не эталон для подгонки (подгонять генерацию под сид
число-в-число в ADR-0006 отклонено).
- **стартовая история стенда** (синонимы — **«стартовый сид»** и **«новый сид»**;
это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` +
заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006
она **несущая**: именно с неё свежий стенд получает историю с первой минуты.
Механизм реализован через Airflow DAG `generator_control` и служебный чистый
путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md).
### Модельное время и масштаб (×K)
**Модельное время стенда** отвязано от настенных часов: генератор крутит
внутренние часы, а драйвер задаёт скорость — ×1 (как реальное время), ×K
(ускоренно, учебная «ручка» урока 7) или `K → ∞` (мгновенная заливка прошлого).
При ускорении «сейчас» стенда уходит вперёд настенного времени — это свойство, не
баг (ключ аналитики — `event_timestamp`). Решение и режимы —
[ADR-0005](./docs/adr/0005-generator-model-clock.md).
## Почему на статическом сиде `Unique Users == Unique Sessions`
В архивном статическом сиде (`data/*.jsonl`) каждый пользователь имеет **ровно один**
`click_id` — связь user↔визит строго 1:1 (проверено: 99 пользователей = `click_id` — связь user↔визит строго 1:1 (проверено: 99 пользователей =
99 `click_id` в полном файле). Поэтому `uniqExact(user_domain_id)` и 99 `click_id` в полном файле). Поэтому `uniqExact(user_domain_id)` и
`uniqExact(click_id)` дают одинаковое число. `uniqExact(click_id)` дают одинаковое число.
@@ -58,11 +113,32 @@ user_domain_id (пользователь, постоянный)
(события на визит, доля визитов с одним событием), а различие «пользователь vs (события на визит, доля визитов с одним событием), а различие «пользователь vs
сессия» объясняется текстом урока. сессия» объясняется текстом урока.
Синтетический генератор (ветка `feature/data-generator`) проблему не решает, а В аналитическом контуре это больше не опорный сценарий: генератор ведёт
усугубляет: он штампует свежий `click_id` на каждое событие, и `click_id` популяцию пользователей и возвращения во времени.
вырождается в «событие». См. `generator/KNOWN_ISSUES.md` на той ветке.
## Профиль сид-датасета (измерено 2026-06-10, полные файлы)
Опорные цифры о статическом сиде (`data/*.jsonl`) — для калибровки генератора
и проверки гипотез. Измерено по **полным** файлам (1000 событий); внимание:
ранние оценки «1..7 событий на визит» делались по малому срезу и неверны.
- **Объём:** 1000 событий, 99 визитов (`click_id`), 99 пользователей (1:1).
- **Длина визита:** 1..27 событий, медиана 10, среднее 10.1.
- **Паузы между событиями визита:** медиана ~20 с, p95 ≈ 17 мин,
максимум ≈ 40 мин; пауз длиннее 30 минут — 11 штук. Разброс времени внутри
одного `click_id` — до 49 минут.
- **Воронка:** 25% визитов достигают `/confirmation`. Пути — с петлями:
возвраты на `/home`, просмотр обоих товаров; у 21 из 25 «купивших» визитов
есть события **после** `/confirmation`, у 9 — `/confirmation` встречается
дважды. То есть `/confirmation` — последний *шаг воронки*, но не обязательно
последнее *событие визита*.
- **Страницы (по событиям):** `/home` 426, `/product_a` 236, `/product_b` 157,
`/cart` 93, `/payment` 53, `/confirmation` 35. `event_type` — 100% `pageview`.
## Слои данных ## Слои данных
`STG → ODS → DDS → DM` — см. [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). `STG → ODS → DDS → DM` — см. [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
Витрины (`dm.*`) и дашборд Superset строятся на статическом сиде, не на потоке. Сейчас витрины (`dm.*`) и дашборд Superset строятся на статическом сиде. По
[ADR-0006](./docs/adr/0006-generation-as-sole-analytics-source.md) целевой источник
аналитики — генерация (стартовая история и живой поток); перевод загрузки, витрин
и дашбордов на неё входит в текущую работу, детальный план — отдельной спекой.
+111 -4
View File
@@ -1,23 +1,33 @@
.PHONY: up down clean ddl data transform logs \ .PHONY: up down clean ddl data transform logs \
generated-history-analytics generated-history-check generated-history-runtime-check generated-history-chain-check \
startup-history-export startup-history-import startup-history-check \
reload-monitoring recover-monitoring \ reload-monitoring recover-monitoring \
superset-init superset-dashboard superset-ui superset-restart superset-init superset-dashboard superset-ui superset-restart \
generator-up generator-down generator-logs generator-restart \
generator-backfill generator-continue generator-reset \
generator-test generator-test-build generator-test-cov \
contract-test config-test test lint
COMPOSE ?= docker compose COMPOSE ?= docker compose
PROFILE ?= ci
PYTEST_ARGS ?= -q -p no:cacheprovider
PYTHON_CHECK_PATHS := airflow/dags configs generator scripts superset tests
BASH_SCRIPTS := $(shell find scripts -type f -name '*.sh' | sort)
# ============================================================================ # ============================================================================
# Основные команды # Основные команды
# ============================================================================ # ============================================================================
up: up:
$(COMPOSE) up -d $(COMPOSE) up -d --build clickhouse kafka kafka-ui postgres-metadata airflow-webserver airflow-scheduler prometheus grafana kafka-exporter statsd-exporter
# Остановить и удалить контейнеры/сети текущего проекта # Остановить и удалить контейнеры/сети текущего проекта
down: down:
$(COMPOSE) down $(COMPOSE) --profile live-generator down
# Полная очистка окружения проекта (включая volumes) # Полная очистка окружения проекта (включая volumes)
clean: clean:
$(COMPOSE) down -v --remove-orphans $(COMPOSE) --profile live-generator down -v --remove-orphans
logs: logs:
$(COMPOSE) logs -f --tail=200 $(service) $(COMPOSE) logs -f --tail=200 $(service)
@@ -35,6 +45,34 @@ data:
transform: transform:
bash ./scripts/run_batch.sh bash ./scripts/run_batch.sh
# Чистый аналитический прогон: стартовая история генератора -> STG -> ODS -> DDS -> DM -> Superset
generated-history-analytics:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generated_history_analytics.sh
# Повторяемая проверка после прогона стартовой истории
generated-history-check:
PROFILE="$${PROFILE:-$${GEN_LAUNCH_PROFILE:-$(PROFILE)}}" GEN_LAUNCH_PROFILE="$${GEN_LAUNCH_PROFILE:-$${PROFILE:-$(PROFILE)}}" CHECK_LIVE_SEAM="$${CHECK_LIVE_SEAM:-1}" COMPOSE_BIN="$(COMPOSE)" bash ./scripts/check_generated_analytics.sh
# Быстрый runtime gate issue 17: короткий daily-wave backfill + live seam без Superset UI
generated-history-runtime-check:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generated_history_runtime_check.sh
# Проверить все завершённые стыки в накопительной цепочке модельных границ
generated-history-chain-check:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/check_generated_history_chain.sh
# Сгенерировать стартовую историю и сохранить портативный артефакт
startup-history-export:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/export_startup_history_artifact.sh
# Воспроизвести портативный артефакт стартовой истории в Kafka
startup-history-import:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/import_startup_history_artifact.sh
# Сверить ClickHouse после импорта с manifest артефакта
startup-history-check:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/check_startup_history_manifest.sh
# Перезагрузка конфигурации мониторинга (после изменений в provisioning) # Перезагрузка конфигурации мониторинга (после изменений в provisioning)
reload-monitoring: reload-monitoring:
@echo "=== Перезагрузка сервисов мониторинга ===" @echo "=== Перезагрузка сервисов мониторинга ==="
@@ -85,3 +123,72 @@ superset-ui:
# Перезапуск Superset # Перезапуск Superset
superset-restart: superset-restart:
$(COMPOSE) restart superset $(COMPOSE) restart superset
# ============================================================================
# Генератор событий (автономный стриминг)
# ============================================================================
# Запустить только генератор (полезно для отладки)
generator-up:
$(COMPOSE) up -d --build generator
# Остановить генератор
generator-down:
$(COMPOSE) stop generator
# Логи генератора
generator-logs:
$(COMPOSE) logs -f --tail=100 generator
# Перезапуск генератора с пересборкой
generator-restart:
$(COMPOSE) up -d --build --force-recreate generator
# Промотать стартовую историю через профиль запуска
generator-backfill:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh backfill
# Продолжить live-поток из существующего state
generator-continue:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh continue
# Запустить live-поток как новый мир
generator-reset:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh reset
# Собрать тестовый образ генератора
generator-test-build:
cd generator && docker build -t generator:test .
# Запустить тесты генератора (pytest)
generator-test: generator-test-build
@echo "=== Запуск тестов генератора ==="
docker run --rm -v $(PWD):/workspace -w /workspace/generator generator:test pytest tests/ $(PYTEST_ARGS)
# Запустить тесты с покрытием
generator-test-cov: generator-test-build
@echo "=== Запуск тестов с покрытием ==="
docker run --rm -v $(PWD):/workspace -w /workspace/generator generator:test pytest tests/ -v --cov=. --cov-report=term-missing
# Запустить контрактные тесты верхнего уровня
contract-test: generator-test-build
@echo "=== Запуск контрактных тестов репозитория ==="
docker run --rm -v $(PWD):/workspace -w /workspace generator:test pytest tests/ $(PYTEST_ARGS)
# Проверить конфигурацию compose без запуска сервисов
config-test:
$(COMPOSE) config --quiet
# Быстрый предкоммитный набор: тесты генератора, контракты и конфигурация
test: generator-test contract-test config-test
# Статические проверки без запуска стенда
lint: generator-test-build
@echo "=== Проверка синтаксиса Python ==="
docker run --rm -e PYTHONPYCACHEPREFIX=/tmp/pycache -v $(PWD):/workspace -w /workspace generator:test python -m compileall -q $(PYTHON_CHECK_PATHS)
@echo "=== Проверка синтаксиса Bash ==="
bash -n $(BASH_SCRIPTS)
@echo "=== Проверка docker compose ==="
$(COMPOSE) config --quiet
@echo "=== Проверка пробелов в diff ==="
git diff --check
+77 -29
View File
@@ -9,8 +9,13 @@
витринами. витринами.
Поток данных коротко: Поток данных коротко:
`data/*.jsonl → Airflow (kafka_load) → Kafka → ClickHouse (слой STG) → Airflow - **стартовая история**: `generator backfill → Kafka → ClickHouse (STG) →
(etl_pipeline: STG → ODS → DDS → DM) → Superset`. batch STG → ODS → DDS → DM → Superset`.
- **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL
→ Superset`.
Файлы `data/*.jsonl` больше не основной источник аналитики. Пока они остаются
архивной кладовкой значений для генератора: браузеры, страны, устройства и UTM.
## Куда дальше ## Куда дальше
@@ -23,36 +28,77 @@
## Быстрый старт ## Быстрый старт
Стенд управляется через Airflow — это основной рабочий способ. Отдельные shell-скрипты в Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`,
`scripts/` оставлены как запасной вариант для локальных прогонов (см. `git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки.
[OPERATIONS](./docs/OPERATIONS.md)).
Для ручной работы поднимите стенд и создайте стартовую историю через Airflow:
```bash ```bash
# 1. Поднять весь стек
make up make up
docker compose ps # убедиться, что контейнеры запустились make ddl
docker compose ps
``` ```
Дальше — три шага в Airflow (веб-интерфейс `http://localhost:8080`, логин и пароль Откройте Airflow: `http://localhost:8080` (`admin/admin`). Сначала снимите
`admin`/`admin`). Сними каждый DAG с паузы (кнопка Unpause) и запусти по очереди: паузу с DAG `etl_pipeline` (переключатель слева от имени): на свежем стенде он
создаётся на паузе, и `backfill` откажется стартовать. Затем запустите
`generator_control` с операцией `backfill`: DAG создаст стартовую историю,
запустит ETL и выполнит `check`. `make up` не запускает live-генератор; live
включается отдельно командой `make generator-continue`.
1. `ddl_init` — создаёт базы, таблицы и представления в ClickHouse. После обновления репозитория снова выполните `make up`: команда пересобирает
2. `kafka_load` — заливает события из `data/*.jsonl` в Kafka. Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд
3. `etl_pipeline` — прогоняет цепочку STG → ODS → DDS → DM. собирается позже, когда DM уже готов: через `make generated-history-analytics`
или `make superset-init`.
Те же шаги можно запускать из командной строки — это удобно для скриптов: Для полностью автоматического чистого прогона из консоли есть команда — это
тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов
(DDL она применяет сама, отдельный `make ddl` не нужен):
```bash ```bash
docker compose exec -T airflow-webserver airflow dags trigger ddl_init make generated-history-analytics
# Загрузить первые 100 строк каждого файла (limit=0 — загрузить всё)
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
--conf '{"limit": 100, "reset_topics": true}'
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
--conf '{"full_refresh": true}'
``` ```
По умолчанию это быстрый профиль `ci`: 6 часов модельного времени. Историю на
2 суток с суточной волной можно прогнать одной командой. В live-продолжении
`daily-wave` идёт с ×60: модельные сутки проходят примерно за 24 настенные
минуты.
```bash
PROFILE=daily-wave make generated-history-analytics
```
Разовую длительность можно задать без ручного расчёта правой границы:
```bash
GEN_HISTORY_DURATION=2d make generated-history-analytics
```
Повторить техническую проверку после такого прогона только стартовой истории:
```bash
CHECK_LIVE_SEAM=0 make generated-history-check
```
Стык backfill/live проверяйте отдельным коротким сценарием:
```bash
make generated-history-runtime-check
```
Перед коммитом используйте быстрые проверки:
```bash
make test
make lint
```
Они не чистят volumes и не запускают долгие стендовые сценарии. Полная проверка
стыка backfill/live остаётся отдельной командой `make generated-history-runtime-check`.
Сохранить стартовую историю в файл и восстановить её без новой генерации можно
по [runbook стартовой истории](./docs/runbooks/startup-history.md).
Проверить, что данные дошли до витрин: Проверить, что данные дошли до витрин:
```bash ```bash
@@ -76,17 +122,16 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12
Готовый дашборд в Superset: Готовый дашборд в Superset:
`http://localhost:8088/superset/dashboard/ecommerce-analytics/` — он создаётся `http://localhost:8088/superset/dashboard/ecommerce-analytics/` — он создаётся
автоматически через минуту-две после `make up`. Состав и настройка дашборда описаны в во время `make generated-history-analytics`. Состав и настройка дашборда описаны в
[SUPERSET_DASHBOARD](./docs/SUPERSET_DASHBOARD.md). [SUPERSET_DASHBOARD](./docs/SUPERSET_DASHBOARD.md).
## Как устроен поток данных ## Как устроен поток данных
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph AF["Airflow"] subgraph GEN["Generator"]
D1["ddl_init"] BF["backfill"]
D2["kafka_load"] LIVE["live"]
D3["etl_pipeline"]
end end
subgraph Kafka["Kafka"] subgraph Kafka["Kafka"]
@@ -100,11 +145,12 @@ flowchart LR
DM["DM: витрины VIEW"] DM["DM: витрины VIEW"]
end end
D2 -->|загрузка JSONL| Kafka -->|Kafka MV| STG BF -->|стартовая история| Kafka
LIVE -->|продолжение| Kafka
Kafka -->|Kafka MV| STG
STG -->|batch| ODS -->|batch| DDS -->|VIEW| DM STG -->|batch| ODS -->|batch| DDS -->|VIEW| DM
D1 -.->|DDL| CH DDL["DDL"] -.-> CH
D3 -.->|batch| ODS & DDS
``` ```
«Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в «Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в
@@ -119,6 +165,8 @@ flowchart LR
обоснование решений. обоснование решений.
- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов, - [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов,
мониторинг, частые проблемы. мониторинг, частые проблемы.
- [Runbook стартовой истории](./docs/runbooks/startup-history.md) — экспорт,
импорт и live-продолжение из готового артефакта.
- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять. - [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять.
- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом - [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом
стенде. стенде.
+356
View File
@@ -0,0 +1,356 @@
"""
DAG-пульт генератора стартовой истории.
Пульт выполняет только Python-код генератора внутри Airflow worker. Жизненный
цикл контейнеров остаётся в Makefile.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow import DAG
from airflow.exceptions import AirflowException
from airflow.models.dag import DagModel
from airflow.models.param import Param
from airflow.operators.empty import EmptyOperator
from airflow.operators.python import BranchPythonOperator, PythonOperator
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.utils.session import provide_session
from airflow.utils.trigger_rule import TriggerRule
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
from clickstream_generator.airflow_control import (
KAFKA_BOOTSTRAP_SERVERS,
assert_clickhouse_matches_manifest,
assert_expected_t_end,
assert_live_generator_not_running,
assert_live_generator_not_running_for_next_day,
assert_next_day_snapshot,
assert_stand_clean,
build_control_env,
build_next_day_env,
default_artifact_path,
load_manifest_from_kafka,
load_state_from_kafka,
run_backfill,
run_import,
run_next_day,
target_dag_trigger_error,
validate_import_artifact,
)
from clickstream_generator.launch import PROFILES
default_args = {
"owner": "airflow",
"depends_on_past": False,
"email_on_failure": False,
"email_on_retry": False,
"retries": 0,
"retry_delay": timedelta(minutes=1),
}
ETL_DAG_ID = "etl_pipeline"
def _conf(context) -> dict:
dag_run = context.get("dag_run")
return dag_run.conf if dag_run and dag_run.conf else {}
def _param(context, name: str):
return _conf(context).get(name, context["params"][name])
def _operation(context) -> str:
return str(_param(context, "operation"))
def _artifact_path(context) -> str:
value = str(_param(context, "artifact_path") or "").strip()
if value:
return value
return default_artifact_path(_operation(context))
def _overrides(context) -> dict[str, str]:
return {
key: str(_param(context, param_name) or "").strip()
for param_name, key in {
"seed": "GEN_SEED",
"model_time_speed": "GEN_MODEL_TIME_SPEED",
}.items()
}
def _build_env(context, operation: str) -> dict[str, str]:
artifact_path = _artifact_path(context)
if (
operation == "backfill"
and not str(_param(context, "artifact_path") or "").strip()
):
artifact_path = None
return build_control_env(
operation,
profile_name=str(_param(context, "profile")),
duration=str(_param(context, "duration") or "").strip(),
artifact_path=artifact_path,
overrides=_overrides(context),
)
def choose_operation(**context) -> str:
"""Выбирает ветку пульта по параметру operation."""
operation = _operation(context)
if operation == "backfill":
return "check_etl_not_paused_before_backfill"
if operation == "import":
return "check_etl_not_paused_before_import"
if operation == "next-day":
return "check_etl_not_paused_before_next_day"
if operation == "check":
return "check_only"
raise ValueError(f"Неизвестная операция: {operation}")
def precheck_backfill(**context) -> None:
"""Проверяет чистоту стенда перед backfill."""
assert_live_generator_not_running()
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
assert_stand_clean(KAFKA_BOOTSTRAP_SERVERS, hook)
context["ti"].xcom_push(
key="generator_env",
value=_build_env(context, "backfill"),
)
def run_backfill_task(**context) -> None:
"""Выполняет backfill через код генератора."""
env = context["ti"].xcom_pull(task_ids="precheck_backfill", key="generator_env")
run_backfill(env)
def precheck_import(**context) -> None:
"""Проверяет чистоту стенда и совместимость артефакта перед import."""
assert_live_generator_not_running()
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
assert_stand_clean(KAFKA_BOOTSTRAP_SERVERS, hook)
env = _build_env(context, "import")
artifact_path = _artifact_path(context)
validate_import_artifact(env, artifact_path)
context["ti"].xcom_push(key="generator_env", value=env)
context["ti"].xcom_push(key="artifact_path", value=artifact_path)
def run_import_task(**context) -> None:
"""Выполняет import портативного артефакта."""
ti = context["ti"]
env = ti.xcom_pull(task_ids="precheck_import", key="generator_env")
artifact_path = ti.xcom_pull(task_ids="precheck_import", key="artifact_path")
run_import(env, artifact_path)
def precheck_next_day(**context) -> None:
"""Проверяет точку продолжения и готовит неизменные настройки мира."""
assert_live_generator_not_running_for_next_day()
manifest = load_manifest_from_kafka()
state = load_state_from_kafka()
assert_next_day_snapshot(manifest, state)
assert_expected_t_end(
str(_param(context, "expected_t_end") or "").strip(),
str(manifest["model_t_end"]),
)
context["ti"].xcom_push(
key="generator_env",
value=build_next_day_env(manifest),
)
def run_next_day_task(**context) -> None:
"""Генерирует следующий модельный день от проверенного state."""
env = context["ti"].xcom_pull(
task_ids="precheck_next_day",
key="generator_env",
)
run_next_day(env)
def check_manifest_task(**context) -> None:
"""Сверяет витрину ClickHouse с manifest стартовой истории."""
manifest = load_manifest_from_kafka()
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
assert_clickhouse_matches_manifest(manifest, hook)
@provide_session
def assert_target_dag_not_paused(dag_id: str, session=None) -> None:
"""
Проверяет, что зависимый DAG можно запустить.
Context7: для Airflow 2.10.5 у старого TriggerDagRunOperator нет надёжного
fail_when_dag_is_paused, поэтому паузу проверяем заранее через DagModel.
"""
dag_model = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none()
error = target_dag_trigger_error(dag_id, dag_model)
if error:
raise AirflowException(error)
# Context7, Airflow 2.10.5: Param поддерживает enum, а max_active_runs ограничивает
# число одновременных DAG run. Поэтому форму и блокировку next-day держим в DAG.
with DAG(
dag_id="generator_control",
description="Пульт стартовой истории генератора",
default_args=default_args,
schedule=None,
start_date=datetime(2024, 1, 1),
catchup=False,
max_active_runs=1,
is_paused_upon_creation=True,
tags=["generator", "startup-history"],
params={
"operation": Param(
"backfill",
type="string",
enum=["backfill", "import", "next-day", "check"],
title="Операция",
description=(
"Что сделать: создать историю, импортировать артефакт, "
"добавить следующий день или проверить витрины."
),
),
"profile": Param(
sorted(PROFILES)[0],
type="string",
enum=sorted(PROFILES),
title="Профиль",
description="Именованный набор настроек генератора.",
),
# "null" в type делает поля формы необязательными (Context7, Airflow 2.10.5).
"duration": Param(
None,
type=["null", "string"],
title="Длительность",
description="Например 6h или 2d. Пусто — взять длительность из профиля.",
),
"seed": Param(
None,
type=["null", "string"],
title="GEN_SEED",
description="Пусто — взять seed из профиля.",
),
"model_time_speed": Param(
None,
type=["null", "string"],
title="GEN_MODEL_TIME_SPEED",
description="Пусто — взять скорость модельного времени из профиля.",
),
"artifact_path": Param(
None,
type=["null", "string"],
title="Артефакт",
description=(
"Backfill: куда сохранить файл; пусто — не сохранять. "
"Import: что читать; пусто — путь по умолчанию в data."
),
),
"expected_t_end": Param(
None,
type=["null", "string"],
title="Ожидаемая граница next-day",
description=(
"Необязательный model_t_end до запуска. Защищает от "
"повторной доливки того же дня."
),
),
},
) as dag:
route = BranchPythonOperator(
task_id="choose_operation",
python_callable=choose_operation,
)
precheck_backfill_task = PythonOperator(
task_id="precheck_backfill",
python_callable=precheck_backfill,
)
backfill_task = PythonOperator(
task_id="run_backfill",
python_callable=run_backfill_task,
)
precheck_import_task = PythonOperator(
task_id="precheck_import",
python_callable=precheck_import,
)
import_task = PythonOperator(
task_id="run_import",
python_callable=run_import_task,
)
precheck_next_day_task = PythonOperator(
task_id="precheck_next_day",
python_callable=precheck_next_day,
)
next_day_task = PythonOperator(
task_id="run_next_day",
python_callable=run_next_day_task,
)
check_etl_not_paused_before_backfill = PythonOperator(
task_id="check_etl_not_paused_before_backfill",
python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID},
)
check_etl_not_paused_before_import = PythonOperator(
task_id="check_etl_not_paused_before_import",
python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID},
)
check_etl_not_paused_before_next_day = PythonOperator(
task_id="check_etl_not_paused_before_next_day",
python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID},
)
trigger_etl = TriggerDagRunOperator(
task_id="trigger_etl",
trigger_dag_id=ETL_DAG_ID,
conf={"full_refresh": True},
wait_for_completion=True,
allowed_states=["success"],
failed_states=["failed"],
poke_interval=30,
trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS,
)
check_after_etl = PythonOperator(
task_id="check_after_etl",
python_callable=check_manifest_task,
)
check_only = PythonOperator(
task_id="check_only",
python_callable=check_manifest_task,
)
done = EmptyOperator(
task_id="done",
trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS,
)
route >> [
check_etl_not_paused_before_backfill,
check_etl_not_paused_before_import,
check_etl_not_paused_before_next_day,
check_only,
]
check_etl_not_paused_before_backfill >> precheck_backfill_task
check_etl_not_paused_before_import >> precheck_import_task
check_etl_not_paused_before_next_day >> precheck_next_day_task
precheck_backfill_task >> backfill_task >> trigger_etl
precheck_import_task >> import_task >> trigger_etl
precheck_next_day_task >> next_day_task >> trigger_etl
trigger_etl >> check_after_etl >> done
check_only >> done
+4 -1
View File
@@ -6,5 +6,8 @@ psycopg2-binary==2.9.9
# ClickHouse operator/hook для DAG'ов # ClickHouse operator/hook для DAG'ов
airflow-clickhouse-plugin==1.6.0 airflow-clickhouse-plugin==1.6.0
# Kafka client для загрузки данных (DAG kafka_load) # Kafka client для загрузки данных и пульта генератора
kafka-python==2.0.6 kafka-python==2.0.6
# Метрики импортируются кодом генератора; HTTP-сервер в Airflow-задачах выключен
prometheus-client==0.21.1
File diff suppressed because it is too large. Load diff
+9
View File
@@ -28,3 +28,12 @@ scrape_configs:
labels: labels:
instance: Airflow-1 instance: Airflow-1
honor_labels: true honor_labels: true
# Generator metrics (custom events generator)
- job_name: "generator"
metrics_path: "/metrics"
static_configs:
- targets: ["generator:9109"]
labels:
instance: Generator-1
honor_labels: true
+56
View File
@@ -10,6 +10,7 @@ x-airflow-env: &airflow-default-env
AIRFLOW__METRICS__STATSD_HOST: "statsd-exporter" AIRFLOW__METRICS__STATSD_HOST: "statsd-exporter"
AIRFLOW__METRICS__STATSD_PORT: "8125" AIRFLOW__METRICS__STATSD_PORT: "8125"
AIRFLOW__METRICS__STATSD_PREFIX: "airflow" AIRFLOW__METRICS__STATSD_PREFIX: "airflow"
PYTHONPATH: /opt/airflow/generator_src
services: services:
@@ -115,6 +116,7 @@ services:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./generator/src:/opt/airflow/generator_src:ro
- ./sql:/opt/airflow/sql:ro - ./sql:/opt/airflow/sql:ro
- ./data:/opt/airflow/data - ./data:/opt/airflow/data
networks: networks:
@@ -140,6 +142,7 @@ services:
" "
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./generator/src:/opt/airflow/generator_src:ro
- ./sql:/opt/airflow/sql:ro - ./sql:/opt/airflow/sql:ro
- ./data:/opt/airflow/data - ./data:/opt/airflow/data
networks: networks:
@@ -162,6 +165,7 @@ services:
<<: *airflow-default-env <<: *airflow-default-env
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./generator/src:/opt/airflow/generator_src:ro
- ./sql:/opt/airflow/sql:ro - ./sql:/opt/airflow/sql:ro
- ./data:/opt/airflow/data - ./data:/opt/airflow/data
networks: networks:
@@ -284,6 +288,58 @@ services:
postgres-metadata: postgres-metadata:
condition: service_healthy condition: service_healthy
# Генератор событий (автономный стриминг в Kafka)
generator:
profiles:
- live-generator
build:
context: ./generator
dockerfile: Dockerfile
environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
GEN_TICK_SECONDS: ${GEN_TICK_SECONDS:-5}
GEN_LAMBDA_BASE_PER_MIN: ${GEN_LAMBDA_BASE_PER_MIN:-30}
GEN_JITTER_PCT: ${GEN_JITTER_PCT:-20}
GEN_MIN_EVENTS_PER_TICK: ${GEN_MIN_EVENTS_PER_TICK:-1}
GEN_MAX_EVENTS_PER_TICK: ${GEN_MAX_EVENTS_PER_TICK:-50}
GEN_MAX_SESSION_EVENTS: ${GEN_MAX_SESSION_EVENTS:-30}
GEN_MAX_ACTIVE_SESSIONS: ${GEN_MAX_ACTIVE_SESSIONS:-200}
GEN_POPULATION_MAX: ${GEN_POPULATION_MAX:-300}
GEN_P_NEW_USER: ${GEN_P_NEW_USER:-0.15}
GEN_MIN_RETURN_MINUTES: ${GEN_MIN_RETURN_MINUTES:-30}
GEN_MODEL_T0: ${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}
GEN_MODEL_T_END: ${GEN_MODEL_T_END:-}
GEN_MODEL_TIMEZONE: ${GEN_MODEL_TIMEZONE:-UTC}
GEN_MODEL_TIME_SPEED: ${GEN_MODEL_TIME_SPEED:-1}
GEN_RUN_MODE: ${GEN_RUN_MODE:-live}
GEN_LAUNCH_PROFILE: ${GEN_LAUNCH_PROFILE:-ci}
GEN_DATA_DIR: /data
GEN_SEED: ${GEN_SEED:-}
GEN_ENABLED: ${GEN_ENABLED:-true}
GEN_METRICS_PORT: ${GEN_METRICS_PORT:-9109}
# Сохранение состояния между рестартами
GEN_STATE_ENABLED: ${GEN_STATE_ENABLED:-true}
GEN_STATE_RESET: ${GEN_STATE_RESET:-false}
# PYTHONUNBUFFERED для сразу видеть логи
PYTHONUNBUFFERED: "1"
ports:
- "9109:9109" # Prometheus metrics endpoint
volumes:
- ./data:/data:ro
networks:
- cs_dwh
depends_on:
- kafka
restart: unless-stopped
# SIGTERM останавливает генератор на границе пакета; минута нужна на сброс буфера Kafka.
stop_grace_period: 1m
healthcheck:
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 3
# Kafka Exporter для мониторинга через Prometheus # Kafka Exporter для мониторинга через Prometheus
kafka-exporter: kafka-exporter:
image: danielqsj/kafka-exporter:v1.9.0 image: danielqsj/kafka-exporter:v1.9.0
+52 -20
View File
@@ -27,10 +27,14 @@
flowchart LR flowchart LR
subgraph AF["Airflow"] subgraph AF["Airflow"]
DAG1["ddl_init"] DAG1["ddl_init"]
DAG2["kafka_load"] DAG2["generator_control"]
DAG3["etl_pipeline"] DAG3["etl_pipeline"]
end end
subgraph GEN["Generator"]
G["generator-service"]
end
subgraph Kafka["Kafka"] subgraph Kafka["Kafka"]
K[4 топика] K[4 топика]
end end
@@ -53,7 +57,9 @@ flowchart LR
V[витрины VIEW] V[витрины VIEW]
end end
DAG2 -->|JSONL| K -->|MV| S DAG2 -->|startup-history backfill/import| K
G -->|live после make generator-continue| K
K -->|MV| S
S -->|batch| O S -->|batch| O
O -->|argMax + JOIN| D1 & D2 O -->|argMax + JOIN| D1 & D2
O -.->|ошибки| OE O -.->|ошибки| OE
@@ -63,16 +69,25 @@ flowchart LR
DAG3 -.->|batch| ODS & DDS DAG3 -.->|batch| ODS & DDS
``` ```
В учебном стенде предусмотрены два пути загрузки:
- `startup-history`: DAG `generator_control` создаёт или импортирует историю, добавляет следующий модельный день, запускает ETL и проверяет витрины;
- `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий.
### Слои и их назначение ### Слои и их назначение
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph AF["Airflow"] subgraph AF["Airflow"]
DAG1["ddl_init"] DAG1["ddl_init"]
DAG2["kafka_load"] DAG2["generator_control"]
DAG3["etl_pipeline"] DAG3["etl_pipeline"]
end end
subgraph GEN["Generator"]
G["generator-service"]
end
subgraph L1["STG"] subgraph L1["STG"]
KAFKA["Kafka Engine"] KAFKA["Kafka Engine"]
STG_T["*_raw таблицы"] STG_T["*_raw таблицы"]
@@ -91,7 +106,9 @@ flowchart LR
DM_T["VIEW"] DM_T["VIEW"]
end end
DAG2 -->|JSONL| KAFKA -->|MV| STG_T DAG2 -->|startup-history| KAFKA
G -->|live| KAFKA
KAFKA -->|MV| STG_T
STG_T -->|batch| ODS_T STG_T -->|batch| ODS_T
ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T
ODS_T -.->|ошибки| DQ ODS_T -.->|ошибки| DQ
@@ -347,6 +364,7 @@ sequenceDiagram
participant User as Пользователь participant User as Пользователь
participant Compose as Docker Compose participant Compose as Docker Compose
participant Airflow as Airflow participant Airflow as Airflow
participant Gen as generator-service
participant K as Kafka participant K as Kafka
participant CH as ClickHouse participant CH as ClickHouse
participant STG as stg.*_raw participant STG as stg.*_raw
@@ -368,14 +386,21 @@ sequenceDiagram
Airflow->>CH: sql/ddl/dm/40_dm.sql Airflow->>CH: sql/ddl/dm/40_dm.sql
CH-->>User: ✅ Структура БД создана CH-->>User: ✅ Структура БД создана
User->>Airflow: Trigger kafka_load alt Startup-history режим
Airflow->>K: precheck + prepare_topics User->>Airflow: Trigger generator_control (backfill/import)
loop 4 файла Airflow->>K: события стартовой истории
Airflow->>K: KafkaProducer.send(topic, json_line) Airflow->>Airflow: trigger etl_pipeline + check
K-->>User: ✅ История в Kafka и витринах
else Live режим
User->>Compose: make generator-continue
loop каждые 1-10 секунд
Gen->>K: send N_t (Poisson) в 4 топика
end end
K-->>User: ✅ Непрерывный поток в Kafka
end
K->>CH: Потребление сообщений K->>CH: Потребление сообщений
CH->>STG: INSERT через MV CH->>STG: INSERT через MV
K-->>User: ✅ Данные в Kafka
CH-->>User: ✅ Данные в STG CH-->>User: ✅ Данные в STG
User->>Airflow: Trigger etl_pipeline User->>Airflow: Trigger etl_pipeline
@@ -607,27 +632,34 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
### Airflow-оркестрация ### Airflow-оркестрация
Инфраструктура Airflow развёрнута и готова к использованию: Инфраструктура Airflow развёрнута и отвечает за DDL/ETL и стартовую историю.
Живой генератор контейнеров запускается отдельно через Makefile.
```python ```python
# airflow/dags/ddl_init_dag.py — создание баз/таблиц (ручной запуск при bootstrap) # airflow/dags/ddl_init_dag.py — создание баз/таблиц
# airflow/dags/kafka_load_dag.py — загрузка JSONL в Kafka (через kafka-python) # airflow/dags/generator_control_dag.py — backfill/import/next-day/check стартовой истории
# airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM) # airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM)
# airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур
# Учебный формат: # Учебный формат:
# - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator; # - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator;
# - SQL-файлы вызываются по фиксированным путям; # - SQL-файлы вызываются по фиксированным путям;
# - загрузка данных в Kafka выполняется через DAG `kafka_load`. # - загрузка может идти двумя путями:
# 1) startup-history через DAG `generator_control`;
# 2) live-поток через явный `make generator-continue`.
# #
# Основной demo-сценарий: # Базовый demo-сценарий:
# ddl_init -> kafka_load -> etl_pipeline # ddl_init -> generator_control(backfill/import) -> etl_pipeline -> check
# Расширенный учебный сценарий:
# make generator-continue + периодический etl_pipeline
``` ```
**DAG `kafka_load`**: **DAG `generator_control`**:
- Загрузка данных из `data/*.jsonl` в Kafka через `kafka-python` - `backfill`: создаёт стартовую историю через генератор
- TaskGroup `precheck`: проверка Kafka, файлов, параметров - `import`: импортирует портативный артефакт стартовой истории
- TaskGroup `ingest`: создание топиков → параллельная загрузка 4 потоков → проверка - `next-day`: пакетно добавляет следующий модельный день от текущего слепка мира
- Параметры: `limit` (0 = все), `reset_topics` - `check`: сверяет ClickHouse с manifest стартовой истории
- После `backfill` и `import` запускает `etl_pipeline` с `full_refresh`
**Подключение к ClickHouse:** **Подключение к ClickHouse:**
- Connection: `clickhouse_default` - Connection: `clickhouse_default`
+30 -42
View File
@@ -1,55 +1,43 @@
**Дашборд для e-commerce кликстрима** # DE-task: краткое ТЗ проекта
Представьте, что вы работаете в e-commerce проекте. Бизнес-юнит в процессе обсуждения, закупать ли вам кликстрим логов посещения некоторого портала другого магазина. Для первичного анализа вам предоставили данные, чтобы оценить применимость и выводы, которые можно сделать из них. Дата актуализации: 14 февраля 2026.
Вам нужно подготовить данные для первичного анализа и собрать несколько графиков в дашборд. Финальный результат вашей работы — развернутая инфраструктура и дашборд с графиками на которых "бизнес" может сделать выводы о данных. Вам дали полный карт-бланш на тему того, как эти данные можно обработать. ## Цель
## Вам нужно: Собрать учебный DE-стенд, который показывает полный путь данных:
`Kafka -> ClickHouse (STG/ODS/DDS/DM) -> BI`, с устойчивой обработкой "грязных" данных и понятной наблюдаемостью.
* спроектировать хранилище в /PostgreSQL/Clickhouse/ со слоями и объектами, которые вы посчитаете нужными ## Что нужно сделать
* написать все нужные расчёты и алгоритмы для создания витрин
* реализовать расчёт с помощью какого-то регулярного процесса
* сделать на основе этих данных дашборд который можно будет посмотреть в браузере
### Какие инструменты выбрать - Развернуть стек в `docker compose`: Kafka, ClickHouse, Airflow, Superset, Prometheus, Grafana.
- Организовать ingest в Kafka в двух режимах:
- `bootstrap`: загрузка JSONL через Airflow DAG `kafka_load`;
- `steady-stream`: автономный генератор, работающий независимо от потребителей.
- Построить слои хранилища `STG -> ODS -> DDS -> DM` в ClickHouse.
- Настроить регулярные трансформации в Airflow (`ddl_init`, `etl_pipeline`).
- Подготовить витрины и дашборд для первичного анализа.
Пара вариантов: ## Ключевые требования
* **Kafka + Clickhouse + Airflow + Superset/Power BI** - "Грязные" записи не должны валить пайплайн: ошибки фиксируются в ODS/DQ-слое.
* Postgresql + dagster + Grafana - Генератор работает отдельно от потребителей и Airflow-триггеров.
* dlt + duckdb + Airflow + [Panel](https://panel.holoviz.org/) - Решение должно быть воспроизводимым и пригодным для учебной демонстрации.
* Clickhouse + Airflow + Redis + [Streamlit](https://streamlit.io/)
* sqlitedb + скрипты питона + Flask + d3.js
Начните с маленького среза данных, чтобы ознакомиться, что за данные вам предоставляют: ## Границы MVP
* browser_events.jsonl — данные с информацией о просмотрах браузера - Один простой режим генератора (`steady-stream`) без сложных сценариев.
* device_events.jsonl — данные об устройствах, с которых пользователи пользовались сайтом - Без production-гарантий уровня exactly-once.
* geo_events.jsonl — данные о локации пользователя - Фокус на рабочем end-to-end контуре, а не на полной симуляции реального продакшена.
* location_events.jsonl — вопреки названию, это данные не о локации пользователя, а непосредственно о положении пользователя на сайте и информации о том, откуда пользователь попал на страницу
Смысл задачи на **своей ВМ** развернуть инструменты и загрузить данные в Kafka и из нее прогнать их в Clickhouse /другая DB по слоям. ## Критерии готовности
В Airflow взять данные из таблиц и преобразовать, потом выгрузить в Clickhouse / другая DB в агрегированном формате под дашборд. 1. Стенд стабильно запускается и работает несколько часов.
2. Данные корректно проходят путь Kafka -> STG -> ODS -> DDS -> DM.
3. Поток событий в Kafka идёт постепенно малыми порциями, без искусственного минутного burst.
4. Есть базовый дашборд и метрики для разбора работы стенда.
Дашборд не оценивается, но будет плюсом, если получиться собрать удобный визуал. ## Где детали реализации
Записать можно на видео и отправить контактному лицу или продемонстрировать на собеседовании - Техническая архитектура: `docs/ARCHITECTURE.md`
- План по генератору: `plans/generator_demo_stream_plan.md`
Можно использовать любые инструменты. - Эксплуатация и запуск: `docs/OPERATIONS.md`
Данные могут быть(скорее всего) с ошибками и компания которая их предоставляем заранее об этом сообщила т.к. они не хотят делиться 100% качественными данными полноценно с продакшена.
### ИНФРАСТРУКТУРА
* инфраструктура клевая и работает = 18 баллов
* инфраструктура работает, но есть баги = 12 баллов
* инфраструктура почти есть, но не совсем = 6 баллов
* инфраструктуры нет = 0 баллов
### ДАШБОРДЫ
* дашборды есть и удобные и работают = 12 баллов
* дашборды работают, но неудобные = 8 баллов
* дашборды почти работают = 4 балла
* дашбордов нет = 0 баллов
-114
View File
@@ -1,114 +0,0 @@
# Шпаргалка для 5-минутного демо проекта
Цель: за 5 минут показать работодателю рабочий end-to-end пайплайн и инженерный уровень исполнения.
---
## 0) Подготовка до звонка (1 раз)
```bash
make up
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
docker compose exec -T airflow-webserver airflow dags trigger kafka_load --conf '{"limit": 50, "reset_topics": true}'
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline --conf '{"full_refresh": true}'
```
Проверить доступы:
- Airflow: `http://localhost:8080` (`admin/admin`)
- Kafka UI: `http://localhost:8082`
- ClickHouse Play: `http://localhost:9123/play`
- Grafana: `http://localhost:3000`
- Superset: `http://localhost:8088`
---
## 1) Сценарий на 5 минут (тайминг + реплики)
### 0:00–0:30 — Контекст
Что открыть:
- README/схему архитектуры или короткий слайд.
Что сказать:
- «Это мини-DWH кликстрима: Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana.»
- «Поток: JSONL -> Kafka -> STG -> ODS -> DDS -> DM-витрины.»
- «Ключевая цель: быстрый повторяемый прогон и устойчивость к грязным данным.»
### 0:30–1:20 — Оркестрация в Airflow
Что открыть:
- Airflow UI, DAG-и `ddl_init`, `kafka_load`, `etl_pipeline`.
Что сказать:
- «`ddl_init` создаёт DDL, `kafka_load` грузит данные в Kafka, `etl_pipeline` считает слои.»
- «Запуск ручной, параметры прозрачные: `limit`, `reset_topics`, `full_refresh`.»
### 1:20–2:10 — Ingest через Kafka
Что открыть:
- Kafka UI (топики/сообщения), затем ClickHouse STG.
Что сказать:
- «Одна строка входа = одно сообщение Kafka.»
- «В STG храним сырой JSON без потери данных, типизация делается позже в ODS.»
### 2:10–3:20 — Проверка результата в ClickHouse
Что открыть:
- ClickHouse Play и выполнить запросы ниже.
Что сказать:
- «Показываю факт прохождения по слоям и готовые витрины для аналитики.»
```sql
SELECT count() AS rows FROM ods.browser_event;
SELECT count() AS rows FROM dds.event;
SELECT count() AS rows FROM dds.click;
SELECT * FROM dm.v_daily_traffic ORDER BY event_date DESC LIMIT 10;
SELECT * FROM dm.v_utm_effectiveness ORDER BY clicks DESC LIMIT 10;
```
### 3:20–4:10 — Качество данных и устойчивость
Что открыть:
- `dm.dq_summary` и/или `ods.*_errors`.
Что сказать:
- «Грязные записи не валят пайплайн: ошибки фиксируются в ODS и отражаются в DQ summary.»
- «Это важнее “идеально чистого” датасета, потому что поведение ближе к прод-среде.»
```sql
SELECT * FROM dm.dq_summary ORDER BY layer, table_name, check_name LIMIT 20;
```
### 4:10–5:00 — Мониторинг и BI
Что открыть:
- Grafana (overview-дашборд) и Superset (одна витрина/чарт).
Что сказать:
- «Есть observability: метрики по ClickHouse/Kafka/Airflow и алерты.»
- «Есть BI-слой: витрины готовы для первичного анализа без ручных выгрузок.»
---
## 2) План Б, если UI тормозит
Показать то же самое через CLI:
```bash
docker compose ps
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM dds.event"
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT * FROM dm.dq_summary LIMIT 10"
curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
```
Короткая реплика:
- «Даже без UI видно, что pipeline отработал, витрины заполнены, мониторинг жив.»
---
## 3) Финальная фраза (15 секунд)
- «Проект показывает полный цикл DE-задачи: инфраструктура, ingestion, слои данных, DQ, витрины и мониторинг.»
- «Если нужно, могу углубиться в любой блок: DAG, SQL-трансформации, модель данных или алерты.»
-182
View File
@@ -1,182 +0,0 @@
# Сценарий демо на 10-15 минут (код + архитектура)
Цель: дать студенту готовый сценарий, который показывает не только запуск стенда, но и инженерные решения в коде.
Формат: запись экрана + голос.
Ориентир по времени: 12 минут (допуск 10-15).
---
## 1) Подготовка перед записью (за 10-20 минут)
```bash
make up
docker compose ps
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
docker compose exec -T airflow-webserver airflow dags trigger kafka_load --conf '{"limit": 50, "reset_topics": true}'
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline --conf '{"full_refresh": true}'
```
Проверить, что открываются:
- Kafka UI: `http://localhost:8082`
- Grafana: `http://localhost:3000/d/clickhouse-overview/clickhouse-overview`
- Airflow: `http://localhost:8080/dags/ddl_init/grid?tab=details`
- Superset (если используете): `http://localhost:8088/login/?next=/`
- ClickHouse Play: `http://localhost:9123/play`
Подготовить вкладки заранее:
- `Makefile`
- `docker-compose.yml`
- `docs/ARCHITECTURE.md`
- `airflow/dags/ddl_init_dag.py`
- `airflow/dags/kafka_load_dag.py`
- `airflow/dags/etl_pipeline_dag.py`
- `sql/ddl/stg/10_stg.sql`
- `sql/ods/20_stg_to_ods.sql`
- `sql/dds/30_ods_to_dds.sql`
- `sql/dm/40_dds_to_dm.sql`
Опционально подготовить DBeaver (если хотите показывать не через Play):
- Host: `localhost`
- Port: `9123` (HTTP) или `8002` (native)
- User: `default`
- Password: `123456`
---
## 2) Поминутный план выступления
### 0:00-1:30 Инфраструктура и цель проекта
Что показывать:
- Терминал с `docker compose ps`
- `Makefile`
- `docker-compose.yml`
Что говорить:
- «Это учебный mini DWH для кликстрима: Kafka, ClickHouse, Airflow, Superset, Prometheus, Grafana.»
- «Инфраструктура поднимается одной командой `make up`; внутри это `docker compose up -d`.»
- «В `Makefile` также есть команды для остановки, очистки, перезагрузки мониторинга и recovery.»
- «Сервисная цель проекта: быстро и повторяемо показать end-to-end поток данных до витрин.»
Что подчеркнуть в коде:
- В `Makefile` показать цели `up/down/clean/reload-monitoring/recover-monitoring`.
- В `docker-compose.yml` бегло показать ключевые сервисы и порты.
### 1:30-3:30 Архитектура и логика выбора
Что показывать:
- `docs/ARCHITECTURE.md` (диаграммы потока, слои STG/ODS/DDS/DM).
Что говорить:
- «Управление сделано через 3 DAG: `ddl_init`, `kafka_load`, `etl_pipeline`.»
- «STG нужен для сырых событий как есть, чтобы сохранять воспроизводимость.»
- «ODS типизирует и валидирует данные, включая фиксацию ошибок парсинга.»
- «DDS собирает бизнес-сущности `event` и `click` для аналитики.»
- «DM отдает витрины и агрегаты для BI и интервью-демо.»
Объяснение решений:
- «Разделение на слои уменьшает связность и ускоряет диагностику проблем.»
- «Грязные данные не останавливают пайплайн: ошибки уходят в `ods.*_errors` и DQ-слой.»
### 3:30-6:30 Показ кода DAG-ов
Что показывать:
- `airflow/dags/ddl_init_dag.py`
- `airflow/dags/kafka_load_dag.py`
- `airflow/dags/etl_pipeline_dag.py`
Что говорить:
- «В `ddl_init` код разворачивает DDL в ClickHouse и подготавливает структуру слоев.»
- «В `kafka_load` есть управляемые параметры `limit` и `reset_topics` для быстрого smoke-прогона.»
- «В `etl_pipeline` выполняются шаги STG->ODS->DDS->DM с конфигурацией `full_refresh`; итоговый DM-блок здесь — загрузка `dm.dq_summary`.»
- «Логика запуска ручная: это удобно для демонстрации на собеседовании и для отладки.»
Что обязательно назвать:
- «Почему `limit=50` в демо: скорость и повторяемость важнее полноты.»
- «Почему DAG-и разделены: проще локализовать сбой и перезапустить только нужный этап.»
### 6:30-8:30 Показ SQL и модели данных
Что показывать:
- `sql/ddl/stg/10_stg.sql`
- `sql/ods/20_stg_to_ods.sql`
- `sql/dds/30_ods_to_dds.sql`
- `sql/dm/40_dds_to_dm.sql`
Что говорить:
- «В STG используется связка Kafka Engine + Materialized View + MergeTree таблицы.»
- «ODS делает типизацию, нормализацию и отправку проблемных строк в таблицы ошибок.»
- «DDS собирает сущности по ключам (`event_id`, `click_id`), чтобы упростить аналитику.»
- «Витрины DM строятся поверх DDS и готовы для BI.»
Короткий акцент на DQ:
- «Вместо падения на невалидном JSON сохраняем ошибку и продолжаем обработку потока.»
### 8:30-10:30 Прогон в Airflow + проверка результата
Что показывать:
- Airflow UI: последний `Success` у `ddl_init`, `kafka_load`, `etl_pipeline`
- ClickHouse Play или DBeaver
Что выполнять:
```sql
SELECT count() AS rows FROM stg.browser_raw;
SELECT count() AS rows FROM ods.browser_event;
SELECT count() AS rows FROM dds.event;
SELECT * FROM dm.v_daily_traffic ORDER BY event_date DESC LIMIT 10;
SELECT * FROM dm.dq_summary ORDER BY layer, table_name, check_name LIMIT 20;
```
Что говорить:
- «На экране видно прохождение данных по слоям и непустые витрины.»
- «DQ summary подтверждает контроль качества и обработку проблемных записей.»
### 10:30-12:00 Мониторинг и финал
Что показывать:
- Grafana: ClickHouse/Kafka/Airflow dashboards
- Prometheus targets (опционально)
- Superset dashboard (если подготовлен)
Что говорить:
- «Мониторинг показывает здоровье стенда и ключевые технические метрики.»
- «На BI-слое уже можно отвечать на базовые бизнес-вопросы по трафику и UTM.»
- «Итог: решение покрывает инфраструктуру, ingestion, трансформации, DQ, витрины и observability.»
---
## 3) План Б, если что-то сломалось на записи
Если не открывается UI:
```bash
docker compose ps
docker compose logs -f --tail=100 airflow-webserver
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM dds.event"
curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
```
Если Grafana пустая:
```bash
make reload-monitoring
```
Если мониторинг завис:
```bash
make recover-monitoring
```
Короткая реплика:
- «Даже при проблемах UI я показываю проверку через CLI и SQL, чтобы подтвердить работоспособность пайплайна.»
---
## 4) Готовый текст финала (20-30 секунд)
«Я реализовал end-to-end mini DWH для кликстрима: от инфраструктуры и ingestion до витрин и мониторинга.
Архитектура послойная STG-ODS-DDS-DM, orchestration через Airflow DAG-и, а невалидные данные фиксируются без падения пайплайна.
Если нужно, могу детально разобрать любой уровень: DAG-код, SQL-трансформации, DQ-проверки или наблюдаемость системы.»
+624 -24
View File
@@ -6,18 +6,33 @@
Базовые команды: Базовые команды:
- `make up` (или `docker compose up -d`) - `make up` (поднимает базовый стенд без Superset init; пересобирает Airflow-образ)
- `make down` (остановить и удалить контейнеры/сети проекта) - `make down` (остановить и удалить контейнеры/сети проекта)
- `make clean` (полная очистка: `down -v --remove-orphans`) - `make clean` (полная очистка: `down -v --remove-orphans`)
- `make generated-history-analytics` (штатный чистый прогон: стартовая история
генератора -> Kafka/STG -> ODS -> DDS -> DM -> Superset)
- `CHECK_LIVE_SEAM=0 make generated-history-check` (повторяемая проверка
ClickHouse и Superset после прогона только стартовой истории)
- `make generated-history-runtime-check` (короткая проверка стыка backfill/live)
- `make generated-history-chain-check` (проверка завершённых стыков между
порциями истории)
- `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse) - `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse)
- `make data` (пересоздаёт топики и заливает данные в Kafka; по умолчанию полный объём, срез — `LIMIT=50 make data`) - `make data` (архивный путь: заливает `data/*.jsonl` в Kafka; не основной источник аналитики)
- `make transform` (запускает batch-процесс ODS -> DDS -> DM) - `make transform` (запускает batch-процесс ODS -> DDS -> DM)
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд) - `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
- `make test` (быстрый предкоммитный набор: тесты генератора, верхние
контрактные тесты и проверка compose-конфигурации)
- `make lint` (статические проверки: синтаксис Python и Bash, compose-конфиг,
пробелы в diff)
- `docker compose ps` - `docker compose ps`
- `docker compose logs -f --tail=200 <service>` - `docker compose logs -f --tail=200 <service>`
- `docker compose down` (сохраняет named volumes, включая `clickhouse-data`) - `docker compose down` (сохраняет named volumes, включая `clickhouse-data`)
- `docker compose down -v` (удаляет named volumes, использовать осознанно) - `docker compose down -v` (удаляет named volumes, использовать осознанно)
`make test` и `make lint` не заменяют стендовые проверки, которые управляют
volumes или live-генератором. Для стыка backfill/live отдельно запускайте
`make generated-history-runtime-check`.
## Порты ## Порты
Порты задаются в `docker-compose.yml`: Порты задаются в `docker-compose.yml`:
@@ -33,6 +48,79 @@
## Airflow DAGs ## Airflow DAGs
Штатный ручной путь начинается с `generator_control`: чистый стенд получает
стартовую историю генератора, затем этот же DAG запускает ETL и проверку.
`kafka_load` остаётся для экспериментов и совместимости учебного стенда.
### `generator_control`
- Запуск: ручной (`Trigger DAG`).
- Назначение: пульт стартовой истории генератора.
- Операции:
- `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и
дождаться `success`;
- `import` — импортировать портативный артефакт, затем запустить
`etl_pipeline` и дождаться `success`;
- `next-day` — восстановить мир из state, добавить 24 модельных часа,
затем запустить `etl_pipeline` и дождаться `success`;
- `check` — сверить ClickHouse с manifest из Kafka.
- Параметры:
- `operation` (`backfill` / `import` / `next-day` / `check`);
- `profile` — список берётся из `PROFILES` генератора;
- `duration` — `6h`, `2d` и т.п.; пусто означает длительность профиля;
- `seed`, `model_time_speed` — необязательные переопределения мира;
- `artifact_path` — для `backfill` путь сохранения, для `import` путь чтения;
- `expected_t_end` — необязательная ожидаемая граница перед `next-day`.
При расхождении запуск показывает ожидаемое и фактическое значения.
Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые
`stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue`
в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через
`make generator-continue`.
`next-day` работает на непустом стенде и не использует проверку чистоты.
Перед записью пульт требует manifest, state ровно на его `T_end` и остановленный
live-генератор. Настройки мира берутся из manifest; поля `profile`, `duration`,
`seed` и `model_time_speed` формы для этой операции не применяются. Один запуск
добавляет полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница
появляется в `boundaries`; старый manifest без поля читается как `[T0, T_end]`.
Расписание остаётся выключенным (`schedule=None`), а `max_active_runs=1` не даёт
двум доливкам выполняться параллельно.
После двух доливок проверьте завершённые стыки:
```bash
make generated-history-chain-check
```
Проверка проходит по внутренним границам `boundaries`, ищет непарные строки и
смену browser/referer/utm внутри переходящих визитов. Она не меняет
`make generated-history-runtime-check` для стыка backfill/live.
Точка фиксации `next-day` — новый manifest. Порядок записи: data-топики, state,
manifest. Автоматического отката нет. Если запуск упал до публикации manifest,
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
последний исправный портативный артефакт, затем повторите `next-day`.
Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей
доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти
каждой доливки растут вместе с историей. Стандартный срок хранения Kafka тоже
ограничивает долгую работу стенда. Для ручного учебного цикла запускайте
соседние дни без долгих пауз. Перед включением расписания отдельная задача
должна выбрать одно из решений: бессрочное хранение data-топиков или новый
формат накопительного состояния manifest.
Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если
стоит, задача падает сразу с подсказкой снять паузу в UI или командой:
```bash
docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline
```
После обновления репозитория выполните `make up`, чтобы Airflow получил новые
зависимости и DAG-и через пересборку образа. Superset metadata собирается после
готового DM: через `make generated-history-analytics` или `make superset-init`.
### `ddl_init` ### `ddl_init`
- Запуск: ручной (`Trigger DAG`) - Запуск: ручной (`Trigger DAG`)
@@ -41,6 +129,7 @@
### `kafka_load` ### `kafka_load`
- Архивный путь, не основной источник аналитики.
- Запуск: ручной (`Trigger DAG with config`) - Запуск: ручной (`Trigger DAG with config`)
- Параметры: - Параметры:
- `limit` (`int`, default `0`) — количество строк (`0` = все) - `limit` (`int`, default `0`) — количество строк (`0` = все)
@@ -61,40 +150,550 @@
- Параметры: - Параметры:
- `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой - `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой
- `wait_stg_timeout_sec` (`int`, default `600`, minimum `30`) — сколько секунд задача `wait_for_stg_data` ждёт появления данных в STG, прежде чем упасть по таймауту - `wait_stg_timeout_sec` (`int`, default `600`, minimum `30`) — сколько секунд задача `wait_for_stg_data` ждёт появления данных в STG, прежде чем упасть по таймауту
- Зависимость: требует наличия данных в STG (от `kafka_load` или `make data`) - Зависимость: требует наличия данных в STG. В штатном сценарии STG наполняет
`make generated-history-analytics` через backfill генератора.
- Архивные `kafka_load` и `make data` оставлены только для ручных экспериментов
и старых проверок.
- Гейт целостности DDS: `check_dds_integrity` считает события без клика, а - Гейт целостности DDS: `check_dds_integrity` считает события без клика, а
`assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после `assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после
`load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи `load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи
`dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не `dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не
чинит уже собранную сироту и только задерживает явный failed-статус. чинит уже собранную сироту и только задерживает явный failed-статус.
## Рекомендуемый сценарий (фаза 2) ## Генератор событий (автономный стриминг)
Автономный сервис для непрерывной генерации событий в Kafka. Работает независимо
от Airflow DAGs. `make up` его не запускает: live включается только явной
командой.
### Управление
```bash ```bash
# 1. Запуск инфраструктуры # Продолжить live-поток из state
make up make generator-continue
# 2. Инициализация схемы (один раз) # Запустить генератор с текущими env напрямую
# Airflow UI -> DAGs -> ddl_init -> Trigger DAG make generator-up
# 3. Загрузка данных через Airflow # Остановить генератор
# Airflow UI -> DAGs -> kafka_load -> Trigger DAG with config make generator-down
# Параметры по умолчанию: limit=0, reset_topics=true
# 4. Запуск ETL # Перезапуск с пересборкой
# Airflow UI -> DAGs -> etl_pipeline -> Trigger DAG with config make generator-restart
# {"full_refresh": true}
# 5. Проверка результатов # Логи
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM ods.browser_event" make generator-logs
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM dds.event"
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT * FROM dm.dq_summary"
``` ```
### Конфигурация (env)
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
| `GEN_P_NEW_USER` | Доля визитов новых пользователей | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_T_END` | Правая граница стартовой истории для `backfill` | пусто |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим: `live`, `backfill` или `next-day` | `live` |
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` |
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто |
| `GEN_STATE_ENABLED` | Сохранять state v3 между рестартами | `true` |
| `GEN_STATE_RESET` | Сбросить state при старте | `false` |
В `docker-compose.yml` через окружение переопределяются демо-параметры модели и
режима, например:
```bash
GEN_STATE_RESET=true GEN_LAMBDA_BASE_PER_MIN=60 docker compose up -d generator
```
Контейнерные `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose оставлены
внутренними значениями `kafka:29092` и `/data`.
### Стартовая история через backfill
Штатная команда чистого прогона:
```bash
make generated-history-analytics
```
Она выполняет полный сброс volumes, поднимает ClickHouse и Kafka, применяет DDL,
выполняет глагол `backfill`, прогоняет batch STG -> ODS -> DDS -> DM,
инициализирует Superset и запускает техническую проверку. Для координатора или
CI короткая повторная проверка такого готового стенда:
```bash
CHECK_LIVE_SEAM=0 make generated-history-check
```
По умолчанию команда требует live-строки после `T_end` и проверяет стык
backfill/live. Чистый `make generated-history-analytics` отключает эту часть
явно, потому что внутри него live-продолжение не запускается. Для ручной
проверки только стартовой истории используйте:
```bash
CHECK_LIVE_SEAM=0 make generated-history-check
```
Для проверки другого live-окна:
```bash
CHECK_LIVE_SEAM=1 GEN_LIVE_CHECK_MINUTES=10 make generated-history-check
```
Для commit gate issue 17 есть короткий runtime-путь без полного `daily-wave` на
2 суток и без Superset UI:
```bash
make generated-history-runtime-check
```
По умолчанию он берёт профиль `daily-wave`, но сжимает историю до `1h` и до
25 секунд ждёт в STG визит с browser-событиями по обе стороны границы. Затем
он делает второй batch и проверяет стык с `CHECK_LIVE_SEAM=1`.
При остановке generator получает SIGTERM и завершает текущий пакет. Параметр
`stop_grace_period: 1m` даёт время дописать четыре топика, сбросить буферы и
записать историю пакета до принудительной остановки контейнера.
Если машина медленная, можно увеличить только ожидания:
```bash
LIVE_SECONDS=45 WAIT_STG_SECONDS=10 make generated-history-runtime-check
```
По умолчанию команда использует быстрый профиль `ci`: 6 часов модельного
времени. Историю на 2 суток с суточной волной можно получить одной командой.
В live-продолжении `daily-wave` идёт с ×60 и тикает раз в секунду, поэтому
модельные сутки проходят примерно за 24 настенные минуты:
```bash
PROFILE=daily-wave make generated-history-analytics
```
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
```bash
GEN_HISTORY_DURATION=2d make generated-history-analytics
```
`GEN_RUN_MODE=backfill` быстро проматывает модельное прошлое от `GEN_MODEL_T0`
до `GEN_MODEL_T_END` без сна. В Kafka попадают события только за полуоткрытый
отрезок `[T0, T_end)`. В compact-topic `generator_state` сохраняется state на
`T_end`, а в `generator_startup_history_manifest` — manifest с настройками и
контрольными числами. При live-запуске с теми же настройками генератор видит,
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.
Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day`
добавляет одну границу, сдвигает `model_t_end` и пересчитывает накопительные
счётчики и контрольные суммы по всей истории.
Если историю нужно сохранить в файл и восстановить на чистом стенде без новой
генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md).
Для чистого повтора пересоздавайте volumes. Это сбрасывает ClickHouse,
Kafka-топики данных, state и manifest генератора. `make generated-history-analytics`
делает это по умолчанию (`CLEAN_START=1`).
Ручной backfill без всего аналитического контура:
```bash
PROFILE=daily-wave make generator-backfill
```
Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые.
Если там уже есть данные, она останавливается с подсказкой `make clean`.
Старый способ через `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END`
остаётся низкоуровневым путём для отладки прямого `docker compose run`. В обычной
проверке используйте `make generated-history-analytics`, чтобы не забыть
Superset и итоговый check.
Manifest можно посмотреть так:
```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 \
--topic generator_startup_history_manifest \
--from-beginning \
--property print.key=true \
--timeout-ms 5000
```
Live-продолжение стартует с этого state. Используйте тот же профиль или ту же
длительность, что были у backfill. Команда сама выставит `GEN_STATE_RESET=false`.
Если state записан старой версией генератора, запуск теперь падает громко:
сначала очистите стенд через `make clean` и пересоздайте историю. Команда
`make generator-reset` подходит только для осознанного нового live-мира на
чистом стенде: она тоже проверяет, что Kafka data-топики и STG пустые.
```bash
PROFILE=daily-wave make generator-continue
sleep 5
make generator-down
sleep 10
bash scripts/run_batch.sh
```
Базовая сверка формы данных после backfill:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end
SELECT
uniqExact(user_domain_id) AS users,
uniqExact(click_id) AS visits,
count() AS events,
users < visits AND visits < events AS pyramid_ok,
min(event_ts) AS min_event_ts,
max(event_ts) AS max_event_ts,
min_event_ts >= t0 AND max_event_ts < t_end AS half_open_ok
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end;
```
Повторяемость чистого прогона удобно сверять коротким digest по ключевым полям
ClickHouse. Запускайте запрос после `bash scripts/run_batch.sh`; при одинаковых
`GEN_SEED`, `T0`, `T_end` и настройках значение должно повторяться.
```bash
docker compose exec -T clickhouse clickhouse-client \
--user=default \
--password=123456 \
--query "
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end
SELECT hex(sipHash128(groupArray(tuple(
event_id,
click_id,
user_domain_id,
event_ts,
page_url_path
)))) AS digest
FROM (
SELECT
event_id,
click_id,
user_domain_id,
event_ts,
page_url_path
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
ORDER BY event_id
)"
```
Возвраты пользователей:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
users AS (
SELECT user_domain_id, uniqExact(click_id) AS visits
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
AND user_domain_id IS NOT NULL
GROUP BY user_domain_id
)
SELECT
count() AS users,
countIf(visits > 1) AS returning_users,
returning_users / users AS returning_share
FROM users;
```
Форма длины визита: проверяем не только среднее, а долю коротких визитов,
медиану и долю визитов, срезанных потолком `GEN_MAX_SESSION_EVENTS`.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
30 AS max_session_events,
sessions AS (
SELECT
click_id,
count() AS events_count,
dateDiff('second', min(event_ts), max(event_ts)) AS duration_sec
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
count() AS visits,
countIf(events_count <= 2) / visits AS short_visit_share,
quantileExact(0.5)(events_count) AS median_events_per_visit,
avg(events_count) AS avg_events_per_visit,
countIf(events_count = max_session_events) / visits AS capped_visit_share,
quantileExact(0.5)(duration_sec) AS median_duration_sec
FROM sessions;
```
Воронка должна монотонно убывать, а доля дошедших до `/confirmation` должна быть
в согласованном коридоре для текущих настроек генератора. Для review gate
`confirmation_share` сравнивается по калибровочной форме «страница была в
визите». Строгий SQL ниже проверяет отдельное свойство: упорядоченный путь
`/home -> товары -> /cart -> /payment -> /confirmation`.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
sessions AS (
SELECT
click_id,
minIf(event_ts, page_url_path = '/home') AS home_ts,
minIf(event_ts, page_url_path IN ('/product_a', '/product_b')) AS product_ts,
minIf(event_ts, page_url_path = '/cart') AS cart_ts,
minIf(event_ts, page_url_path = '/payment') AS payment_ts,
minIf(event_ts, page_url_path = '/confirmation') AS confirmation_ts
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
countIf(home_ts IS NOT NULL) AS home,
countIf(home_ts IS NOT NULL AND product_ts > home_ts) AS products,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts) AS cart,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts) AS payment,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts AND confirmation_ts > payment_ts) AS confirmation,
products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
confirmation / home AS confirmation_share
FROM sessions;
```
Калибровочная форма воронки проверяет, что страница была в визите, без строгого
порядка событий. Именно эту форму используем для сравнения `confirmation_share`
в review gate.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
sessions AS (
SELECT
click_id,
countIf(page_url_path = '/home') > 0 AS has_home,
countIf(page_url_path IN ('/product_a', '/product_b')) > 0 AS has_product,
countIf(page_url_path = '/cart') > 0 AS has_cart,
countIf(page_url_path = '/payment') > 0 AS has_payment,
countIf(page_url_path = '/confirmation') > 0 AS has_confirmation
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
countIf(has_home) AS home,
countIf(has_home AND has_product) AS products,
countIf(has_home AND has_product AND has_cart) AS cart,
countIf(has_home AND has_product AND has_cart AND has_payment) AS payment,
countIf(has_home AND has_product AND has_cart AND has_payment AND has_confirmation) AS confirmation,
products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
confirmation / home AS confirmation_share
FROM sessions;
```
Стык backfill + live проверяется после короткого live-продолжения:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
toDateTime64('2026-01-01 06:10:00', 6) AS t_live_end
SELECT
count() AS events,
uniqExact(event_id) AS unique_events,
events - unique_events AS duplicate_events,
countIf(event_ts = t_end) AS boundary_events,
min(event_ts) AS min_event_ts,
max(event_ts) AS max_event_ts
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_live_end;
```
Однородность визитов, переходящих через `T_end`:
```sql
WITH
toDateTime64('2026-01-01 06:00:00', 6) AS t_end,
crossing AS (
SELECT
click_id,
min(event_ts) AS first_ts,
max(event_ts) AS last_ts,
groupUniqArray(user_domain_id) AS users,
groupUniqArray(device_type) AS devices,
groupUniqArray(os_name) AS os_names,
groupUniqArray(geo_country) AS countries
FROM dm.v_events_enriched
WHERE event_ts >= t_end - INTERVAL 30 MINUTE
AND event_ts < t_end + INTERVAL 30 MINUTE
GROUP BY click_id
HAVING first_ts < t_end AND last_ts >= t_end
)
SELECT
count() AS crossing_visits,
countIf(
length(users) = 1
AND length(devices) = 1
AND length(os_names) = 1
AND length(countries) = 1
) AS homogeneous_visits,
crossing_visits = homogeneous_visits AS context_ok
FROM crossing;
```
### Проверка модельного времени в ClickHouse
Для повторяемой проверки используйте чистый стенд и явный сброс состояния
генератора. `event_timestamp` в событиях — модельное время от `GEN_MODEL_T0`, а
не настенное время запуска процесса. При `GEN_MODEL_TIME_SPEED=K` один тик
покрывает `GEN_TICK_SECONDS * K` модельных секунд, и событийный бюджет считается
по этой модельной длительности.
```bash
make clean
docker compose up -d clickhouse kafka
make ddl
GEN_SEED=4242 \
GEN_MODEL_T0=2026-01-01T10:00:00+00:00 \
GEN_MODEL_TIMEZONE=UTC \
GEN_STATE_RESET=true \
GEN_TICK_SECONDS=60 \
GEN_LAMBDA_BASE_PER_MIN=60 \
GEN_JITTER_PCT=0 \
docker compose up -d --build generator
# Убедитесь по логам, что Tick 1 завершился, а Tick 2 ещё не стартовал.
sleep 9
docker compose logs --tail=40 generator
docker compose stop generator
sleep 5
bash scripts/run_batch.sh
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "
SELECT
count() AS events,
min(event_ts) AS min_event_ts,
max(event_ts) AS max_event_ts,
uniqExact(event_id) AS unique_events,
uniqExact(click_id) AS unique_clicks
FROM ods.browser_event
FORMAT Vertical"
```
Повторите блок с теми же значениями. При чистом стенде и `GEN_STATE_RESET=true`
контрольные числа должны совпасть, а `min_event_ts` должен начинаться от
`2026-01-01 10:00:00`.
### Топик истории
Генератор пишет историю батчей в топик `generator_batch_history` (JSON, ключ `batch_id`).
**Важно:** генератор требует работающей Kafka. Без Kafka генератор упадёт при старте или потеряет события.
```bash
# Чтение истории из Kafka
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 \
--topic generator_batch_history \
--from-beginning
```
### Метрики
Prometheus метрики доступны на `http://localhost:9109/metrics`:
- `generator_events_total` — счётчик отправленных событий
- `generator_publish_errors_total` — ошибки публикации
- `generator_tick_duration_seconds` — длительность тика
- `generator_last_success_timestamp` — время последнего успешного или частично
успешного тика
### Мониторинг через Grafana
**Dashboard URL:** `http://localhost:3000/d/generator-overview`
Дашборд "Generator Overview" автоматически загружается при старте Grafana и содержит:
| Раздел | Панели | Описание |
|--------|--------|----------|
| **Overview** | Events/min | Скорость генерации событий в минуту |
| | Tick Duration | Медиана и p99 длительности тика |
| | Last Successful Tick | Время последнего успешного тика |
| **Events by Topic** | Events per Hour (24h bar chart) | Распределение событий по часам и топикам |
| | Events Rate by Topic | График по 4 топикам (browser, location, device, geo) |
| | Total Events by Topic | Суммарные счётчики по каждому топику |
| **Errors** | Total Errors | Общее число ошибок публикации |
| | Error Rate | Скорость ошибок (err/min) |
| | Errors by Topic | Ошибки разбиты по топикам |
| **Tick Statistics** | Tick Duration Distribution | p50, p95, p99 длительности тиков |
| | Events per Tick | Среднее число событий на тик |
| | Hour Factor | Текущий временной множитель (0.7/1.0/1.2) |
| **Status** | Generator Status | Статус работы (enabled/disabled) |
| | Generator Health | Статус активности (heartbeat last tick), не проверяет state save |
| | Time Since Last Tick | Время с последнего тика |
| **Info** | Полезные команды и параметры конфигурации |
#### Troubleshooting генератора
**Нет данных на дашборде:**
1. Проверить, что генератор запущен: `docker compose ps generator`
2. Проверить метрики напрямую: `curl http://localhost:9109/metrics`
3. Проверить target в Prometheus: `http://localhost:9090/targets` (job: generator)
**Высокий error rate:**
- Проверить доступность Kafka: `docker compose ps kafka`
- Смотреть логи: `make generator-logs`
- Проверить consumer lag: дашборд Kafka Overview
**Длительные тики (p99 > 1s):**
- Проверить CPU/ресурсы контейнера
- Возможно, высокая нагрузка на Kafka — проверить дашборд Kafka
**Dashboard не загрузился:**
```bash
# Перезагрузить provisioning Grafana
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/dashboards/reload
# Или пересоздать контейнер
docker compose restart grafana
```
## Рекомендуемый сценарий
```bash
# Полный чистый путь: генерация -> STG -> ODS -> DDS -> DM -> Superset
make generated-history-analytics
# Повторная проверка данных стартовой истории без пересоздания
CHECK_LIVE_SEAM=0 make generated-history-check
# Проверка стыка backfill/live
make generated-history-runtime-check
```
Обычный `make generated-history-check` корректен только после
`make generator-continue` и повторного `make transform`, когда в витринах уже
есть строки после `T_end`.
## Быстрые проверки ## Быстрые проверки
- Kafka ingest: наличие данных в `stg.*` и типизированных строк в `ods.*`. - Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`.
- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `kafka_load`, `etl_pipeline`. - Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `generator_control`,
`kafka_load`, `etl_pipeline`; основной ручной пульт генератора — `generator_control`.
- BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате. - BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате.
--- ---
@@ -139,7 +738,7 @@ curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
- Airflow отправляет метрики в StatsD-формате на `statsd-exporter:8125` - Airflow отправляет метрики в StatsD-формате на `statsd-exporter:8125`
- Mapping конфигурация: `configs/statsd_mapping.yml` - Mapping конфигурация: `configs/statsd_mapping.yml`
- **Grafana provisioning** (`configs/grafana/provisioning/`): - **Grafana provisioning** (`configs/grafana/provisioning/`):
- Дашборды: ClickHouse Overview, Kafka Overview, Airflow Overview - Дашборды: ClickHouse Overview, Kafka Overview, Airflow Overview, Generator Overview
- Алерты: ClickHouse, Kafka, Airflow - Алерты: ClickHouse, Kafka, Airflow
### После `git pull`: быстрый апдейт мониторинга ### После `git pull`: быстрый апдейт мониторинга
@@ -310,6 +909,7 @@ curl -s -X POST -u admin:admin http://localhost:3000/api/admin/provisioning/aler
docker compose up -d clickhouse docker compose up -d clickhouse
docker compose up -d --force-recreate superset-init superset docker compose up -d --force-recreate superset-init superset
``` ```
- После `docker compose down -v` нужно повторно прогнать: `ddl_init` -> `kafka_load` -> `etl_pipeline`. - После `docker compose down -v` нужно повторно прогнать `make generated-history-analytics`.
- После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или отсутствуют до прогона ETL; после `ddl_init` -> `kafka_load` -> `etl_pipeline` выполнить `make superset-init`. - После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или
- Для демо по умолчанию использовать малый срез данных; полный прогон делать осознанно. отсутствуют до прогона стартовой истории; используйте `make generated-history-analytics`.
- Архивную загрузку `make data` использовать только для ручных экспериментов.
+23 -6
View File
@@ -4,16 +4,23 @@
## Исполняемые файлы ## Исполняемые файлы
### Airflow (основной путь запуска) ### Airflow (ручной и учебный путь запуска)
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse - `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
- `airflow/dags/kafka_load_dag.py` — загрузка в Kafka из JSONL - `airflow/dags/generator_control_dag.py` — Airflow-пульт стартовой истории: backfill/import/next-day/check
- `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM - `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka - `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
- `airflow/dags/utils/sql_helpers.py` — чтение и подготовка SQL-файлов для DAG - `airflow/dags/utils/sql_helpers.py` — чтение и подготовка SQL-файлов для DAG
- `airflow/dags/utils/airflow_params.py` — разбор и валидация параметров DAG - `airflow/dags/utils/airflow_params.py` — разбор и валидация параметров DAG
- `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin - `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin
### Generator
- `generator/src/clickstream_generator/` — исходники генератора кликстрима (модельное время, состояние мира, запись в Kafka)
- `generator/tests/` — тесты генератора
- `generator/Dockerfile` — образ generator-service
### SQL ### SQL
DDL (форма таблиц): DDL (форма таблиц):
@@ -35,17 +42,27 @@ DDL (форма таблиц):
- `superset/init_superset.py` — подключение к ClickHouse + создание датасетов - `superset/init_superset.py` — подключение к ClickHouse + создание датасетов
- `superset/create_dashboard.py` — сборка дашборда с чартами - `superset/create_dashboard.py` — сборка дашборда с чартами
### Скрипты (запасной путь, не основной) ### Скрипты
Shell-скрипты `scripts/*` (и обёртки `make ddl`/`make data`/`make transform`) — это локальный fallback в обход Airflow. Основной путь запуска — DAG-и Airflow (см. выше). Shell-скрипты `scripts/*` и Makefile-обёртки дают повторяемый локальный запуск.
Основной чистый путь аналитики — `make generated-history-analytics`.
- `scripts/apply_clickhouse_ddl.sh` — применение DDL - `scripts/apply_clickhouse_ddl.sh` — применение DDL
- `scripts/load_kafka_data.sh` — загрузка в Kafka - `scripts/load_kafka_data.sh` — архивная загрузка `data/*.jsonl` в Kafka
- `scripts/run_batch.sh` — batch-процесс - `scripts/run_batch.sh` — batch-процесс
- `scripts/run_generated_history_analytics.sh` — чистый прогон стартовой истории до DM и Superset
- `scripts/check_generated_analytics.sh` — проверка DM-витрин и Superset metadata на данных генерации
- `scripts/run_generator.sh` — запуск генератора через понятные глаголы (backfill/continue/reset) поверх старых env-переменных
- `scripts/assert_stand_clean.sh` — проверка, что новый мир генератора не смешается со старым
- `scripts/export_startup_history_artifact.sh` — экспорт стартовой истории в портативный JSON-артефакт
- `scripts/import_startup_history_artifact.sh` — импорт портативного артефакта стартовой истории в Kafka
- `scripts/check_startup_history_manifest.sh` — сверка DM-витрины ClickHouse с manifest портативной стартовой истории
- `scripts/check_generated_history_chain.sh` — проверка завершённых стыков между порциями модельной истории
- `scripts/run_generated_history_runtime_check.sh` — runtime-проверка, что daily-wave доходит до manifest/check, а live-продолжение пишет стык backfill/live
## Данные и конфиги ## Данные и конфиги
- `data/*.jsonl` — исходные данные (могут быть грязными) - `data/*.jsonl` — архивная фактура для генератора; не основной источник аналитики
- `configs/` — конфиги ClickHouse, Prometheus, Grafana - `configs/` — конфиги ClickHouse, Prometheus, Grafana
- `configs/prometheus.yml` — конфигурация Prometheus (scrape targets для ClickHouse, Kafka, Airflow) - `configs/prometheus.yml` — конфигурация Prometheus (scrape targets для ClickHouse, Kafka, Airflow)
- `configs/statsd_mapping.yml` — маппинг StatsD → Prometheus метрик для Airflow - `configs/statsd_mapping.yml` — маппинг StatsD → Prometheus метрик для Airflow
+39 -33
View File
@@ -9,20 +9,13 @@
### 1. Запуск инфраструктуры ### 1. Запуск инфраструктуры
```bash ```bash
# Запуск всех сервисов # Чистый прогон: стартовая история генератора -> DM -> Superset
make up make generated-history-analytics
# Применение DDL в ClickHouse
make ddl
# Загрузка данных в Kafka
make data
# Запуск ETL-пайплайна (ODS → DDS → DM)
make transform
``` ```
### 2. Инициализация Superset Команда очищает volumes, генерирует стартовую историю, прогоняет batch
STG -> ODS -> DDS -> DM и создаёт metadata Superset. Если данные уже
подготовлены и нужно только пересобрать Superset:
```bash ```bash
# Автоматическая инициализация (создание подключения и датасетов) # Автоматическая инициализация (создание подключения и датасетов)
@@ -32,7 +25,13 @@ make superset-init
make superset-dashboard make superset-dashboard
``` ```
### 3. Доступ к UI Повторный `make superset-dashboard` синхронизирует чарты этого учебного
дашборда с `CHARTS_CONFIG`: обновляет параметры, переименовывает старые имена и
может удалить лишний чарт-дубль. Удаление ограничено dashboard
`ecommerce-analytics`, поэтому одноимённые чарты менти в других dashboard не
трогаются.
### 2. Доступ к UI
Откройте в браузере: http://localhost:8088 Откройте в браузере: http://localhost:8088
@@ -63,17 +62,29 @@ make superset-dashboard
- **🎯 Conversion to /confirmation** — доля просмотров `/confirmation` от просмотров `/home` - **🎯 Conversion to /confirmation** — доля просмотров `/confirmation` от просмотров `/home`
KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки. KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки.
`Unique Sessions` не вынесен отдельной KPI-плиткой, потому что в демо-данных `Unique Sessions` не вынесен отдельной KPI-плиткой: в текущем дашборде важнее
`user_domain_id` и `click_id` идут 1:1 и дают то же число, что `Unique Users`. развести события, пользователей и среднюю глубину визита. Генератор создаёт
повторные визиты, поэтому `user_domain_id` и `click_id` уже не идут 1:1.
#### Динамика трафика #### Динамика трафика
- **📅 Events over Time** — линейный график событий с 5-минутными бакетами - **📅 Events over Time** — линейный график событий с 5-минутными бакетами
(все события стенда укладываются в ~50 минут, поэтому часовая гранулярность (быстрый проверочный профиль покрывает 6 часов модельного времени, поэтому
давала бы всего 2 точки и прямую линию) 5-минутные бакеты дают видимую динамику без лишнего шума)
- **📱 Traffic by Device** — pie chart распределения по устройствам - **📱 Traffic by Device** — pie chart распределения по устройствам
#### География #### География
- **🌍 Geography Map** — world map с распределением по странам - **🌍 Top Countries by Events** — top-15 стран по количеству событий
(`COUNT(*)`, единицы — события, штуки). Столбцы заменили прежнюю геовизуализацию:
на текущем разреженном распределении так видны страна, значение, порядок и
tooltip. Перекос стран приходит из гео-фактуры статического сида
`geo_by_click_id`; своя генерация гео описана как отдельный будущий шаг в
ADR-0006 и не лечится настройкой чарта.
> **Что проверили по Superset.** Через MCP Context7 проверили `/apache/superset`:
> прежний геоплагин описан как legacy-плагин, а ECharts bar chart имеет
> штатные параметры `show_legend`, `rich_tooltip`, подписи осей и формат чисел.
> Поэтому для разреженной географии выбран top-N bar chart
> (`viz_type: echarts_timeseries_bar`), а не донастройка прежней геовизуализации.
#### Маркетинг #### Маркетинг
- **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток - **🔗 UTM Effectiveness Table** — таблица эффективности UTM-меток
@@ -92,11 +103,9 @@ KPI разложены в одну строку по 12-колоночной с
> **Почему именно одно зерно, а не сумма по слою.** Чарт берёт по одной > **Почему именно одно зерно, а не сумма по слою.** Чарт берёт по одной
> канонической таблице на слой (`browser_raw → browser_event → event → > канонической таблице на слой (`browser_raw → browser_event → event →
> v_events_enriched`). Если суммировать `total_rows` по всем таблицам слоя, > v_events_enriched`). Если суммировать `total_rows` по всем таблицам слоя,
> в один столбец складываются таблицы разного зерна (события `1000` + визиты `99` > в один столбец складываются таблицы разного зерна: события, визиты и
> + пустые error-таблицы) и получается **ложная «воронка потерь»**, которой нет. > error-таблицы. Получается **ложная «воронка потерь»**, которой нет. На одном
> На одном зерне убывание становится настоящим: видимый шаг **1050 → 1000** — > зерне видно прохождение event-строк по слоям, а не сумму несравнимых таблиц.
> это дедупликация at-least-once потока по `event_id` в ODS
> (`ReplacingMergeTree`), а дальше число стабильно до витрины.
> >
> Настоящие сигналы качества (`rows_with_errors` в ODS, `orphan_events` в DDS) > Настоящие сигналы качества (`rows_with_errors` в ODS, `orphan_events` в DDS)
> на чистых демо-данных равны нулю и живут в `dm.dq_summary` отдельными > на чистых демо-данных равны нулю и живут в `dm.dq_summary` отдельными
@@ -110,13 +119,13 @@ KPI разложены в одну строку по 12-колоночной с
| Фильтр | Поле | Тип | Применение | | Фильтр | Поле | Тип | Применение |
|--------|------|-----|------------| |--------|------|-----|------------|
| 📅 Date Range | `event_date` | Time Range | Charts с `event_date`; по умолчанию `No filter`, чтобы демо-данные 2022 года не скрывались | | 📅 Date Range | `event_date` | Time Range | Charts с `event_date`; по умолчанию `No filter`, чтобы стартовая история не скрывалась фильтром даты |
| 🌍 Country | `geo_country` | Multi-select | Charts на `dm.v_events_enriched` | | 🌍 Country | `geo_country` | Multi-select | Charts на `dm.v_events_enriched` |
| 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` | | 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` |
| 🌐 Browser | `browser_name` | Multi-select | Charts на `dm.v_events_enriched` | | 🌐 Browser | `browser_name` | Multi-select | Charts на `dm.v_events_enriched` |
Фильтры работают через левую панель Superset. Click-to-filter между виджетами не включен: Фильтры работают через левую панель Superset. Click-to-filter между виджетами не включен:
клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. клик по сектору pie chart, столбцу Top Countries, строке таблицы или funnel не меняет остальные charts.
Фильтр применяется только к charts, где есть нужное поле. Агрегированные витрины Фильтр применяется только к charts, где есть нужное поле. Агрегированные витрины
`dm.v_utm_effectiveness` и `dm.v_top_pages_daily` содержат `event_date`, но не содержат `dm.v_utm_effectiveness` и `dm.v_top_pages_daily` содержат `event_date`, но не содержат
@@ -135,8 +144,10 @@ make clean # Остановка с удалением volumes
make logs service=superset # Логи сервиса make logs service=superset # Логи сервиса
# ETL # ETL
make generated-history-analytics # Чистый прогон генерации до Superset
CHECK_LIVE_SEAM=0 make generated-history-check # Проверка DM и Superset после backfill
make ddl # Применение DDL в ClickHouse make ddl # Применение DDL в ClickHouse
make data # Загрузка данных в Kafka make data # Архивная загрузка data/*.jsonl в Kafka
make transform # Запуск batch-процесса make transform # Запуск batch-процесса
# Superset # Superset
@@ -177,7 +188,7 @@ python /app/superset_init/init_superset.py
1. Перейдите в **Charts → + Chart** 1. Перейдите в **Charts → + Chart**
2. Выберите датасет (например, `dm.v_events_enriched`) 2. Выберите датасет (например, `dm.v_events_enriched`)
3. Настройте визуализацию: 3. Настройте визуализацию:
- **Viz Type:** Big Number / Line Chart / Pie Chart / World Map / Table - **Viz Type:** Big Number / Line Chart / Pie Chart / ECharts Bar / Table
- **Metrics:** COUNT(*), COUNT(DISTINCT ...) - **Metrics:** COUNT(*), COUNT(DISTINCT ...)
- **Dimensions:** группировки - **Dimensions:** группировки
- **Filters:** фильтры - **Filters:** фильтры
@@ -247,12 +258,7 @@ make superset-restart
# Полная переинициализация # Полная переинициализация
docker compose down -v docker compose down -v
docker compose up -d make generated-history-analytics
make ddl
make data
make transform
make superset-init
make superset-dashboard
``` ```
### Нет данных в чартах ### Нет данных в чартах
+22 -40
View File
@@ -8,15 +8,17 @@
Проверить, что стек `Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana`: Проверить, что стек `Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana`:
- стабильно поднимается; - стабильно поднимается;
- загружает данные по пути `kafka_load -> STG -> etl_pipeline -> ODS/DDS/DM`; - загружает данные по пути `startup-history/backfill -> Kafka -> STG -> ODS/DDS/DM`;
- корректно обрабатывает «грязные» записи (ошибки фиксируются в ODS, пайплайн не падает); - корректно обрабатывает «грязные» записи (ошибки фиксируются в ODS, пайплайн не падает);
- отдает метрики и дашборды мониторинга. - отдает метрики и дашборды мониторинга.
## Общие принципы ## Общие принципы
- По умолчанию используем малый срез (`limit=50`) для быстрых и повторяемых проверок. - По умолчанию используем быстрый профиль стартовой истории (`PROFILE=ci`).
- Полный прогон (`limit=0`) выполняем отдельно как long-run сценарий. - Полный прогон выполняем отдельно через `PROFILE=daily-wave`.
- Основной путь запуска — через Airflow DAG. - Основной ручной путь запуска — через Airflow DAG `generator_control`.
- Консольный чистый прогон `make generated-history-analytics` остаётся коротким
повторяемым сценарием для smoke и CI.
- Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга. - Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга.
--- ---
@@ -28,10 +30,10 @@
### A.1 Подготовка окружения ### A.1 Подготовка окружения
```bash ```bash
# Полная очистка стенда # Чистый быстрый прогон: backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset
make clean make generated-history-analytics
# Запуск инфраструктуры # Поднять остальные UI-сервисы после чистого прогона
make up make up
# Проверка контейнеров # Проверка контейнеров
@@ -42,16 +44,7 @@ docker compose ps
- `airflow-init` в `Exited (0)`; - `airflow-init` в `Exited (0)`;
- остальные сервисы в `Up` (включая `superset`, `prometheus`, `grafana`, `kafka-exporter`, `statsd-exporter`). - остальные сервисы в `Up` (включая `superset`, `prometheus`, `grafana`, `kafka-exporter`, `statsd-exporter`).
### A.2 DDL и минимальная загрузка данных ### A.2 Проверка стартовой истории в STG
```bash
# Инициализация схемы
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
# Быстрый ingest: по 50 строк на поток
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
--conf '{"limit": 50, "reset_topics": true}'
```
Проверки: Проверки:
@@ -72,12 +65,7 @@ SELECT 'geo_raw', count() FROM stg.geo_raw
Ожидаем: во всех 4 таблицах `cnt > 0`. Ожидаем: во всех 4 таблицах `cnt > 0`.
### A.3 ETL и проверки слоев ### A.3 Проверки слоев
```bash
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
--conf '{"full_refresh": true}'
```
Проверки: Проверки:
@@ -123,7 +111,7 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie
Критерий успеха smoke: Критерий успеха smoke:
- сервисы подняты; - сервисы подняты;
- 3 DAG (`ddl_init`, `kafka_load`, `etl_pipeline`) успешны; - стартовая история доведена до DM;
- данные проходят до DM; - данные проходят до DM;
- мониторинг и дашборды доступны. - мониторинг и дашборды доступны.
@@ -133,29 +121,22 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie
Ожидаемая длительность: ~30-60 минут. Ожидаемая длительность: ~30-60 минут.
### B.1 Полная загрузка и полный ETL ### B.1 Полная стартовая история и ETL
```bash ```bash
# Чистый старт # История на 2 суток с видимой суточной волной
make clean && make up PROFILE=daily-wave make generated-history-analytics
make up
# DDL
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
# Полный ingest (все строки)
docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
--conf '{"limit": 0, "reset_topics": true}'
# Полный ETL
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
--conf '{"full_refresh": true}'
``` ```
### B.2 Проверка объемов и DQ ### B.2 Проверка объемов и DQ
```bash ```bash
# Фактические размеры входа # Диапазон модельного времени в витрине
wc -l data/*.jsonl docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "
SELECT min(event_ts) AS min_event_ts, max(event_ts) AS max_event_ts, count() AS events
FROM dm.v_events_enriched
"
# Сводка по слоям # Сводка по слоям
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query " docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "
@@ -185,6 +166,7 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12
Критерии успеха full: Критерии успеха full:
- STG заполнен по всем 4 потокам; - STG заполнен по всем 4 потокам;
- ODS/DDS/DM заполнены; - ODS/DDS/DM заполнены;
- диапазон `event_ts` соответствует созданной стартовой истории;
- `dm.dq_summary` не пуста и содержит метрики всех слоев. - `dm.dq_summary` не пуста и содержит метрики всех слоев.
### B.3 Тест восстановления stop/start ### B.3 Тест восстановления stop/start
@@ -0,0 +1,86 @@
# ADR-0004: Steady-stream источник — синтетический иерархический генератор, не реплей
Принято: 2026-06-09
Статус: accepted
Связано: [`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md) (диагноз
дефекта), [`CONTEXT.md`](../../CONTEXT.md),
[ADR-0002](./0002-specs-as-durable-design-docs.md) (спеки как durable design-доки),
спека [`docs/specs/2026-06-09-generator-rework-hierarchical.md`](../specs/2026-06-09-generator-rework-hierarchical.md)
(форма доработки).
> Обновление (2026-06-14): тезис «генератор сосуществует с сидом, не заменяет
> его» частично пересмотрен в [ADR-0006](./0006-generation-as-sole-analytics-source.md)
> — для аналитического контура стенда генерация заменяет статический сид; сид
> выводится из оборота (целевым образом — полностью). Сосуществование остаётся
> верным лишь для переходной роли сида (палитра атрибутов / dev-фикстура).
## Решение
Режим `steady-stream` (живой поток на стенде) питается **синтетическим
генератором, переписанным с нуля по иерархической модели** «популяция
пользователей → сессии → события», а **не реплеем статического сида**. Модель
интенсивности текущего генератора (Poisson по тикам + дневной коэффициент +
jitter) сохраняется. Режим `bootstrap` (статический сид `data/*.jsonl`) и уроки
0–6 остаются прежними — генератор **сосуществует** с сидом, не заменяет его.
## Контекст
Проект сменил назначение на учебный стенд с курсом. Базовый курс стоит на
статическом сиде, и это осознанно: сид **честен внутри визита** (`click_id`
группирует несколько событий — настоящая воронка; по полному замеру 2026-06-10 —
1..27 событий, медиана 10, см. профиль сид-датасета в `CONTEXT.md`). Но у сида
два потолка, которые
сид принципиально не закрывает:
- **`users == sessions`** — каждый пользователь имеет ровно один `click_id` (1:1),
возвращающихся пользователей нет (см. `CONTEXT.md`).
- **одноразовость** — сид заливается батчем; не видно, как ClickHouse и витрины
ведут себя на непрерывном живом потоке.
Нужен режим, в котором стенд **живёт и движется** на правдоподобных данных
(воронка + жизнеподобные колебания интенсивности), при **скромном объёме** (у
менти может не быть мощного железа — на объём/стресс не закладываемся).
Двойная учебная ценность — ключевой критерий выбора: ценны не только *данные*
(живой стенд + честная пирамида `users < sessions < events`), но и **сам
генератор как объект изучения** — его генеративная модель достойна того, чтобы её
разбирать в курсе.
Текущая реализация генератора не дорабатывается инкрементально: у неё
концептуальный дефект модели сущностей (свежий `click_id` на каждое событие
схлопывает иерархию — см. `generator/KNOWN_ISSUES.md`), переписываем с нуля.
## Рассмотренные варианты
- **A — синтетический иерархический генератор (принято).** Популяция юзеров с
постоянным `user_domain_id` → 1..N сессий (`click_id` на сессию) → 1..M
упорядоченных по времени событий с правдоподобной воронкой. Единственный
вариант, дающий возвращающихся пользователей (`users < sessions < events`) и
учебную ценность самого моделирования. Цена — самый большой объём работы и
риск ошибиться в статистической модели.
- **C — реплей честного сида на часах.** Лить реальные записи сида в Kafka во
времени, переписывая `event_timestamp` в «сейчас», зацикливая пул и модулируя
rate. **Отклонено.** Дёшев и даёт гарантированно честную воронку почти без
риска, **но**: (1) обходит ровно ту генеративно-модельную часть, ради учебной
ценности которой всё и затевается; (2) наследует вырождение `users == sessions`
из сида — полную пирамиду не даёт никогда; (3) «бесконечность» = зацикленный
конечный пул.
- **B — минимальный «живой» генератор с грубой воронкой.** Тот же rewrite, но
воронка на фиксированных вероятностях, без глубины. Отклонено как
половинчатое: числа воронки менее убедительны, а вопрос возвратов всё равно
надо решать — то есть основной сложности не избегает.
## Последствия
- Режим `steady-stream` фиксируется как синтетическая генерация; направление
переоткрывать не нужно (типовой вопрос «почему не реплей?» закрыт здесь).
- `bootstrap`-сид и уроки 0–6 не трогаем; живой поток подаётся отдельным уроком 7.
- Контракт данных потока: здоровая пирамида `users < sessions < events`,
монотонное время внутри сессии, `click_id` переиспользуется внутри сессии.
Этот контракт наследуют будущие артефакты (спека доработки, урок 7, возможные
правки витрин).
- Детальная архитектура/требования — в спеке `2026-06-09-generator-rework-hierarchical.md`.
- **Статистическая модель** (распределения событий/сессия, сессий/пользователь,
межсессионные паузы; нужен ли настоящий session-timeout; персистентность
популяции через рестарты) **выносится в отдельную follow-up-спеку** и здесь
намеренно не фиксируется.
+111
View File
@@ -0,0 +1,111 @@
# ADR-0005: Модельные часы генератора, отвязанные от настенного времени
Принято: 2026-06-11
Статус: accepted
Связано: [ADR-0004](./0004-steady-stream-synthetic-generator.md) (расширяет —
steady-stream как синтетический генератор), [`CONTEXT.md`](../../CONTEXT.md)
(термины времени и «сида»), мат-спека
[`docs/specs/2026-06-10-generator-math-model.md`](../specs/2026-06-10-generator-math-model.md)
(разделы «Персистентность» и «Воспроизводимость» написаны от настенных часов —
будут реконсилированы отдельным заходом), задача
[`06-state-v2-and-restart`](../../.scratch/feature-data-generator/issues/06-state-v2-and-restart.md)
(state v2 и правило рестарта построены на настенных часах).
> Обновление (2026-06-14): направление «стартовая история стенда» запущено в
> [ADR-0006](./0006-generation-as-sole-analytics-source.md); реконсиляция
> разделов «Персистентность» и «Воспроизводимость» мат-спеки выполнена в
> [спеке модельного времени](../specs/2026-06-14-generator-model-time-and-startup-history.md).
## Решение
Модельное время генератора **расцеплено** от настенных часов (`now()`).
Генератор потребляет метку такта **извне**; «драйвер часов» задаёт отображение
модельного времени на реальное. Шов под это уже существует в коде (такт
принимает метку времени параметром).
Три режима — это **драйверы поверх одного шва**, а не отдельные архитектуры:
- **живой ×1** — модельное время идёт со скоростью настенных часов (текущее
поведение);
- **живой ускоренный ×K** — модельное время идёт в `K` раз быстрее реального
(`K` — конфигурация, дефолт ×1);
- **заливка ×∞** — модельное время гонится без сна от стартовой точки до
целевой; частный случай `K → ∞`.
Живой стенд **масштабируется ×K, дефолт ×1**. Ускорение — учебная «ручка»
урока 7: разогнать стенд и вживую увидеть медленные явления (возвраты,
межсессионные паузы, сдвиг воронки после правки марковской таблицы переходов).
Метки событий пишутся по **модельному** времени. При ускорении/заливке «сейчас»
стенда расходится с настенными часами — для аналитического стенда (ключ —
`event_timestamp`, внеочередная вставка в ClickHouse нормальна) это **безвредно**;
фиксируем как явное свойство, чтобы «время стенда ≠ твои настенные часы» не
читалось как баг.
Имена параметров, формат конфигурации и алгоритм драйвера — за исполнителем; здесь
не фиксируются.
## Контекст
Текущая модель неявно держит инвариант **«время генератора ≡ реальное время ×1»**:
главный цикл спит до настенного такта, метки событий = `now()` + смещения, а
паузы внутри визита «проживаются» в реальном времени. Для учебного плана это
неудобно по двум причинам:
- **Медленные явления не успеть показать.** Возвраты (~2 ч межсессионной паузы),
накопление воронки, удержание разворачиваются часами и днями — в рамках урока
на ×1 их не увидеть.
- **Историческую глубину живой генератор не создаёт вовсе.** За реальную минуту
рождается ~λ событий реального времени; глубину сейчас даёт только статический
сид.
Дополнительно: воспроизводимость на ×1 завязана на настенный час (дневной
коэффициент, метки) — запуск в другой час даёт другой поток. Расцепление с
**фиксированной стартовой модельной точкой** эту зависимость снимает.
Связь с задачей 06: персистентность (state v2 + рестарт) уже построена на
настенных часах. Расцепление меняет смысл «правила 30 минут» — см. «Последствия».
Эта ADR — **отдельный слой поверх 06**; она не правит то, что задачи 06/07 уже
зафиксировали (их доводила автономная петля субагентов на реал-тайм-модели),
реконсиляция идёт отдельным заходом.
## Рассмотренные варианты
- **Оставить ×1 (статус-кво).** Отклонено: не решает учебное неудобство;
историческую глубину даёт только сид, медленные явления вживую не показать.
- **Живой всегда ×1, «быстро» только как разовая заливка прошлого.**
Жизнеспособно, но слабее: урок 7 не может вживую ускорить наблюдение, и это два
разных механизма (живой стенд и батч-заливка) вместо одного.
- **Живой масштабируется ×K, дефолт ×1; заливка = `K → ∞` (принято).** Один
механизм поглощает все режимы; ускорение прямо служит уроку 7; дефолт ×1
сохраняет «дыхание» настоящего сайта; пре-заливка прошлого тоже доступна
(бакаешь на огромном `K`, живёшь на умеренном). Цена — метки уходят вперёд
настенных часов при разгоне (безвредно для аналитики).
## Последствия
- Режимы живой / ускоренный / заливка — конфигурация одного драйвера часов;
реализация и имена параметров — за исполнителем.
- **Правило 30 минут (задача 06) переопределяется в модельном времени.** Формула
не меняется: разрыв между модельной меткой следующего события визита и
**модельным временем возобновления** больше 30 минут → визит закрывается;
меньше → продолжается. Меняется **origin возобновления** — он становится
параметром:
- **восстановление после краха** — origin = настенное `now()` (время реально
прошло) → поведение как сегодня;
- **продолжение из заморозки/сида** — origin = метка снимка → разрыв ≈ 0, мир
продолжается непрерывно, активные визиты не закрываются.
Текущий код 06 берёт `now()` жёстко: корректно для краха, требует
параметризации для продолжения.
- **Будущее направление (ещё не строим): «стартовая история стенда».** Заливка
`K → ∞` + заморозка state v2 даёт сгенерированное прошлое, с которого живой
стенд стартует **непрерывно** (те же люди возвращаются — `users < sessions`
рождается вживую, без костыля статического сида `users == sessions`). При этом
роль статического сида со временем смещается с «учебный датасет» на
«dev-фикстура и калибровочный эталон» (термины — в `CONTEXT.md`). **На время
разработки** сид и поток **сосуществуют** (как зафиксировано в ADR-0004);
продолжение-из-сида — отдельная будущая задача после готовности генератора.
- Воспроизводимость улучшается при фиксированной стартовой модельной точке;
реконсиляция разделов «Персистентность» и «Воспроизводимость» мат-спеки —
отдельный заход после ревью эксперимента с автономной петлёй.
@@ -0,0 +1,112 @@
# ADR-0006: Единственный источник аналитики — генерация; статический сид становится архивным
Принято: 2026-06-14
Статус: accepted
Связано: [ADR-0004](./0004-steady-stream-synthetic-generator.md) (**частично
пересматривает** — тезис о сосуществовании сида и потока),
[ADR-0005](./0005-generator-model-clock.md) (**запускает** направление
«стартовая история стенда»), мат-спека
[`docs/specs/2026-06-10-generator-math-model.md`](../specs/2026-06-10-generator-math-model.md)
(модель распределений), спека механизма
[`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`](../specs/2026-06-14-generator-model-time-and-startup-history.md)
(как это устроено), [`CONTEXT.md`](../../CONTEXT.md) (три значения слова «сид»),
[`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md).
## Решение
Аналитический контур стенда (Kafka → STG→ODS→DDS→DM → Superset) питается **только
генерацией**: стартовой историей (готовое сгенерированное прошлое) при создании
стенда и живым потоком далее. Статический сид `data/*.jsonl` **выводится из этого
процесса и становится архивным** — он больше не грузится в Kafka и не строит
витрины. Перевод процесса на новый, сгенерированный сид — **цель этой работы**, а
не отложенный шаг.
У старого сида остаётся одна временная роль — **кладовка готовых значений** для
генератора (браузеры, страны, устройства, метки кампаний): генератор берёт оттуда
«фактуру», чтобы одевать ею события, и так же делает сам новый сид. Поэтому файл
пока остаётся в репозитории, хотя из рабочего процесса уже вышел.
- **Что делаем в этой работе:** новый сид становится источником аналитики; процесс
(загрузка, витрины, дашборды, уроки) перестраивается на него; старый сид выходит
из процесса в архив.
- **Что остаётся на отдельный шаг:** научить генератор придумывать фактуру
самостоятельно. После этого старый файл можно удалить совсем — последняя
зависимость от него исчезнет.
- **Целевое состояние:** самодостаточный генератор, старого сида в репозитории нет.
Это решение перекрывает тезис ADR-0004 «генератор сосуществует с сидом, не
заменяет его»: для аналитики генерация сид заменяет.
## Контекст
ADR-0004 (2026-06-09) зафиксировал: «`bootstrap`-сид и уроки 0–6 не трогаем,
генератор сосуществует с сидом». На старте переработки это было верно. Две вещи
изменили посылку: генератор теперь даёт здоровую пирамиду `users < sessions <
events`, а ADR-0005 ввёл модельное время — с ним стартовая история (готовое
сгенерированное прошлое) становится несущей: именно она даёт стенду историческую
глубину с первой минуты.
Почему старый сид перестаёт быть источником:
- **Вырождение `users == sessions`** — у каждого пользователя ровно один визит;
это ровно то, от чего уходим (см. `CONTEXT.md`).
- **Неизвестное происхождение и качество** — это срез чужого учебного задания
(видно, что данные сделаны библиотекой-заполнителем: `dummywebsite.com`,
выдуманные почты, случайные локали). Считать его авторитетным образцом оснований
нет.
- **Одноразовость** — заливается одной пачкой, не показывает живой стенд во
времени.
Скрытая зависимость, найденная при разборе кода: генератор **одевает события
значениями из сида**. Профиль пользователя хранится ссылкой на сид-сессию
(мат-спека, §«Профиль пользователя»), а `generate_batch` копирует поля сид-строк,
переопределяя лишь идентификаторы, время и путь страниц. Поэтому старый сид нельзя
убрать одним движением: сначала генератору нужна своя «фактура». Отсюда разделение
на два шага.
Побочный факт: путь для «грязных» записей (`ods.*_errors`) сид **не наполняет** —
проверка показала, что все 1000 записей во всех четырёх файлах чисты, ни одна не
попадает в ошибки. Значит вывод сида из процесса не вредит уроку про грязные
данные; наоборот, генератор, умеющий **намеренно** подсыпать брак, научит этому
лучше идеально чистого среза.
## Рассмотренные варианты
- **A — сид остаётся источником, генерация сосуществует (текущее положение по
ADR-0004).** Отклонено: тащит вырождение `users == sessions` в аналитику и
держит два источника вместо одного.
- **B — генерация заменяет источник, но сид остаётся жёстким образцом для сверки**
(генератор обязан число-в-число повторять профиль сида). Отклонено как
направление: образцовость сида сомнительна, а подгонять генерацию под
вырожденный профиль — шаг назад от того, ради чего всё затевалось.
- **C — генерация единственный источник; старый сид становится архивным
(принято).** Свои намеренно спроектированные распределения вместо
унаследованных; своя «фактура» генератора — отдельным шагом, до него сид
временно остаётся кладовкой значений. Цена — переход в два шага и временно
сохранённая зависимость от сида.
## Последствия
- Аналитический контур переключается на генерацию; **стартовая история становится
несущей** — без неё свежий стенд стартует с пустым прошлым. Это связывает
решение со спекой механизма (модельное время + стартовая история).
- **Порядок шагов важен.** Старый сид выходит из процесса не раньше, чем
(1) готова стартовая история и (2) процесс — загрузка, витрины, дашборды,
уроки — переведён на новый сид. Удалить файл совсем можно только третьим шагом —
после того как генератор научится своей фактуре. Вынуть сид раньше — оставить
стенд без данных.
- **ADR-0004 частично пересмотрен**: «генератор сосуществует с сидом, не заменяет»
остаётся верным лишь для временной роли сида как кладовки значений; для аналитики
генерация сид заменяет. Ссылка вперёд добавлена в ADR-0004.
- **Глоссарий `CONTEXT.md`** (раздел «три значения слова сид») надо выровнять:
статический сид → архивная кладовка значений с целью полного вывода. Обновляется
отдельным шагом.
- **Перестройка процесса** — перевод загрузки (`kafka_load_dag`), витрин/Superset
и уроков на новый сид — входит в эту работу как её цель; детальный план этой
перестройки — отдельная спека на этапе реализации.
- **Полное удаление `data/*.jsonl`** — только после того, как генератор научится
своей фактуре (отдельная спека).
- **Учебная ценность растёт**: со своей фактурой проектируем интересные
распределения (перекос по странам, набор устройств, привязка кампаний, намеренный
брак) вместо случайного набора из чужого сида — но это уже шаг про фактуру, не
этот.
+2
View File
@@ -11,6 +11,8 @@
понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`) понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`)
- Задачи реализации — `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, нумерация с `01` - Задачи реализации — `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, нумерация с `01`
- Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`) - Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`)
- Завершённая задача — `Status: done`: локальный аналог «закрыть issue» в GitHub.
Это состояние жизненного цикла трекера, а не роль триажа (в `triage-labels.md` его нет)
- Комментарии и история — в конец файла под заголовком `## Comments` - Комментарии и история — в конец файла под заголовком `## Comments`
## Когда скилл говорит «опубликовать в issue tracker» ## Когда скилл говорит «опубликовать в issue tracker»
+4 -4
View File
@@ -21,7 +21,7 @@
запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name`
и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет.
**Верни как было** — каждая правка завершается явным шагом отката к чистому **Верни как было** — каждая правка завершается явным шагом отката к чистому
состоянию (откатить изменение либо `make clean/up/ddl/data/transform`), чтобы состоянию (откатить изменение либо `make generated-history-analytics && make up`), чтобы
самостоятельный менти не застрял со сломанным стендом без ментора. самостоятельный менти не застрял со сломанным стендом без ментора.
(Урок 0 — без этого шага, только наблюдение.) (Урок 0 — без этого шага, только наблюдение.)
5. **Проверь себя** — самопроверка (раздел 4). 5. **Проверь себя** — самопроверка (раздел 4).
@@ -112,7 +112,7 @@
- штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; - штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`;
- встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или - встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или
нарушении целостности); нарушении целостности);
- `make clean/up/ddl/data/transform` для сброса и повтора. - `make generated-history-analytics && make up` для чистого сброса и повтора штатного пути.
В каждом уроке — маленькая табличка самопроверки в формате В каждом уроке — маленькая табличка самопроверки в формате
**действие → где смотреть → что ожидать**, а для управляемой правки — какой **действие → где смотреть → что ожидать**, а для управляемой правки — какой
@@ -125,8 +125,8 @@
- **Проверяй на стенде, а не «на глаз».** Любой категоричный claim про числа или целостность - **Проверяй на стенде, а не «на глаз».** Любой категоричный claim про числа или целостность
подтверждай командой на стенде. Так нашёлся баг с `kafka_ts` в уроке 1 (значение типа подтверждай командой на стенде. Так нашёлся баг с `kafka_ts` в уроке 1 (значение типа
`DateTime64` молча резалось через `toInt64`, и время по всему стенду уехало в `1970`) и `DateTime64` молча резалось через `toInt64`, и время по всему стенду уехало в `1970`) и
подтвердилось, что «26 из 50» в уроке 2 — это схлопывание повторов по `click_id` (проверено подтвердилось, что меньший размер таблиц контекста в уроке 2 — это схлопывание повторов
`uniqExact`), а не потеря данных. по `click_id` (проверяется через `uniqExact`), а не потеря данных.
- **Имена таблиц и колонок в тексте сверяй с реальным DDL.** То, что написано в уроке, должно - **Имена таблиц и колонок в тексте сверяй с реальным DDL.** То, что написано в уроке, должно
совпадать с `sql/ddl/...`, иначе менти запутается, когда выполнит запрос на стенде. совпадать с `sql/ddl/...`, иначе менти запутается, когда выполнит запрос на стенде.
- **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к - **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к
+58 -3
View File
@@ -9,6 +9,13 @@
> Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не > Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не
> «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор > «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор
> разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5. > разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5.
> Поправка 2026-07-04 (целеполагание): §1 расширен — зафиксировано место стенда в
> экосистеме менторской программы (теория и лабы по ClickHouse живут в других местах,
> здесь — интеграции «как в жизни») и иерархия целей вплоть до генератора. Демо-материалы
> (шпаргалки 5 и 10–15 минут) устарели и удалены: демо выросло в отдельный проект и целью
> стенда больше не является. Схема потока в §1 обновлена под генератор (источник данных
> сменился, см. ADR-0006). В §7 закрыта развилка про урок о генераторе и добавлена
> развилка про кластерную конфигурацию.
> Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем > Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем
> он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план > он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план
> реализации и не стандарт уроков (см. раздел «Связанные документы»). > реализации и не стандарт уроков (см. раздел «Связанные документы»).
@@ -23,7 +30,7 @@
Репозиторий — рабочий сквозной стенд кликстрим-DWH: Репозиторий — рабочий сквозной стенд кликстрим-DWH:
``` ```
data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset generator (backfill/live) → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset
↘ Prometheus / Grafana (мониторинг) ↘ Prometheus / Grafana (мониторинг)
``` ```
@@ -32,6 +39,33 @@ data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ET
программу обучения — это **продвинутый курс «со звёздочкой»** для менти, уже программу обучения — это **продвинутый курс «со звёздочкой»** для менти, уже
прошедших базу (SQL, моделирование, Python, Git, Docker, Airflow). прошедших базу (SQL, моделирование, Python, Git, Docker, Airflow).
### Место стенда в менторской программе (2026-07-04)
Трек ClickHouse для продвинутых менти состоит из трёх частей, и у каждой своя роль:
- **Теория** — внешние курсы (бесплатный курс Яндекса или запись курса Отус).
Здесь теорию не пересказываем.
- **Лабы по самому ClickHouse** — отдельный стенд
[clickhouse-learning-cluster](https://github.com/dementev-dev/clickhouse-learning-cluster):
полноценный кластер, но без внешних интеграций.
- **Этот стенд** — то, чего нет в первых двух: ClickHouse в окружении «как в жизни».
Интеграции (Kafka, Airflow), мониторинг и BI поверх. Ниша стенда — не сам
ClickHouse, а инженерия данных вокруг него.
Иерархия целей внутри репозитория:
1. **Курс — главная цель.** Всё остальное — средства.
2. **Стенд — носитель курса.** Должен подниматься одной командой, давать
правдоподобные данные (пирамида «пользователи < визиты < события», суточная
волна, возвраты) и быстро воспроизводиться.
3. **Генератор — скрытая инфраструктура стенда.** Его задача — живые данные:
стартовая история плюс живое продолжение. Учебным предметом не является
(решение — §7); пользовательская граница — «как пользоваться», не «как устроен».
Демо-употребление стенда (шпаргалки на 5 и 10–15 минут) **устарело**: демо выросло
в отдельный проект и целью этого репозитория больше не является (2026-07-04,
материалы удалены).
Цель курса — превратить стенд в **самостоятельный учебный материал**, по которому Цель курса — превратить стенд в **самостоятельный учебный материал**, по которому
продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычном продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычном
еженедельном созвоне (что получилось / что нет / вопросы / план на неделю). Долгий еженедельном созвоне (что получилось / что нет / вопросы / план на неделю). Долгий
@@ -61,8 +95,9 @@ Kafka → ClickHouse → BI).
- **Аудитория:** продвинутые менти, прошедшие базовую программу. Пишем обобщённо, - **Аудитория:** продвинутые менти, прошедшие базовую программу. Пишем обобщённо,
но затачиваем под реальный первый прогон, а не под гипотетических будущих менти. но затачиваем под реальный первый прогон, а не под гипотетических будущих менти.
- **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, поднимает - **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, готовит
стенд у себя (`make up`) и идёт по урокам из `docs/course/` рядом с кодом. стенд штатным путём (`make generated-history-analytics && make up`) и идёт по
урокам из `docs/course/` рядом с кодом.
Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**. Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**.
- **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без - **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без
построчного разбора кода. построчного разбора кода.
@@ -139,8 +174,28 @@ Kafka → ClickHouse → BI).
синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не
блокирует). блокирует).
Закрыто позже:
- ~~Генератор как инфраструктура против отдельного урока (открыто, 2026-06-14).~~
**Решено (2026-07-04): отдельного урока про генератор не будет — генератор
остаётся скрытой инфраструктурой.** Причина: устройство генератора (марковская
модель, нетривиальный Python) — это разработка бэкенда и математика, а не
инженерия данных; такой урок не попадает в цели курса, и менти его не ожидает.
Существующие уроки адаптируем без ввода марковских цепей и сложного Python в
путь менти (задача — `.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md`).
Удобство стенда (одна команда + короткий runbook) остаётся отдельной задачей
(`.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md`)
и делает адаптацию уроков проще, но решение от неё больше не зависит.
Остаётся на будущее: Остаётся на будущее:
- Переиспользование: после **первого реального прогона менти** — ретроспектива и - Переиспользование: после **первого реального прогона менти** — ретроспектива и
обобщение материала под других менти. Это валидация уже собранного курса, а не обобщение материала под других менти. Это валидация уже собранного курса, а не
часть его подготовки. часть его подготовки.
- Кластерная конфигурация ClickHouse (открыто, 2026-07-04). Сейчас стенд —
одноузловой ClickHouse; кластер живёт в соседнем
[clickhouse-learning-cluster](https://github.com/dementev-dev/clickhouse-learning-cluster).
Развилка: оставить разделение «здесь интеграции, там кластер» или доработать этот
стенд до кластера — «всё в одном» удобнее и ближе к промышленной системе, но
ощутимо утяжеляет стенд (ресурсы, конфигурация, сложность уроков) и размывает
нишу learning-cluster. Решение не принято.
+34 -6
View File
@@ -25,16 +25,25 @@
слова с живым стендом. слова с живым стендом.
- **Железо:** стек тяжёлый — Kafka, ClickHouse, Airflow, Superset, Prometheus и Grafana - **Железо:** стек тяжёлый — Kafka, ClickHouse, Airflow, Superset, Prometheus и Grafana
поднимаются одновременно. Нужна машина, которая это потянет. поднимаются одновременно. Нужна машина, которая это потянет.
- **Подними стенд и залей малый срез** (из корня репозитория) — этого хватит, чтобы - **Инструменты:** `Docker` с `docker compose`, `make`, `bash`, `curl`, `git` и `uv`.
начать, и прогон быстрый: `uv` нужен для локальных Python-проверок и команд разработки.
- **Подними стенд и создай стартовую историю** (из корня репозитория) — этого хватит,
чтобы начать, и прогон быстрый:
```bash ```bash
make up # поднять контейнеры make generated-history-analytics
make ddl # создать схему в ClickHouse (базы, таблицы, VIEW) make up
LIMIT=50 make data # залить по 50 строк на топик в Kafka
``` ```
Дальше каждый урок в секции «Руки» сам напоминает, что перезапустить и с каким срезом. Эта команда проводит штатный путь стенда: готовый источник данных создаёт стартовую
историю, события попадают в Kafka, затем в STG, ODS, DDS, DM и Superset. Это тот же
путь, что ручной вариант из README (Airflow UI и операция `backfill`), но одной
командой — выбери один из двух, оба ведут к одинаковому стенду. Файлы
`data/*.jsonl` пока остаются только кладовкой готовых значений для генератора
(браузеры, страны, устройства, UTM), а не источником аналитического контура. `make up`
после неё поднимает остальные UI-сервисы курса: Kafka UI, Airflow, Prometheus и Grafana.
Дальше каждый урок в секции «Руки» сам напоминает, что перезапустить.
Точные шаги, параметры и troubleshooting — в [`docs/OPERATIONS.md`](../OPERATIONS.md). Точные шаги, параметры и troubleshooting — в [`docs/OPERATIONS.md`](../OPERATIONS.md).
## Уроки ## Уроки
@@ -65,6 +74,25 @@
- **Идёшь с ментором** — еженедельный созвон покрывает несколько уроков сразу: туда - **Идёшь с ментором** — еженедельный созвон покрывает несколько уроков сразу: туда
несёшь затыки и ответы «своими словами» из тех же секций «Проверь себя». несёшь затыки и ответы «своими словами» из тех же секций «Проверь себя».
## Проверка чистого маршрута
Перед проверкой уроков 0, 1 и 5 подними стенд с нуля:
```bash
make generated-history-analytics
make up
```
Что должен подтвердить человек:
- урок 0: в Kafka UI видны четыре топика событий и понятны служебные топики
генератора;
- урок 1: после `CLEAN_START=0 make generated-history-analytics` учебная колонка
`kafka_msg_ts` не пропадает и заполняется;
- урок 5: Prometheus targets `clickhouse`, `kafka`, `airflow` находятся в `UP`,
а `Kafka No Messages Produced` трактуется с учётом того, запущен live-генератор
или только стартовая история.
## Границы ## Границы
Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к
+36 -16
View File
@@ -7,7 +7,7 @@
> Эталонный путь: сам Kafka UI на `http://localhost:8082`. > Эталонный путь: сам Kafka UI на `http://localhost:8082`.
> >
> Поток данных одной строкой: > Поток данных одной строкой:
> `make data → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` > `готовый источник данных стенда → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает`
> >
> О чём урок простыми словами: ходим по Kafka UI и разглядываем поток — где лежат события, > О чём урок простыми словами: ходим по Kafka UI и разглядываем поток — где лежат события,
> кто их читает и как Kafka помнит, до какого места уже дочитано. > кто их читает и как Kafka помнит, до какого места уже дочитано.
@@ -22,11 +22,15 @@
consumer читает в своём темпе, не трогая producer'а. Если читатель отстал или прилёг — consumer читает в своём темпе, не трогая producer'а. Если читатель отстал или прилёг —
события не теряются, они лежат в топике и ждут. события не теряются, они лежат в топике и ждут.
На нашем стенде роли уже расставлены: `make data` играет роль трекера и заливает события На нашем стенде роли уже расставлены: готовый источник данных стенда играет роль трекера
в топики, а ClickHouse — это consumer, который их вычитывает. В этом уроке мы не запускаем и пишет события в топики, а ClickHouse — это consumer, который их вычитывает. Для курса нам
пайплайн и ничего не меняем — мы открываем Kafka UI и **узнаём в живом кластере** те самые важно не устройство источника, а сам путь данных: Kafka → STG → ODS → DDS → DM → Superset.
понятия из видео. Это разминка: в уроке 1 ты уже руками увидишь, как эти же сообщения Файлы `data/*.jsonl` здесь не источник аналитики; пока это только кладовка готовых значений
доезжают до таблиц ClickHouse. для источника данных стенда.
В этом уроке мы не запускаем пайплайн и ничего не меняем — мы открываем Kafka UI и
**узнаём в живом кластере** те самые понятия из видео. Это разминка: в уроке 1 ты уже руками
увидишь, как эти же сообщения доезжают до таблиц ClickHouse.
> **В проде так же, только крупнее.** Здесь один брокер и крошечный срез данных. В бою > **В проде так же, только крупнее.** Здесь один брокер и крошечный срез данных. В бою
> брокеров несколько, топик разбит на много партиций, читателей в группе — тоже несколько, > брокеров несколько, топик разбит на много партиций, читателей в группе — тоже несколько,
@@ -37,8 +41,14 @@ consumer читает в своём темпе, не трогая producer'а.
## 2. Наблюдай: открой Kafka UI ## 2. Наблюдай: открой Kafka UI
Стенд уже должен быть поднят (`make up`) и в топиках должны лежать события Стенд уже должен быть поднят, а в топиках должны лежать события после стартовой истории:
(`LIMIT=50 make data` из урока 1 — или любой прошлый прогон). Открой Kafka UI:
```bash
make generated-history-analytics
make up
```
Открой Kafka UI:
`http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя. `http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя.
Пройди по трём экранам и просто посмотри. Пройди по трём экранам и просто посмотри.
@@ -50,24 +60,34 @@ consumer читает в своём темпе, не трогая producer'а.
- `geo_events` — гео; - `geo_events` — гео;
- `location_events` — местоположение. - `location_events` — местоположение.
Рядом может быть служебный топик `__consumer_offsets` (если включён показ внутренних Рядом могут быть служебные топики:
топиков) — его Kafka использует сама, мы его не трогаем. У каждого нашего топика в колонке
с партициями стоит **1**: топик маленький, делить не на что.
**Сообщения в топике.** Открой `browser_events` → вкладку *Messages*. Это и есть события, - `__consumer_offsets` — внутренний топик Kafka. В нём Kafka хранит прогресс
которые залил `make data`. У каждого сообщения видно: consumer-групп;
- `generator_state` — слепок состояния генератора на правой границе стартовой истории.
По нему live-продолжение понимает, откуда продолжать тот же мир;
- `generator_startup_history_manifest` — паспорт стартовой истории: seed, границы
модельного времени и контрольные числа;
- `generator_batch_history` — журнал батчей генератора: сколько сообщений он
отправил и чем закончилась каждая пачка записи.
Служебные топики нужны стенду, но в упражнениях курса мы их не меняем. У каждого нашего
топика событий в колонке с партициями стоит **1**: топик маленький, делить не на что.
**Сообщения в топике.** Открой `browser_events` → вкладку *Messages*. Это и есть события
стенда. У каждого сообщения видно:
- **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …); - **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …);
- **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события); - **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события);
- **Value** — тело: JSON события целиком, например: - **Value** — тело: JSON события целиком, например:
```json ```json
{"event_id": "8cca1c7d-...", "event_timestamp": "2022-11-28 20:51:05.627882", {"event_id": "8cca1c7d-...", "event_timestamp": "2026-01-01 00:01:00.000000",
"event_type": "pageview", "browser_name": "Chrome", "browser_language": "sat_IN"} "event_type": "pageview", "browser_name": "Chrome", "browser_language": "sat_IN"}
``` ```
Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось, Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось,
здесь — 2022 год), и оно отличается от Kafka-Timestamp (когда оно попало в топик — при заливке стенда). модельное время стенда), и оно отличается от Kafka-Timestamp (когда оно попало в топик).
Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на
стороне ClickHouse. стороне ClickHouse.
@@ -117,7 +137,7 @@ consumer-группа помнит, где остановилась: дочит
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| открыть список топиков | Kafka UI → *Topics* | 4 топика событий (`*_events`), у каждого 1 партиция | | открыть список топиков | Kafka UI → *Topics* | 4 топика событий (`*_events`) и, возможно, служебные топики генератора |
| открыть `browser_events` | вкладка *Messages* | сообщения с offset'ами 0, 1, 2, …; в Value — JSON события | | открыть `browser_events` | вкладка *Messages* | сообщения с offset'ами 0, 1, 2, …; в Value — JSON события |
| сравнить два времени | Value (`event_timestamp`) vs Kafka *Timestamp* | время события и время доставки в Kafka различаются | | сравнить два времени | Value (`event_timestamp`) vs Kafka *Timestamp* | время события и время доставки в Kafka различаются |
| открыть consumers | Kafka UI → *Consumers* | 4 группы `ch_stg_*`, статус `STABLE`, по 1 участнику | | открыть consumers | Kafka UI → *Consumers* | 4 группы `ch_stg_*`, статус `STABLE`, по 1 участнику |
+17 -14
View File
@@ -49,13 +49,14 @@
## 2. Руки: убедись, что данные текут ## 2. Руки: убедись, что данные текут
Поднимаем стенд, создаём схему и заливаем **малый срез** (50 строк на топик — этого Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник
хватает, чтобы всё увидеть, и прогон быстрый): данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS и DM. Файлы `data/*.jsonl` в этом пути не источник аналитики; пока это только
кладовка значений для источника данных стенда.
```bash ```bash
make up # поднять инфраструктуру (Kafka, ClickHouse, ...) make generated-history-analytics
make ddl # создать базы и таблицы в ClickHouse (в т.ч. слой STG) make up
LIMIT=50 make data # залить по 50 строк каждого файла в Kafka-топики
``` ```
Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль: Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль:
@@ -188,19 +189,20 @@ SELECT
FROM stg.kafka_browser_raw; FROM stg.kafka_browser_raw;
``` ```
**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим таблицу — иначе **Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим только таблицу
рядом останутся строки из секции 2, вставленные ещё *до* `ADD COLUMN`, и в них приёмника — иначе рядом останутся строки из секции 2, вставленные ещё *до*
`kafka_msg_ts` будет пустой (`1970-01-01`): `ADD COLUMN`, и в них `kafka_msg_ts` будет пустой (`1970-01-01`):
```sql ```sql
TRUNCATE TABLE stg.browser_raw; TRUNCATE TABLE stg.browser_raw;
``` ```
Затем перезаливаем — с пересозданием топиков, чтобы Kafka-движок перечитал сообщения Затем повторно создаём стартовую историю **без очистки volumes**. Это важно:
с начала (без этого он считает их уже прочитанными и ничего нового не подхватит): `CLEAN_START=0` сохраняет твою новую колонку и пересозданное MV, но добавляет свежие
сообщения в Kafka, чтобы ClickHouse прочитал их уже с новой схемой.
```bash ```bash
LIMIT=50 RESET_TOPICS=1 make data CLEAN_START=0 make generated-history-analytics
``` ```
**Смотрим результат:** **Смотрим результат:**
@@ -230,10 +232,11 @@ ALTER TABLE stg.browser_raw DROP COLUMN kafka_msg_ts;
``` ```
```bash ```bash
make ddl # пересоздаёт эталонный MV из 10_stg.sql — схема снова как в репозитории make ddl # пересоздаёт эталонное MV из 10_stg.sql, схема снова как в репозитории
``` ```
Если запутался в состоянии — всегда есть полный сброс: `make clean && make up && make ddl && LIMIT=50 make data`. Если запутался в состоянии — всегда есть полный чистый прогон:
`make generated-history-analytics && make up`.
--- ---
@@ -241,7 +244,7 @@ make ddl # пересоздаёт эталонный MV из 10_stg.sql —
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| `LIMIT=50 make data` | `SELECT count() FROM stg.browser_raw` | счётчик > 0 и растёт после загрузки | | `make generated-history-analytics && make up` | `SELECT count() FROM stg.browser_raw` | счётчик > 0 |
| глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный | | глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный |
| глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей | | глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей |
| правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения | | правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения |
+43 -44
View File
@@ -84,27 +84,27 @@ ODS мы пересобираем целиком, одной задачей Airf
## 2. Руки: смотрим базовый прогон ## 2. Руки: смотрим базовый прогон
Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник
трансформацию: данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого
источника, а не источником аналитического контура.
```bash ```bash
make up # поднять инфраструктуру make generated-history-analytics
make ddl # создать базы и таблицы (в т.ч. слой ODS) make up
LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG
make transform # батч STG → ODS → DDS → DM (нас интересует первый шаг)
``` ```
`make transform` прогоняет всю цепочку слоёв сразу, но прямо в консоли печатает то, что нам Команда прогоняет всю цепочку слоёв и прямо в консоли печатает то, что нам нужно сейчас, —
нужно сейчас, — блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам слоя (четыре
слоя (четыре основных и четыре с ошибками): основных и четыре с ошибками). Пример формы вывода:
``` ```
Статистика ODS: Статистика ODS:
┌─table──────────────────────┬─rows─┐ ┌─table──────────────────────┬─rows─┐
│ ods.browser_event │ 50 │ │ ods.browser_event │ ... │
│ ods.location_event │ 50 │ │ ods.location_event │ ... │
│ ods.device_by_click │ 26 │ │ ods.device_by_click │ ... │
│ ods.geo_by_click │ 26 │ │ ods.geo_by_click │ ... │
│ ods.browser_event_errors │ 0 │ │ ods.browser_event_errors │ 0 │
│ ods.location_event_errors │ 0 │ │ ods.location_event_errors │ 0 │
│ ods.device_by_click_errors │ 0 │ │ ods.device_by_click_errors │ 0 │
@@ -114,35 +114,34 @@ make transform # батч STG → ODS → DDS → DM (нас инте
Прочитаем эту табличку — в ней три вещи, которые стоит заметить. Прочитаем эту табличку — в ней три вещи, которые стоит заметить.
**Все четыре `*_errors` — по нулям.** Значит, наш срез чистый: ни одна запись не дала ошибки **Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала
разбора, столбец `parse_errors` у всех пустой. Это нормально — данные в демо аккуратные. ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные.
Ошибки мы увидим в секции 4, когда сами их устроим. Ошибки мы увидим в секции 4, когда сами их устроим.
**`browser` и `location` дали 50 из 50.** Сколько событий пришло — столько и легло, один к **`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк
одному. и ожидаем увидеть после типизации, если ключи валидны.
**А `device` и `geo` — только 26 из 50.** Вот это уже интересно. Половина куда-то делась? Нет. **А `device` и `geo` обычно меньше, чем событий.** Вот это уже интересно. Часть строк
И это важно понять, иначе дальше будет казаться, что данные текут. куда-то делась? Нет. И это важно понять, иначе дальше будет казаться, что данные текут.
Дело в том, что эти две таблицы хранят не события, а **контекст клика**: с какого устройства Дело в том, что эти две таблицы хранят не события, а **контекст клика**: с какого устройства
был клик и из какой точки на карте. Ключ у них — `click_id`. А в срезе на 50 событий разных был клик и из какой точки на карте. Ключ у них — `click_id`. Разных кликов меньше, чем событий:
кликов всего 26: на один клик приходится несколько событий, и `click_id` у них повторяется. на один клик приходится несколько событий, и `click_id` у них повторяется. Движок таблицы
Движок таблицы (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке на клик.
на клик. Отсюда и 26.
Проверь это сам, а не верь на слово. Открой SQL-консоль `http://localhost:9123/play` Проверь это сам, а не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
(пользователь `default`, пароль `123456`) и посчитай, сколько в срезе *различных* `click_id`: (пользователь `default`, пароль `123456`) и посчитай, сколько в STG *различных* `click_id`:
```sql ```sql
-- Всего строк в STG — 50, но различных click_id среди них — ровно 26 -- Строк событий больше, чем различных click_id
SELECT count() AS stg_rows, SELECT count() AS stg_rows,
uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks
FROM stg.geo_raw; FROM stg.geo_raw;
``` ```
Получишь `stg_rows = 50`, `distinct_clicks = 26` — ровно столько, сколько строк в `distinct_clicks` должен быть меньше или равен `stg_rows` и совпадать с числом строк в
`ods.geo_by_click`. Значит, 26 — это схлопнутые повторы, а не пропавшие данные. Ничего не `ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие данные.
потерялось молча. Ничего не потерялось молча.
--- ---
@@ -174,7 +173,7 @@ toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS
Весь смысл — в суффиксе `OrNull`. Если значение **не** приводится к нужному типу (вместо UUID Весь смысл — в суффиксе `OrNull`. Если значение **не** приводится к нужному типу (вместо UUID
пришёл мусор), функция не падает с ошибкой, а просто возвращает `NULL`. Это ровно то правило пришёл мусор), функция не падает с ошибкой, а просто возвращает `NULL`. Это ровно то правило
стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов. стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов.
Один кривой `event_id` станет `NULL` и будет помечен, а остальные 49 строк спокойно доедут. Один кривой `event_id` станет `NULL` и будет помечен, а остальные строки спокойно доедут.
> Кстати, про `AS`: эти строки живут в блоке `WITH` в начале запроса. `WITH` — это просто > Кстати, про `AS`: эти строки живут в блоке `WITH` в начале запроса. `WITH` — это просто
> способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него > способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него
@@ -230,7 +229,7 @@ arrayFilter(x -> x != '', [
> если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим > если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим
> воспользуемся — и увидим, чем грозит такой рассинхрон. > воспользуемся — и увидим, чем грозит такой рассинхрон.
### Движок: откуда взялись 26 строк ### Движок: почему строк контекста меньше
И последнее место — строчка про движок основных таблиц: И последнее место — строчка про движок основных таблиц:
@@ -241,8 +240,8 @@ ORDER BY (click_id)
`ReplacingMergeTree` — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся `ReplacingMergeTree` — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся
из `ORDER BY`), оставляя самую свежую по `src_ingest_ts` — времени загрузки в ODS. Вот она, из `ORDER BY`), оставляя самую свежую по `src_ingest_ts` — времени загрузки в ODS. Вот она,
причина «26 из 50» из секции 2: у `device` и `geo` много строк с одинаковым `click_id`, и причина разницы из секции 2: у `device` и `geo` много строк с одинаковым `click_id`, и движок
движок оставляет по одной на клик. оставляет по одной на клик.
--- ---
@@ -276,8 +275,8 @@ make transform
И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная: И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:
``` ```
│ ods.geo_by_click │ 26 │ │ ods.geo_by_click │ ... │
│ ods.geo_by_click_errors │ 50 │ ← было 0 │ ods.geo_by_click_errors │ ... │ ← было 0
``` ```
А в самой основной таблице широта пропала — но не молча, рядом стоит метка: А в самой основной таблице широта пропала — но не молча, рядом стоит метка:
@@ -295,11 +294,10 @@ LIMIT 4;
└──────────────┴──────────────┴───────────────┴──────────────────────┘ └──────────────┴──────────────┴───────────────┴──────────────────────┘
``` ```
Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. 26 строк Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным
остались в основной таблице (ключ `click_id` цел) с пометкой `bad_geo_latitude`. И те же `click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в
записи попали в число 50 строк `geo_by_click_errors`. Долгота на месте, а широты больше нет: `geo_by_click_errors`. Долгота на месте, а широты больше нет: один неверный тип — и целое поле
один неверный тип — и целое поле потеряно по всему слою. Заметили это `parse_errors` и таблица потеряно по всему слою. Заметили это `parse_errors` и таблица ошибок — для того DQ-split и нужен.
ошибок — для того DQ-split и нужен.
> **Бывает и хуже — тихо, совсем без метки.** Здесь нас спас суффикс `OrNull`: неверный тип > **Бывает и хуже — тихо, совсем без метки.** Здесь нас спас суффикс `OrNull`: неверный тип
> дал `NULL`, а `NULL` мы умеем замечать (на него и сработал `parse_errors`). По-настоящему > дал `NULL`, а `NULL` мы умеем замечать (на него и сработал `parse_errors`). По-настоящему
@@ -319,7 +317,7 @@ make transform
``` ```
После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем «поплыл» — После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем «поплыл» —
всегда есть полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`. всегда есть полный чистый прогон: `make generated-history-analytics && make up`.
--- ---
@@ -327,9 +325,9 @@ make transform
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| `make transform` (базовый прогон) | блок «Статистика ODS» | `browser`/`location` = 50, `device`/`geo` = 26, все `*_errors` = 0 | | базовый прогон | блок «Статистика ODS» | основные таблицы не пустые, все `*_errors` = 0 |
| почему 26, а не 50 | запрос `uniqExact(click_id)` по `stg.geo_raw` | 26 различных `click_id` — это схлопывание повторов, а не потеря | | почему `device`/`geo` меньше событий | запрос `uniqExact(click_id)` по `stg.geo_raw` | число различных `click_id` совпадает с `ods.geo_by_click` |
| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул `0 → 50` | | правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул с `0` на ненулевое число |
| правка из секции 4 | `SELECT geo_latitude, parse_errors FROM ods.geo_by_click` | широта `NULL`, в `parse_errors` — `bad_geo_latitude` | | правка из секции 4 | `SELECT geo_latitude, parse_errors FROM ods.geo_by_click` | широта `NULL`, в `parse_errors` — `bad_geo_latitude` |
--- ---
@@ -338,7 +336,8 @@ make transform
После урока у тебя на руках — видимый результат (одно на выбор): После урока у тебя на руках — видимый результат (одно на выбор):
- скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0` на `50`; - скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0`
на ненулевое число;
- либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом. - либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом.
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
+30 -30
View File
@@ -65,35 +65,35 @@
## 2. Руки: смотрим базовый прогон ## 2. Руки: смотрим базовый прогон
Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник
трансформацию: данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого
источника, а не источником аналитического контура.
```bash ```bash
make up # поднять инфраструктуру make generated-history-analytics
make ddl # создать базы и таблицы (в т.ч. слой DDS) make up
LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG
make transform # батч STG → ODS → DDS → DM
``` ```
`make transform` прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика Команда прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика DDS»** —
DDS»** — счётчики строк по двум нашим сущностям: счётчики строк по двум нашим сущностям:
``` ```
Статистика DDS: Статистика DDS:
┌─table─────┬─rows─┐ ┌─table─────┬─rows─┐
│ dds.click │ 26 │ │ dds.click │ ... │
│ dds.event │ 50 │ │ dds.event │ ... │
└───────────┴──────┘ └───────────┴──────┘
``` ```
Прочитаем эти две строки. Прочитаем эти две строки.
**`dds.event` — 50.** Сколько событий пришло, столько карточек и собралось: одно событие — одна **`dds.event` — события.** Сколько событий пришло с валидным ключом, столько карточек и
строка. Ровно как `ods.browser_event` из прошлого урока. собралось: одно событие — одна строка. Ровно как `ods.browser_event` из прошлого урока.
**`dds.click` — 26, а не 50.** И это та же история, что мы уже разбирали в уроке 2. Карточка **`dds.click` обычно меньше, чем `dds.event`.** И это та же история, что мы уже разбирали
клика — одна на клик, а в срезе на 50 событий разных кликов всего 26 (на один клик приходится в уроке 2. Карточка клика — одна на клик, а событий на один клик может быть несколько.
несколько событий). Поэтому 50 событий ссылаются на 26 кликов — это нормально, так и должно быть. Поэтому много событий ссылаются на меньшее число кликов — это нормально, так и должно быть.
Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»** Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»**
(это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку (это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку
@@ -108,9 +108,9 @@ DDS»** — счётчики строк по двум нашим сущност
В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя
дата — не пугайся, если она не совпадёт с примером. дата — не пугайся, если она не совпадёт с примером.
`orphan_events = 0` — ни одной сироты. Каждое из 50 событий нашло свой клик в `dds.click`. На `orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На
чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции
сироту устроим сами — и эта строка оживёт. 4 мы сироту устроим сами — и эта строка оживёт.
Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play` Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
(пользователь `default`, пароль `123456`) и посчитай сирот напрямую: (пользователь `default`, пароль `123456`) и посчитай сирот напрямую:
@@ -158,9 +158,9 @@ SELECT click_id FROM ods.geo_by_click ...
строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал
в `device`. в `device`.
> На нашем срезе `device` и `geo` содержат один и тот же набор из 26 кликов, так что универсум > На чистой стартовой истории `device` и `geo` должны содержать один и тот же набор кликов,
> тоже 26. Но код написан так, чтобы пережить случай, когда наборы **разойдутся**, — и это > так что универсум совпадает с обоими источниками. Но код написан так, чтобы пережить случай,
> правильно: в проде они расходятся постоянно. > когда наборы **разойдутся**, — и это правильно: в проде они расходятся постоянно.
### `argMax`: одна строка на клик, самая свежая ### `argMax`: одна строка на клик, самая свежая
@@ -221,7 +221,7 @@ LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id
### Сироты: событие без клика ### Сироты: событие без клика
Мы собрали `dds.click` (26 карточек кликов) и `dds.event` (50 карточек событий). Внутри каждого Мы собрали `dds.click` (карточки кликов) и `dds.event` (карточки событий). Внутри каждого
события лежит `click_id` — ссылка на клик. И вот тут возникает вопрос целостности из секции 1: события лежит `click_id` — ссылка на клик. И вот тут возникает вопрос целостности из секции 1:
**а на каждую ли ссылку есть карточка клика?** **а на каждую ли ссылку есть карточка клика?**
@@ -237,16 +237,16 @@ WHERE click_id IS NOT NULL
Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой
контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события** контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события**
нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по
пустым полям внутри карточки, вторую — отдельным счётчиком. На чистом срезе сирот ноль — сейчас пустым полям внутри карточки, вторую — отдельным счётчиком. На чистой стартовой истории сирот
мы это изменим. ноль — сейчас мы это изменим.
--- ---
## 4. Управляемая правка: заведём сироту ## 4. Управляемая правка: заведём сироту
Сирота на чистом срезе не появится сама — данные слишком аккуратные. Поэтому **создадим её Сирота на чистой стартовой истории не появится сама — данные слишком аккуратные. Поэтому
руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого в `dds.click` **создадим её руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого
нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`. в `dds.click` нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`.
Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие: Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие:
@@ -309,7 +309,7 @@ make transform
``` ```
После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд совсем «поплыл» — После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд совсем «поплыл» —
полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`. полный чистый прогон: `make generated-history-analytics && make up`.
--- ---
@@ -317,9 +317,9 @@ make transform
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| `make transform` (базовый прогон) | блок «Статистика DDS» | `dds.click` = 26, `dds.event` = 50 | | базовый прогон | блок «Статистика DDS» | `dds.click` и `dds.event` не пустые |
| `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` | | `make transform` (базовый прогон) | блок «Сводка по качеству», строка `orphan_events` | `0` |
| почему `click` = 26, а `event` = 50 | запрос `count()` по `dds.click` и `dds.event` | 50 событий ссылаются на 26 кликов — норма | | почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма |
| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` | | правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` |
| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` | | та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` |
+13 -10
View File
@@ -56,12 +56,14 @@ Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Gra
## 2. Руки: запускаем DAG и смотрим зелёный прогон ## 2. Руки: запускаем DAG и смотрим зелёный прогон
Подними стенд, создай схему и залей малый срез: Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого
источника, а не источником аналитического контура.
```bash ```bash
make generated-history-analytics
make up make up
make ddl
LIMIT=50 make data
``` ```
Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG
@@ -75,13 +77,13 @@ LIMIT=50 make data
заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый, заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый,
старые эксперименты не мешают. старые эксперименты не мешают.
Когда DAG завершится, открой его граф. На чистом срезе все задачи должны быть зелёными. Найди Когда DAG завершится, открой его граф. На чистой стартовой истории все задачи должны быть
внутри группы `transform` две задачи подряд: зелёными. Найди внутри группы `transform` две задачи подряд:
- `check_dds_integrity` — SQL-задача, которая считает сирот; - `check_dds_integrity` — SQL-задача, которая считает сирот;
- `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше. - `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше.
На чистом срезе `assert_dds_integrity` зелёная: сирот нет, пайплайн прошёл в DM. На чистой стартовой истории `assert_dds_integrity` зелёная: сирот нет, пайплайн прошёл в DM.
Проверь то же число в ClickHouse play-консоли `http://localhost:9123/play`: Проверь то же число в ClickHouse play-консоли `http://localhost:9123/play`:
@@ -305,14 +307,15 @@ WHERE click_id IS NOT NULL
AND click_id NOT IN (SELECT click_id FROM dds.click); AND click_id NOT IN (SELECT click_id FROM dds.click);
``` ```
Снова должно быть `0`. Если стенд после экспериментов совсем запутался, полный сброс остаётся Снова должно быть `0`. Если стенд после экспериментов совсем запутался, сделай штатный
тем же: чистый прогон:
```bash ```bash
make clean && make up && make ddl && LIMIT=50 make data make generated-history-analytics
make up
``` ```
После этого запусти `etl_pipeline` с `{"full_refresh": true}`. После этого при необходимости запусти `etl_pipeline` с `{"full_refresh": true}`.
--- ---
+48 -18
View File
@@ -57,15 +57,20 @@
## 2. Руки: открываем дашборды и targets ## 2. Руки: открываем дашборды и targets
Подними стенд и прогони маленький срез, если он ещё не поднят: Подними стенд и создай стартовую историю, если он ещё не поднят. Это штатный путь курса:
готовый источник данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем
batch строит ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений
для этого источника, а не источником аналитического контура.
```bash ```bash
make generated-history-analytics
make up make up
make ddl
LIMIT=50 make data
``` ```
После этого запусти `etl_pipeline` в Airflow с конфигом: Учти: путь `generated-history-analytics` собирает витрины напрямую, без запуска
`etl_pipeline`, поэтому панели про задачи Airflow в `Airflow Overview` останутся
пустыми, пока ты хотя бы раз не запустишь `etl_pipeline` сам (это делалось в
уроке 4). Запустить его можно в Airflow с конфигом:
```json ```json
{"full_refresh": true} {"full_refresh": true}
@@ -78,16 +83,19 @@ LIMIT=50 make data
Открой Prometheus: `http://localhost:9090`. В меню зайди в **Status → Targets**. Открой Prometheus: `http://localhost:9090`. В меню зайди в **Status → Targets**.
Ожидаем три job: Ожидаем основные job:
| Job | Target внутри Docker | Что это значит | | Job | Target внутри Docker | Что это значит |
|-----|----------------------|----------------| |-----|----------------------|----------------|
| `clickhouse` | `clickhouse:9126` | ClickHouse отдаёт встроенный Prometheus endpoint | | `clickhouse` | `clickhouse:9126` | ClickHouse отдаёт встроенный Prometheus endpoint |
| `kafka` | `kafka-exporter:9308` | `kafka-exporter` подключился к Kafka и отдаёт метрики | | `kafka` | `kafka-exporter:9308` | `kafka-exporter` подключился к Kafka и отдаёт метрики |
| `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus | | `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus |
| `generator` | `generator:9109` | live-генератор отдаёт свои метрики, только когда явно запущен |
У всех трёх состояние должно быть `UP`. Если один target `DOWN`, Grafana дальше будет показывать У `clickhouse`, `kafka` и `airflow` состояние должно быть `UP`. `generator` на штатном
`No data` или старые значения. backfill-only стенде может быть `DOWN`, потому что `make up` не запускает live-генератор.
Это нормально для курса до явного `make generator-continue`. Если один из трёх основных
target `DOWN`, Grafana дальше будет показывать `No data` или старые значения.
То же можно проверить из терминала: То же можно проверить из терминала:
@@ -112,11 +120,13 @@ curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
- строка поиска сверху помогает быстро найти `ClickHouse Overview`, `Kafka Overview` или - строка поиска сверху помогает быстро найти `ClickHouse Overview`, `Kafka Overview` или
`Airflow Overview`, если меню выглядит иначе. `Airflow Overview`, если меню выглядит иначе.
В **Dashboards** должны быть три дашборда: В **Dashboards** должны быть четыре дашборда:
- `ClickHouse Overview`; - `ClickHouse Overview`;
- `Kafka Overview`; - `Kafka Overview`;
- `Airflow Overview`. - `Airflow Overview`;
- `Generator Overview` — про live-генератор; на backfill-only стенде он пуст,
как и target `generator` выше, и в этом уроке не понадобится.
Открой каждый и смотри не на красоту графиков, а на смысл: какой слой стенда он показывает и Открой каждый и смотри не на красоту графиков, а на смысл: какой слой стенда он показывает и
какой вопрос помогает задать. какой вопрос помогает задать.
@@ -154,8 +164,14 @@ URL: `http://localhost:3000/d/kafka-overview/kafka-overview`
- **Partitions** и **Partition Offsets (Current)** — текущие offset-ы по партициям. - **Partitions** и **Partition Offsets (Current)** — текущие offset-ы по партициям.
**Consumer lag** — это разница между тем, что уже лежит в топике, и тем, что consumer group **Consumer lag** — это разница между тем, что уже лежит в топике, и тем, что consumer group
успела прочитать. В нашем стенде lag обычно быстро возвращается к нулю: данных мало, ClickHouse успела прочитать. В нашем стенде ClickHouse обычно читает быстро, поэтому большой устойчивый
читает быстро. Если lag растёт и не снижается, downstream не успевает за Kafka. lag не ожидается.
Есть важная оговорка из урока 0: ClickHouse-движок не всегда показывает свой прогресс как
обычная Kafka-группа. Поэтому в Kafka UI колонки offset/lag у `ch_stg_*` могут быть пустыми,
а в Grafana панель lag может быть пустой или нулевой. Это не конфликт между уроками. Для
точной проверки чтения со стороны ClickHouse смотри `system.kafka_consumers`, а Grafana здесь
используй как общий сигнал: появился ли большой lag, который не уходит.
### Airflow Overview ### Airflow Overview
@@ -184,7 +200,7 @@ URL: `http://localhost:3000/d/airflow-overview/airflow-overview`
### `scrape_configs`: кого опрашивает Prometheus ### `scrape_configs`: кого опрашивает Prometheus
В файле три блока: В файле четыре блока:
```yaml ```yaml
scrape_configs: scrape_configs:
@@ -199,10 +215,12 @@ scrape_configs:
`localhost`: Prometheus живёт внутри compose-сети и ходит к соседним контейнерам по их service `localhost`: Prometheus живёт внутри compose-сети и ходит к соседним контейнерам по их service
name. name.
Kafka и Airflow устроены так же, но с exporter-ами: Kafka, Airflow и generator устроены так же, но источники разные:
- `kafka` → `kafka-exporter:9308`; - `kafka` → `kafka-exporter:9308`;
- `airflow` → `statsd-exporter:9102`. - `airflow` → `statsd-exporter:9102`;
- `generator` → `generator:9109`, только когда live-генератор явно запущен.
Без live этот target может быть `DOWN`, как в проверке Prometheus выше.
### Почему Airflow идёт через StatsD ### Почему Airflow идёт через StatsD
@@ -254,9 +272,21 @@ Grafana.
| Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks | | Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks |
| Airflow Alerts | `High DAG Parse Time` | DAG-файлы долго парсятся | | Airflow Alerts | `High DAG Parse Time` | DAG-файлы долго парсятся |
Не все эти правила обязаны стрелять в учебном стенде. Часть порогов специально похожа на Не все эти правила обязаны быть тихими в учебном стенде. По умолчанию `make up` и
продовые: они показывают, как формулируется условие, но не создают шум на каждом маленьком `make generated-history-analytics` **не запускают live-генератор**, поэтому после готовой
прогоне. стартовой истории новые сообщения перестают приходить. Из-за этого `Kafka No Messages Produced`
может перейти в `Alerting` на полностью здоровом backfill-only стенде. Если хочешь проверить
это правило в спокойном состоянии, явно включи live:
```bash
make generator-continue
```
После наблюдения останови live-генератор, чтобы он не менял стенд дальше:
```bash
make generator-down
```
--- ---
@@ -331,7 +361,7 @@ make recover-monitoring
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| открыть Prometheus targets | `http://localhost:9090` → Status → Targets | `clickhouse`, `kafka`, `airflow` в состоянии `UP` | | открыть Prometheus targets | `http://localhost:9090` → Status → Targets | `clickhouse`, `kafka`, `airflow` в состоянии `UP`; `generator` может быть `DOWN` без live |
| открыть `ClickHouse Overview` | Grafana dashboards | панели `Queries per Second`, `Failed Queries (total)`, `Inserted Rows/sec` не пустые | | открыть `ClickHouse Overview` | Grafana dashboards | панели `Queries per Second`, `Failed Queries (total)`, `Inserted Rows/sec` не пустые |
| открыть `Kafka Overview` | Grafana dashboards | видны `Brokers Up`, `Topics`, `Consumer Lag by Group` | | открыть `Kafka Overview` | Grafana dashboards | видны `Brokers Up`, `Topics`, `Consumer Lag by Group` |
| открыть `Airflow Overview` | Grafana dashboards | видны `Scheduler Heartbeat Rate`, `Queued Tasks`, `Task Failures vs Success Rate` | | открыть `Airflow Overview` | Grafana dashboards | видны `Scheduler Heartbeat Rate`, `Queued Tasks`, `Task Failures vs Success Rate` |
+29 -43
View File
@@ -63,42 +63,28 @@ DM-витрина — это SQL-объект в ClickHouse. Она задаёт
## 2. Руки: запускаем Superset и смотрим дашборд ## 2. Руки: запускаем Superset и смотрим дашборд
Подними стенд и прогони полный демо-датасет: Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS, DM и обновляет Superset. Файлы `data/*.jsonl` пока остаются только кладовкой
значений для этого источника, а не источником аналитического контура.
```bash ```bash
make generated-history-analytics
make up make up
make ddl
make data
make transform
``` ```
`make transform` прогоняет цепочку STG → ODS → DDS → DM вне Airflow. Для этого урока так Команда уже прогоняет цепочку STG → ODS → DDS → DM и создаёт metadata Superset:
быстрее: нам нужен готовый DM-слой, а не разбор DAG. Отладочный срез через
`LIMIT=50 make data` можно использовать для быстрых экспериментов, но эталонный dashboard
и числа урока рассчитаны на полном наборе данных.
Теперь инициализируй Superset:
```bash
make superset-init
```
Эта команда создаёт или обновляет:
- подключение `clickhouse_dwh`; - подключение `clickhouse_dwh`;
- 6 datasets поверх `dm.*`. - 6 datasets поверх `dm.*`.
После этого создай или обнови charts и сам dashboard:
```bash
make superset-dashboard
```
Эта команда создаёт или обновляет:
- 10 charts; - 10 charts;
- dashboard `E-commerce Analytics Dashboard`. - dashboard `E-commerce Analytics Dashboard`.
Для этого урока нам нужен готовый DM-слой и уже созданный dashboard, а не разбор DAG.
Если Superset metadata нужно пересоздать отдельно, используй `make superset-init`.
Если нужно обновить только charts и dashboard после правки в `superset/create_dashboard.py`,
используй `make superset-dashboard`.
Открой Superset: `http://localhost:8088` (логин `admin`, пароль `admin`). Открой Superset: `http://localhost:8088` (логин `admin`, пароль `admin`).
Если Superset предлагает сменить пароль после первого входа, для учебного стенда можно нажать Если Superset предлагает сменить пароль после первого входа, для учебного стенда можно нажать
@@ -119,7 +105,7 @@ http://localhost:8088/superset/dashboard/ecommerce-analytics/
- KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, - KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`,
`Conversion to /confirmation`; `Conversion to /confirmation`;
- динамика: `Events over Time`, `Traffic by Device`; - динамика: `Events over Time`, `Traffic by Device`;
- география: `Geography Map`; - география: `Top Countries by Events`;
- маркетинг: `UTM Effectiveness Table`, `Page Funnel`; - маркетинг: `UTM Effectiveness Table`, `Page Funnel`;
- прохождение строк по слоям: `Rows by Layer (event)`. - прохождение строк по слоям: `Rows by Layer (event)`.
@@ -133,33 +119,33 @@ http://localhost:8088/superset/dashboard/ecommerce-analytics/
> **Зерно (grain), он же уровень гранулярности — это что считается одной строкой > **Зерно (grain), он же уровень гранулярности — это что считается одной строкой
> таблицы.** У события зерно > таблицы.** У события зерно
> «одно событие = одна строка» (ключ `event_id`), у визита — «один визит = одна > «одно событие = одна строка» (ключ `event_id`), у визита — «один визит = одна
> строка» (ключ `click_id`). Это разные зёрна: событий 1000, а визитов 99, потому > строка» (ключ `click_id`). Это разные зёрна: событий обычно больше, чем визитов,
> что в одном визите много событий. Складывать строки разного зерна в одно число > потому что в одном визите много событий. Складывать строки разного зерна в одно
> бессмысленно — это всё равно что сложить «штуки яблок» и «корзины яблок». > число бессмысленно — это всё равно что сложить «штуки яблок» и «корзины яблок».
Поэтому чарт держит **одно зерно — event**: берёт по одной канонической таблице Поэтому чарт держит **одно зерно — event**: берёт по одной канонической таблице
событий на слой (`browser_raw → browser_event → event → v_events_enriched`), а не сумму событий на слой (`browser_raw → browser_event → event → v_events_enriched`), а не сумму
по слою. Если просуммировать все таблицы слоя, в один столбец попадут таблицы разного по слою. Если просуммировать все таблицы слоя, в один столбец попадут таблицы разного
зерна (события 1000 + визиты 99 + пустые error-таблицы) и получится «воронка потерь», зерна: события, визиты и технические таблицы ошибок. Получится «воронка потерь», которой
которой на самом деле нет. на самом деле нет.
Шаг **1050 → 1000** на первом переходе — это не потеря данных, а дедупликация Если на первом переходе число уменьшается, это не обязательно потеря данных. В ODS работает
at-least-once потока по `event_id` в ODS (`ReplacingMergeTree`): в STG приехало 1050 строк, дедупликация at-least-once потока по `event_id` (`ReplacingMergeTree`): в STG могут приехать
но уникальных `event_id` среди них — 1000 (часть событий Kafka доставила повторно). Дальше повторы, а дальше остаётся каноническое число уникальных событий. Настоящие проблемы качества
число стабильно. Настоящие проблемы качества (ошибки парсинга, осиротевшие события) на (ошибки парсинга, осиротевшие события) на чистой стартовой истории равны нулю и лежат в
чистых демо-данных равны нулю и лежат в `dm.dq_summary` отдельными `check_name` — их `dm.dq_summary` отдельными `check_name` — их разбирали уроки 3–4.
разбирали уроки 3–4.
### Фильтр даты ### Фильтр даты
В демо-данных события датированы `2022-11-28`. В текущей конфигурации dashboard фильтр даты В стартовой истории события идут по модельному времени стенда. В текущей конфигурации
открывается как `No filter`. Если у тебя осталась старая metadata Superset и native filter dashboard фильтр даты открывается как `No filter`. Если у тебя осталась старая metadata
**Date Range** стоит в значении `Last week`, часть графиков может быть пустой, хотя данные есть. Superset и native filter **Date Range** стоит в значении `Last week`, часть графиков может
быть пустой, хотя данные есть.
Для этого урока поставь в фильтре даты одно из двух: Для этого урока поставь в фильтре даты одно из двух:
- `No filter`; - `No filter`;
- или ручной диапазон вокруг `2022-11-28`. - или ручной диапазон вокруг дат, которые видны в `event_timestamp`.
После этого нажми **Apply filters**. Теперь смотри на dashboard как аналитик: какие графики После этого нажми **Apply filters**. Теперь смотри на dashboard как аналитик: какие графики
отвечают на бизнес-вопросы, а какие только показывают техническое устройство конвейера. отвечают на бизнес-вопросы, а какие только показывают техническое устройство конвейера.
@@ -324,7 +310,7 @@ Native filters создаются в `build_dashboard_metadata`. Там есть
- `Browser` по `browser_name`. - `Browser` по `browser_name`.
Эти фильтры задаются в левой панели dashboard. Click-to-filter между виджетами не включен: Эти фильтры задаются в левой панели dashboard. Click-to-filter между виджетами не включен:
клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. клик по сектору pie chart, столбцу Top Countries, строке таблицы или funnel не меняет остальные charts.
Фильтр применяется только к charts, где есть нужное поле. `Country`, `Device Type` и Фильтр применяется только к charts, где есть нужное поле. `Country`, `Device Type` и
`Browser` работают с charts на `dm.v_events_enriched`; агрегированные витрины для UTM, `Browser` работают с charts на `dm.v_events_enriched`; агрегированные витрины для UTM,
@@ -415,7 +401,7 @@ metadata Superset.
| `make superset-init` | Superset → **Settings → Database Connections** | есть подключение `clickhouse_dwh` | | `make superset-init` | Superset → **Settings → Database Connections** | есть подключение `clickhouse_dwh` |
| открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` | | открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` |
| открыть dashboard | Superset UI | видны KPI, маркетинг, география и прохождение строк по слоям | | открыть dashboard | Superset UI | видны KPI, маркетинг, география и прохождение строк по слоям |
| поставить **Date Range → No filter** | dashboard filters | графики не скрываются из-за даты `2022-11-28` | | поставить **Date Range → No filter** | dashboard filters | графики не скрываются из-за даты модельного времени |
| поменять `row_limit` у `Page Funnel` на `3` и запустить `make superset-dashboard` | chart `Page Funnel` | не больше трёх страниц | | поменять `row_limit` у `Page Funnel` на `3` и запустить `make superset-dashboard` | chart `Page Funnel` | не больше трёх страниц |
| вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц | | вернуть `row_limit` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц |
@@ -0,0 +1,431 @@
# Эксперимент с координатором субагентов
Status: Research
Дата: 2026-06-11
## Зачем проводили эксперимент
Проверяли рабочую схему, где верхнеуровневый агент не реализует задачу сам, а
управляет цепочкой субагентов:
- выдаёт одному worker-субагенту один локальный issue;
- требует от worker-а работать через `/goal` и `/tdd`;
- получает отчёт;
- просит того же worker-а сделать саморевью;
- классифицирует находки;
- запускает исправления только по важным пунктам;
- при необходимости подключает отдельного reviewer-субагента;
- сам выполняет финальную проверку, разделяет коммиты и принимает решение о
переходе к следующей задаче.
Цель эксперимента была не только закрыть задачи генератора, но и понять, можно
ли из такого процесса сделать повторяемый агентный навык.
## Контекст
Эксперимент проводился в ветке `feature/data-generator` на последних трёх
срезах переработки steady-stream генератора:
1. `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md`
2. `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`
3. `.scratch/feature-data-generator/issues/07-service-integration-and-docs.md`
Задачи были зависимыми и меняли близкие места генератора, поэтому параллелить
их было нельзя. Каждая следующая задача должна была стартовать только после
коммита предыдущей.
Итоговые рабочие коммиты:
- `8f1e997 feat(generator): откалиброван поток steady-stream генератора`
- `640050e feat(generator): добавлено состояние версии 2 для рестартов`
- `6c4e0f4 feat(generator): подключена новая модель к steady-stream сервису`
Отдельные docs-коммиты фиксировали ход и выводы эксперимента.
## Начальная гипотеза
Ожидалось, что полезное разделение ролей такое:
- **Координатор** держит порядок, границы задач, проверки, коммиты и решение,
какие замечания действительно надо чинить.
- **Worker-субагент** глубоко погружается в один issue и реализует его через
короткие TDD-срезы.
- **Саморевью worker-а** полезно, потому что у него уже есть подробный контекст
реализации.
- **Отдельный reviewer-субагент** нужен не всегда, а на рискованных переходах
или после больших исправлений.
Верхнеуровневому координатору решили не включать `/goal`: это могло бы сместить
поведение в сторону «закрыть большую цель любой ценой», тогда как задача
координатора — управлять этапами и останавливаться на контрольных точках.
## Словарь ролей
- **Координатор** — верхнеуровневый агент в текущей сессии. Он не пишет основную
реализацию, а управляет порядком работ, проверяет границы, классифицирует
замечания, запускает дополнительные проверки и делает коммиты.
- **Worker** — субагент-исполнитель. Он получает один issue, работает в общем
рабочем дереве, меняет файлы, запускает тесты, но не коммитит.
- **Саморевью worker-а** — отдельная фаза после реализации. Worker временно
меняет роль с исполнителя на проверяющего свою работу, но сначала только
выдаёт находки, не исправляя их.
- **Reviewer** — отдельный субагент для независимой проверки. По умолчанию он
работает только на чтение: анализирует diff, issue, спеки и тесты, но не
редактирует файлы.
- **Находка** — гипотеза о проблеме, а не автоматическое требование правки.
Решение принимает координатор.
Важная техническая деталь: все агенты работают с одним рабочим деревом. Поэтому
координатор обязан регулярно проверять `git status --short` и явно отделять
рабочие изменения задачи от процессных заметок, handoff-файлов и чужих правок.
## Протокол, который сложился в эксперименте
Рабочий цикл на задачу:
1. Проверить чистоту дерева и, если нужно, отделить процессные правки отдельным
docs-коммитом.
2. Запустить нового worker-субагента на один issue.
3. В задании worker-у указать:
- `/goal`-образную цель;
- `/tdd` как методику;
- границы задачи;
- запрет на коммит;
- запрет на откат чужих изменений;
- требование финального отчёта: файлы, тесты, проверки, критерии, риски,
`git status --short`.
4. После реализации запросить у того же worker-а саморевью без правок.
5. Классифицировать находки.
6. Вернуть worker-у только одобренные правки.
7. Запустить локальные проверки координатора.
8. На сложных местах запустить отдельного reviewer-а.
9. Снова классифицировать находки reviewer-а.
10. При больших исправлениях выполнить второй reviewer-круг.
11. Коммит делает координатор.
12. Старый worker закрывается; следующая задача стартует в новом worker-е.
Это описание фиксирует фактический процесс эксперимента. Будущий скилл не обязан
механически повторять каждый шаг, но должен сохранить инварианты ниже.
## Классификация reviewer-находок
Reviewer не является источником истины. Его выводы работают как гипотезы,
которые координатор обязан проверить и классифицировать:
- `чинить до коммита` — реальный дефект, нарушение acceptance criteria или
слабое доказательство ключевого поведения;
- `записать как риск` — важно помнить, но не блокирует текущий коммит;
- `ложная тревога` — reviewer неверно понял код, тест или границы задачи;
- `вне скоупа` — может быть полезно позже, но не относится к текущему issue.
Это оказалось критически важным. Без фильтрации reviewer легко превращается в
источник расползания задачи.
## Наблюдения по задаче 05
Задача 05 была самой дорогой по обратной связи: калибровка статистической
модели требует длинных симуляций и не даёт быстрый «один инвариант — один
результат».
Worker долго работал без промежуточного отчёта. Координатор сначала поставил
мягкий статус-чек, затем прервал агента ради статуса. Это не сломало работу, но
показало: для задач со статистическими тестами стоит заранее закладывать
контрольные точки.
Саморевью worker-а нашло реальные проблемы:
- `docker-compose.yml` оставлял старую интенсивность `GEN_LAMBDA_BASE_PER_MIN=200`;
- тест межсессионной паузы был слишком широким;
- в тестовой конфигурации оставались старые числа;
- README мог быть двусмысленным про `GEN_MIN/MAX_EVENTS_PER_TICK`.
Координаторская проверка добавила отдельную ценность: был найден конфликт между
новым `GEN_LAMBDA_BASE_PER_MIN=30` и старым нижним пределом
`GEN_MIN_EVENTS_PER_TICK=5`. При тике 5 секунд это давало минимум 60 событий в
минуту и ломало критерий интенсивности.
Вывод: саморевью хорошо ловит локальные несостыковки реализации, но координатор
нужен для проверки связей между дефолтами, обычным запуском, документацией и
acceptance criteria.
## Наблюдения по задаче 06
Задача 06 показала, зачем нужен отдельный reviewer-субагент между задачами.
Саморевью worker-а и отдельный reviewer независимо нашли две существенные
проблемы:
- битое state v2 с валидным состоянием ГПСЧ могло пройти `from_dict_safe`, а
затем уронить сервис уже в `restore_state`;
- снимок активных визитов сохранялся полными batch-словарями и на верхних
лимитах получался порядка мегабайт, хотя спека говорила о компактном
состоянии.
Координатор проверил обе гипотезы локально:
- `restore_state` действительно падал на битой вложенной структуре;
- оценка JSON-снимка при 200 активных визитах и популяции 300 дала около 4.7 MB.
Обе находки были классифицированы как `чинить до коммита`.
После исправления компактного состояния понадобился второй reviewer-круг. Он
нашёл новый дефект уже в исправленной версии: формально похожий v2-state с
`population=[]` или строковым `pending_visit_births` проходил первичную
загрузку, но затем оставлял поток без пользователей или ронял следующий тик.
Координатор подтвердил это локальной проверкой и вернул worker-у как
обязательный пункт второго круга. Затем координатор дополнительно нашёл
парный случай: пользователь с `active_click_id`, но без соответствующего
`active_visit`.
Вывод: если исправление по reviewer-находке меняет дизайн, нужен повторный
reviewer-круг. Иначе можно закрыть старый дефект и внести новый рядом.
## Наблюдения по задаче 07
Задача 07 показала другую пользу reviewer-а: проверку силы доказательства, а не
только поиск падений.
Worker добавил сервисный тест с мок-публикацией. Он доказывал, что сервисный
тик публикует связанные сообщения во все четыре топика, но внешний reviewer
заметил: один опубликованный event ещё слабо доказывает уход от старой плоской
модели. Для acceptance criteria было важнее доказать невырожденную модель:
один визит должен дать несколько событий с одним `click_id`.
Координатор классифицировал это как `чинить до коммита`. Тест был усилен:
сервисный контур теперь делает несколько тиков и проверяет несколько событий
одного визита с общим `click_id`, разными `event_id` и согласованными
location/device/geo.
Reviewer также поймал документационные неточности:
- README слишком широко говорил, что переменные из таблицы проброшены через
compose, хотя `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` должны оставаться
безопасными внутренними значениями контейнера;
- `docs/OPERATIONS.md` отставал от новых параметров и метрик.
Вывод: финальная интеграционная задача требует reviewer-а не только по коду, но
и по доказательности тестов и честности документации.
## Что сработало
- Один worker на один issue хорошо удерживает контекст и границы.
- `/goal`-образное задание worker-у работает как хороший контракт даже тогда,
когда координатор сам не находится в goal-режиме.
- Саморевью того же worker-а полезно, если явно запретить правки на первом
шаге и попросить сначала выдать находки.
- Отдельный reviewer полезен на границах задач и после больших исправлений.
- Координаторская классификация находок обязательна.
- Коммиты должен делать координатор: это последняя точка контроля состава diff.
- Процессные наблюдения лучше фиксировать отдельно от рабочих коммитов.
## Что не стоит автоматизировать слепо
- Не стоит всегда запускать reviewer-а после каждой мелкой правки. Это дорого и
может раздувать задачу.
- Не стоит принимать все reviewer-находки как правду.
- Не стоит давать координатору верхнеуровневый `/goal` на всю цепочку, если от
него требуется управленческая осторожность, а не слепое достижение цели.
- Не стоит смешивать handoff-наблюдения с рабочими коммитами задач.
- Не стоит требовать реальный Kafka-стек для каждого шага, если acceptance
criteria допускают мок-публикацию. Но надо явно фиксировать, что именно не
проверялось.
## Ограничения эксперимента
Выводы нельзя считать универсально доказанными. Эксперимент прошёл в одном
репозитории, на одной ветке и на трёх зависимых задачах одного домена. Это
хороший сигнал для класса задач «последовательные инженерные срезы с тестами,
документацией и явными acceptance criteria», но не доказательство, что процесс
так же хорошо подойдёт для:
- независимых задач, которые можно безопасно параллелить;
- маленьких однофайловых правок;
- задач без тестовой базы;
- продуктовых или исследовательских задач, где итог заранее неясен;
- задач, где субагентам нельзя писать в общее рабочее дерево.
Стоимость процесса тоже заметна. Статистические и state-задачи требуют долгих
прогонов, ожидания worker-а, reviewer-кругов и ручной классификации находок.
Поэтому будущий скилл должен уметь выбирать облегчённый режим, а не всегда
запускать полную схему.
## Инварианты будущего скилла
Если на базе эксперимента делать скилл, в него стоит перенести не конкретные
команды из этого репозитория, а следующие обязательные правила:
- **Один worker — один bounded issue.** Цепочка задач ведётся координатором, а
не одним долгоживущим исполнителем.
- **Reviewer по умолчанию read-only.** Он выдаёт находки, но не правит файлы.
- **Исправления идут только после классификации.** Даже хорошие reviewer-находки
сначала проходят фильтр координатора.
- **Коммит делает координатор.** Это защищает границы задачи и состав diff.
- **Большое исправление требует повторной проверки.** Если правка меняет дизайн
или формат данных, результат самой правки надо ревьюить заново.
- **Рабочее дерево — общий ресурс.** Каждый цикл должен начинаться и
заканчиваться явной проверкой статуса.
- **Документация и тесты являются частью доказательства.** Reviewer должен
проверять не только падения, но и силу тестов, честность README/операционных
документов и соответствие acceptance criteria.
## Эвристики для будущего скилла
Если делать скилл на базе эксперимента, в нём стоит закрепить такие правила:
1. **Начинать с чистого дерева.** Если есть процессные или чужие правки,
отделить их до запуска worker-а.
2. **Давать worker-у один issue.** Не просить закрывать цепочку задач в одном
агенте.
3. **Формулировать task prompt как контракт.** В нём должны быть цель, границы,
методика, запрет на коммит, запрет на откат чужого и формат отчёта.
4. **Требовать саморевью без правок.** Исправления только после решения
координатора.
5. **Классифицировать находки.** Минимальный набор: чинить до коммита, риск,
ложная тревога, вне скоупа.
6. **Запускать отдельного reviewer-а на рискованных точках.** Особенно:
state/serialization, сервисная интеграция, документация, изменения формата
данных, статистические модели.
7. **Повторять reviewer-круг после больших исправлений.** Если исправление
изменило дизайн, оно само нуждается в ревью.
8. **Координатор делает собственный sanity-check.** Не полный реимплемент, а
проверка связей: дефолты, compose, документация, acceptance criteria,
`git status`, тесты.
9. **Коммитить только координатору.** Worker не должен фиксировать изменения.
10. **Закрывать агентов.** Завершённые subagents надо закрывать, чтобы не
держать лишний контекст и лимиты.
## Возможный скелет скилла
1. Прочитать issue и зависимости.
2. Проверить рабочее дерево.
3. Запустить worker.
4. Дождаться отчёта или запросить статус, если работа слишком долго молчит.
5. Запустить self-review того же worker-а.
6. Классифицировать находки.
7. Вернуть worker-у только выбранные правки.
8. Запустить проверки координатора.
9. Если задача рискованная, запустить reviewer-а.
10. Классифицировать reviewer-находки.
11. При больших исправлениях повторить reviewer-круг.
12. Сделать коммит.
13. Обновить исследовательскую заметку или итоговый лог, если эксперимент ещё
идёт.
Этот скелет должен быть параметризуемым. Например, для простой задачи можно
ограничиться worker + саморевью + проверки координатора. Для state/serialization,
интеграции сервиса, документации или статистической модели нужен reviewer.
## Оставшиеся вопросы
- Как заранее понять, когда нужен отдельный reviewer, а когда достаточно
саморевью?
- Стоит ли задавать worker-у обязательные промежуточные отчёты для статистики и
долгих тестов?
- Нужно ли явно ограничивать бюджет worker-а или число reviewer-кругов?
- Как лучше формализовать критерий «большое исправление требует второго
reviewer-а»?
- Должен ли будущий скилл сам создавать research log, или это должен быть
отдельный режим?
## Предварительная рекомендация
Эксперимент стоит считать успешным. Процесс дал не только рабочий результат, но
и поймал дефекты, которые легко могли пройти обычный одиночный поток:
- конфликт дефолтов интенсивности;
- падение на битом state v2;
- слишком большой state v2;
- неполная валидация компактного state;
- слабый интеграционный тест, не доказывавший уход от старой плоской модели.
Для будущего скилла ядро должно быть не «запусти много агентов», а
**координационный цикл с классификацией находок и контролем границ**.
Главная формула процесса:
```text
worker реализует -> worker сам себя ревьюит -> координатор фильтрует ->
worker чинит выбранное -> reviewer проверяет риск -> координатор фильтрует ->
при большом исправлении повторить reviewer -> координатор коммитит
```
## Внешнее ревью другой моделью (отложенный заход, 2026-06-14)
Отложенное ревью результата петли (его наметил handoff
`2026-06-11-generator-time-adr-and-pending-review.md`) выполнено. Ревью провела
**модель другой родословной** — та, что вела дизайн, ADR и спеки, а не линия
Кодекс, писавшая код. Это и есть проверка центральной гипотезы эксперимента:
какой класс дефектов автономная петля систематически не видит без внешнего
взгляда.
Основа проверки: перечитаны ADR-0004/0005 и обе спеки; код прочитан помодульно;
марковская модель путей прогнана симуляцией на 300 тыс. визитов; прогнан весь
набор тестов (113 passed); происхождение обёртки `try→fresh` прослежено по git.
### Прогноз по находке A не оправдался — петля её поймала
Handoff предсказывал, что петля пропустит ссылочную целостность при
`restore_state` («слепые зоны одной линии Кодекс скоррелированы»). На деле:
- висячая ссылка визит→пользователь ловится валидатором формы (`state.py:88`);
- висячая ссылка пользователь→словарь (неизвестный `seed_click_id`) валидатору
формы недоступна, но петля **в коммите задачи 06** (`640050e`) добавила обёртку
`try→fresh` вокруг `restore_state` в сервисе **и** написала регрессионный тест
`test_invalid_restored_v2_state_starts_fresh` (профиль с `missing-click-id`).
Это совпадает с разделом «Наблюдения по задаче 06»: и саморевью worker-а, и
отдельный reviewer независимо нашли «битый v2 проходит `from_dict_safe`, но
роняет `restore_state`».
Вывод: многоролевой цикл (worker + саморевью + отдельный reviewer) **поймал**
ровно тот класс дефекта, который ставился как главный индикатор эксперимента.
Прогноз о скоррелированной слепоте по этому пункту ошибочен.
### Что внешний взгляд всё же добавил
Дефекты, дожившие до внешнего ревью, оказались **не точечными, а сквозными** —
их не пинит ни один тест, и каждый по отдельности «проходит»:
- **Расхождение живого и восстановленного путей.** Живой визит берёт
браузер/локацию из *случайной* сид-сессии и `event_id` = `_new_uuid`
(`generation.py`), восстановленный — из сид-сессии *профиля* и `event_id` =
`uuid5` (`runtime.py`). У визита, пережившего рестарт, атрибуты «доезжающих»
событий меняются на середине. Инвариант «один визит — однородный контекст»
нарушается на стыке двух путей, который ни один тест не сводит вместе.
- **Форма распределения длины визита.** Среднее (10.7) и конверсия в
`/confirmation` (27%) на цели, воронка монотонна — это тесты проверяют и
подтверждают. Но *форма* дальше от сида: спайк 16% на длине 2 (форс минимума в
2 события убирает визиты-отказы из одного события, которые сид допускает),
~6.6% визитов срезаются о потолок 30, медиана 8 сидит на нижней границе
тестовой полосы 8–12. Тесты пинят средние и пороги, но не форму распределения.
### Уточнённая гипотеза
Главный индикатор A был *локальным* дефектом — падение в одной точке на битом
входе; того же семейства, что и прочие находки задачи 06 (раздутый снимок,
неполная валидация). Такие вещи дисциплинированный многоролевой цикл ловит
хорошо. А выжили до внешнего ревью свойства **межмодульные** (инвариант поверх
двух путей кода) и **распределительные** (форма, а не среднее) — ровно там, где
слабы и ревью внутри одной линии, и покритериальные тесты.
Отсюда уточнение к открытому вопросу «когда нужен отдельный reviewer»: ценнее
всего reviewer **другой родословной**, и именно там, где (а) свойство тянется
через несколько модулей/путей, либо (б) корректность распределительная (форма),
а не одно утверждение. Для точечных дефектов на входе хватает саморевью и
отдельного reviewer той же линии.
### Побочно подтвердилось
Разделение двух линий сработало: ADR-0005 (модельные часы ×K) остался решением
на бумаге, код петли честно держался реал-тайм-модели, нечаянного переплетения
линий нет. Процессное решение «не мешать брейншторм времени с петлёй 06/07»
выдержало.
## Связанные артефакты
- `.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md` —
исходный одноразовый handoff и рабочий журнал наблюдений.
- `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md`
- `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`
- `.scratch/feature-data-generator/issues/07-service-integration-and-docs.md`
- `docs/adr/0003-handoffs-in-scratch.md`
- `docs/adr/0002-specs-as-durable-design-docs.md`
+177
View File
@@ -0,0 +1,177 @@
# Runbook: стартовая история стенда
Этот runbook нужен, чтобы один раз создать стартовую историю генератора, сохранить
её в файл и быстро восстановить на чистом стенде.
За устройством генератора см. [`generator/README.md`](../../generator/README.md)
и [спеку модельного времени](../specs/2026-06-14-generator-model-time-and-startup-history.md).
## Что дёшево
- Перезапуск без очистки volumes: Kafka хранит `generator_state` и manifest.
- Live-продолжение после импорта: генератор стартует с `T_end`, если настройки
совпадают.
- Восстановление чистого стенда из готового файла: события повторно пишутся в
Kafka, ClickHouse наполняется штатным путём.
## Что требует нового артефакта
- Другая длительность истории или другой профиль запуска.
- Другой `GEN_SEED`, `GEN_MODEL_T0`, часовой пояс, скорость или настройки
генерации.
- Осознанный новый мир после несовместимого state: сначала сбросьте state через
`GEN_STATE_RESET=true` или чистые volumes.
## Профили запуска
Основной способ — глагол плюс профиль:
| Профиль | Для чего | Длительность | Live-ход |
|---------|----------|--------------|----------|
| `ci` | Быстрая проверка и CI | `6h` | ×1, тик 60 с |
| `daily-wave` | История с видимой суточной волной | `2d` | ×60, тик 1 с |
Длительность можно переопределить через `GEN_HISTORY_DURATION`, например `2d`.
Команда сама считает `GEN_MODEL_T_END` от `GEN_MODEL_T0`.
## Пульт в Airflow
Основной ручной путь — DAG `generator_control` в Airflow UI:
1. Поднимите стенд: `make up`.
2. Если DDL ещё не применён, запустите `ddl_init`.
3. Снимите паузу с `etl_pipeline`, если он ещё paused:
`docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`.
4. Откройте `generator_control` и выберите `operation`.
Операции:
- `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает
`etl_pipeline`, ждёт завершения и выполняет `check`.
- `import` — прочитать артефакт из `artifact_path`. Несовместимый артефакт
отклоняется до записи в Kafka.
- `check` — сверить ClickHouse с manifest из Kafka.
Поля формы:
- `profile` берётся из профилей генератора.
- `duration` можно оставить пустым, тогда берётся длительность профиля.
- `seed` и `model_time_speed` — необязательные переопределения мира.
- `artifact_path`: для `backfill` — куда сохранить файл; пусто — не сохранять.
Для `import` — что читать; пусто — `/opt/airflow/data/startup-history-import.json`.
Backfill и import работают только на чистом стенде. Если Kafka-топики данных или
STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Консольные
команды `make generator-backfill` и `make startup-history-import` делают такую же
предпроверку с хоста. Это защита от смешивания разных миров.
Границы пульта:
- `make up`, `make clean` и live-продолжение остаются в консоли.
- Операции `continue` в DAG нет намеренно: live — долгоживущий сервис, а пульт
управляет разовыми пакетными операциями.
- Airflow не получает доступ к жизненному циклу контейнеров; таски выполняют обычный
Python-код генератора.
Если backfill сохраняет файл в `./data`, он создаётся пользователем Airflow
внутри контейнера. Чтение работает из Airflow и консольных команд, но перезапись
чужого файла может потребовать удалить старый файл вручную.
## Экспорт
По умолчанию создаётся быстрый 6-часовой артефакт:
```bash
make startup-history-export
```
Файл по умолчанию: `/tmp/clickstream-startup-history.json`.
История на 2 суток с суточной волной. В live-продолжении этот профиль проживает
модельные сутки примерно за 24 настенные минуты:
```bash
ARTIFACT=/tmp/clickstream-startup-history-2d.json \
PROFILE=daily-wave \
make startup-history-export
```
Команда делает чистый backfill и пишет в файл один связный набор: события Kafka,
state и manifest.
## Импорт на чистый стенд
```bash
make clean
docker compose up -d clickhouse kafka
make ddl
ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-import
sleep 10
make transform
ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-check
CHECK_LIVE_SEAM=0 make generated-history-check
```
Это путь только импорта: live-строк после `T_end` ещё нет. Стык backfill/live
проверяйте через `make generated-history-runtime-check` или после
`make generator-continue` и повторного `make transform`.
Импорт не пишет напрямую в ClickHouse. Он воспроизводит события и служебные
compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и
Materialized View, затем batch строит ODS, DDS и DM.
Если артефакт создан не профилем `ci`, импорт запускайте с тем же профилем:
```bash
PROFILE=daily-wave ARTIFACT=/tmp/clickstream-startup-history-2d.json \
make startup-history-import
```
`make startup-history-check` сверяет контрольные числа DM-витрины с manifest
артефакта: события, визиты, пользователей и диапазон `event_timestamp`. Если
data-топики Kafka уже непустые, импорт остановится до публикации событий.
В текущем стеке `kafka-python` не даёт транзакционный producer для нескольких
топиков. Поэтому импорт остаётся clean-stand операцией: при ошибке записи он
удаляет import-топики Kafka, чтобы повторный импорт не дописал дубли. Если
ClickHouse уже успел прочитать частичные сообщения, очистите стенд через
`make clean` и повторите импорт.
## Live-продолжение
После импорта запускайте live с тем же профилем, что был в артефакте. Для
артефакта `daily-wave`:
```bash
PROFILE=daily-wave make generator-continue
```
У `daily-wave` скорость ×60. Долгий простой стенда создаёт большую дыру в
модельном времени: ночь простоя может стать десятками модельных суток без
событий. Для чистой демонстрации лучше очистите стенд, затем запустите
`make generator-reset` или повторите импорт стартовой истории через
`make startup-history-import`.
`make generator-reset` начнёт новый live-мир только на чистом стенде; если в
Kafka data-топиках или STG уже есть строки, команда попросит `make clean`.
Если читаемый state есть, но настройки не совпадают, генератор падает с перечнем
полей. Это защита от смешения разных миров. Для намеренного нового мира
сначала очистите стенд через `make clean`, затем запускайте нужный сценарий.
После обновления кода старый state может оказаться в старом формате. При
`GEN_STATE_RESET=false` это теперь громкий отказ, а не тихий старт с нуля поверх
старой истории. Оператору нужно выбрать одно из двух: очистить стенд через
`make clean` и заново создать стартовую историю, либо осознанно начать новый
live-мир через `make generator-reset` на чистом стенде.
После нестандартного мира `make generator-continue` нужно запускать с теми же
настройками, что были у backfill/import. При расхождении генератор громко
покажет поля, которые не совпали.
Старые артефакты `daily-wave`, созданные до перехода на ×60 и тик 1 с, с новым
профилем несовместимы. Это ожидаемо: защита от смешения миров должна остановить
такой запуск.
Старые переменные `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END` остаются
низкоуровневым способом для отладки и прямого `docker compose run`.
@@ -32,8 +32,9 @@
## Non-goals ## Non-goals
- Не модернизируем **типы** виджетов (`pie`/`world_map`/`dist_bar` → ECharts) — - Не модернизируем **типы** виджетов (`pie`/геовизуализация/`dist_bar` → ECharts) —
отдельный косметический заход. отдельный косметический заход. Геоблок позже заменён задачей 10
`generator-model-time-startup-history`.
- Не трогаем эмодзи в тайтлах, секции-заголовки, языковой винегрет. - Не трогаем эмодзи в тайтлах, секции-заголовки, языковой винегрет.
- Не трогаем генератор. - Не трогаем генератор.
- Не правим код `create_dashboard.py` в рамках этой спеки — это реализация. - Не правим код `create_dashboard.py` в рамках этой спеки — это реализация.
@@ -60,7 +61,7 @@ geo_country: 40 | device_type: 2 (Mobile/Computer) | utm_source: 5 | utm_medium:
| Avg Events/Session | big_number_total | оставить, переименовать `/Visit` | | Avg Events/Session | big_number_total | оставить, переименовать `/Visit` |
| Top Pages | dist_bar | **апгрейд в Funnel** (центральный учебный объект) | | Top Pages | dist_bar | **апгрейд в Funnel** (центральный учебный объект) |
| UTM Effectiveness | table | оставить; **выкинуть колонки purchases/add_to_cart** (всегда 0) | | UTM Effectiveness | table | оставить; **выкинуть колонки purchases/add_to_cart** (всегда 0) |
| Geography Map | world_map | оставить (40 стран; тип модернизировать отдельно) | | Geography Map | прежняя геовизуализация | позже заменена на `Top Countries by Events` |
| Traffic by Device | pie | оставить | | Traffic by Device | pie | оставить |
| Events by Hour | line | **проверить на пустоту**, иначе дропнуть | | Events by Hour | line | **проверить на пустоту**, иначе дропнуть |
| Data Quality Summary | dist_bar | оставить (⚠️ позже пересмотрено — см. ниже) | | Data Quality Summary | dist_bar | оставить (⚠️ позже пересмотрено — см. ниже) |
@@ -0,0 +1,185 @@
# Переработка генератора: иерархическая модель сущностей для steady-stream
Дата: 2026-06-09
Статус: Draft
Связано: [ADR-0004](../adr/0004-steady-stream-synthetic-generator.md) (решение и
почему), [`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md) (диагноз
дефекта), [`CONTEXT.md`](../../CONTEXT.md) (доменный язык),
[ADR-0002](../adr/0002-specs-as-durable-design-docs.md)
## Problem
Курс на статическом сиде показывает только маленький одноразовый батч и не может
показать, как стенд **живёт и движется**: непрерывный поток через пайплайн,
шевелящиеся дашборды, поведение ClickHouse и витрин на живых данных. Текущий
генератор источником быть не может — у него концептуальный дефект модели
сущностей (свежий `click_id` на каждое событие схлопывает иерархию
`пользователь → сессия → событие`, см. `KNOWN_ISSUES.md`). Нужен переписанный
с нуля генератор, дающий правдоподобный живой поток.
## Context
- **Решение направления уже принято** в [ADR-0004](../adr/0004-steady-stream-synthetic-generator.md):
синтетический иерархический генератор, не реплей сида. Эта спека — про *форму*
доработки, не про выбор «генератор vs реплей».
- **Двойная учебная ценность** (критерий ADR-0004): ценны и данные (живой стенд +
честная пирамида), и сам генератор как объект изучения. Дизайн должен быть
пригоден для разбора в курсе, а не только «работать».
- **Доменный язык** зафиксирован в `CONTEXT.md`: пользователь = `user_domain_id`,
визит/сессия = `click_id`, событие = `event_id`; здоровые данные дают пирамиду
`users ≤ sessions ≤ events`. Спека добавляет термины «популяция пользователей»,
«возвращающийся пользователь».
- **Воронка в данных живёт в `page_url_path`, не в `event_type`.** В сиде
`event_type` = 100% `pageview` (других типов нет); воронка реализована
страницами: `/home → /product_a + /product_b → /cart → /payment →
/confirmation` (терминальный шаг — `/confirmation`). Дашборд так и устроен
(Page Funnel, конверсия `/confirmation ÷ /home`). Схема `event_type`
*допускает* и другие значения (click/purchase/add_to_cart), но в данных их
нет. `user_domain_id` — на уровне `click` (device_events), device/geo —
сессионный контекст. См. `sql/ddl/dds/30_dds.sql`, `CONTEXT.md`.
- **Что сохраняем без изменений:** модель интенсивности
(`_calculate_events_count()` — Poisson + `_hour_factor()` + jitter) —
`KNOWN_ISSUES.md` подтверждает её корректность.
## Goals
- Поток порождает **здоровую пирамиду** `users < sessions < events`: популяция
возвращающихся пользователей, у пользователя 1..N сессий, у сессии 1..M событий.
- `click_id` **переиспользуется внутри сессии** (envelope из нескольких событий с
общим device/geo), а не штампуется на каждое событие.
- **Время событий внутри сессии монотонно растёт** (правдоподобный путь по
страницам), а не равно `now()` для всего батча.
- **Правдоподобная воронка** по `page_url_path`: путь по страницам
(`/home → … → /confirmation`), где число визитов затухает от шага к шагу,
а до последнего шага воронки доходит меньшинство (в сиде ~25%) —
согласованно с дашбордом и сидом.
- Сохранена модель интенсивности (жизнеподобные колебания нагрузки).
- Встраивается в курс как **урок 7 «Система живёт»**, сосуществует с bootstrap-сидом
и уроками 0–6.
## Non-goals
- **Не закладываемся на объём/стресс.** Скромный rate, рассчитанный на слабое
железо менти; цель — «видно жизнь и движение», а не нагрузочное тестирование.
- **Не выбираем здесь распределения и прочую математику** — конкретные модели
(события/сессия, сессии/пользователь, межсессионные паузы, нужен ли настоящий
session-timeout, персистентность популяции через рестарты) выносятся в
**отдельную follow-up-спеку**.
- **Не трогаем** статический сид как bootstrap, базовые уроки 0–6, витрины и
дашборд (строятся на сиде).
- **Не дорабатываем текущую реализацию инкрементально** — переписываем с нуля
(см. ADR-0004 и `KNOWN_ISSUES.md`).
- **Не пишем сам урок 7** в рамках этой спеки — это материал по `LESSON_STANDARD`
на этапе реализации.
## Current state
`generator/generator.py`, `generate_batch()` — плоская выборка на каждое событие:
свежий `click_id`, атрибуты копируются из случайной сид-строки, время ≈ `now()`
для всего батча. Итог на потоке: `Sessions == Events` (сессия = одно событие),
`user_domain_id` переиспользуется из сид-пула (~99) без привязки к сессиям.
Полный разбор и таблица «сид vs поток» — в `generator/KNOWN_ISSUES.md`.
Корректная часть, которую сохраняем: `_calculate_events_count()`
(Poisson + дневной коэффициент + jitter).
## Recommended approach
Иерархическая генерация вместо плоской выборки. На уровне требований (без выбора
конкретных распределений — это follow-up-спека):
1. **Популяция пользователей.** Поддерживать множество пользователей с
*постоянными* `user_domain_id`. Пользователь может **возвращаться**
несколькими сессиями во времени. (Насколько device/geo стабильны между
сессиями одного пользователя — моделирующее допущение; по схеме device/geo —
сессионный контекст уровня `click`. Степень стабильности — в follow-up-спеке.)
2. **Сессии пользователя.** Пользователь со временем открывает 1..N сессий; каждая
сессия — новый `click_id`, общий для всех её событий, с общим device/geo.
Межсессионные паузы — модель «вернувшегося пользователя».
3. **События сессии.** Сессия порождает 1..M событий, разделяющих `click_id`,
**упорядоченных по монотонно растущему времени**, образующих правдоподобный
путь по воронке страниц (`page_url_path`: `/home → … → /confirmation`), где до
последнего шага воронки доходит меньшинство визитов (~25%, как в сиде).
(Вводить ли разнообразие `event_type` сверх `pageview` — открытый вопрос,
см. ниже.)
4. **Интенсивность.** Поверх иерархии работает сохранённая модель интенсивности —
сколько событий и когда (Poisson + дневной коэффициент + jitter).
Связь с тиковой моделью генератора (как «развернуть» иерархию во времени по тикам,
а не одним батчем) — открытый вопрос дизайна, см. ниже.
## Rationale
- Иерархия — единственный способ получить возвраты и честную пирамиду
(обоснование выбора против реплея — в ADR-0004).
- Монотонное время и `click_id`-на-сессию делают воронку и сессии настоящими
учебными объектами, а не артефактами.
- Сохранение модели интенсивности минимизирует объём rewrite и риск: переписываем
только модель сущностей, корректную математику интенсивности не трогаем.
## Risks and mitigations
- **Стыковка иерархии с тиковой/потоковой моделью.** Иерархия естественно
«батчевая» (сессия = последовательность), а генератор тиковый и стремится к
`now()`. Риск снова получить время ≈ `now()` или порвать сессии между тиками.
Митигейшн: в follow-up-спеке явно спроектировать, как сессия раскладывается во
времени по тикам (например, активные сессии как состояние генератора).
- **Правдоподобие против воспроизводимости.** `GEN_SEED` должен давать
воспроизводимый поток и при иерархической модели.
- **Персистентность популяции через рестарты.** Текущий state-в-Kafka хранит tick
и RNG; популяция пользователей/активные сессии — новый вид состояния. Решается
в follow-up-спеке (возможно, расширением `generator_state`).
- **Скромное железо.** Держать rate низким по умолчанию; иерархия не должна
раздувать память (ограничивать размер активной популяции/сессий).
## Validation
Критерии приёмки переработанного генератора (на потоке, не на сиде):
- `uniqExact(user_domain_id) < uniqExact(click_id) < count(event_id)` —
пирамида не вырождена (есть возвраты и мультисобытийные сессии).
- У `click_id` с несколькими событиями время строго **возрастает**
(`event_timestamp` в источнике, `event_ts` в DDS — проверять можно в любой
точке); мультисобытийные визиты составляют заметную долю потока
(одностраничные визиты-отказы допустимы и правдоподобны).
- Один `click_id` принадлежит ровно одному `user_domain_id`; у пользователя
бывает несколько `click_id`.
- Воронка по `page_url_path` затухает от шага к шагу; доля визитов, доходящих
до `/confirmation`, согласована с сидом (~25%). `/confirmation` — последний
шаг воронки, но визит может продолжаться и после него (как в сиде).
- Поток крутится на слабом железе при дефолтном rate без деградации стенда.
- `GEN_SEED` даёт воспроизводимый поток.
## Documentation impact
- `generator/README.md` и `generator/KNOWN_ISSUES.md` — обновить **в момент
реализации**: снять предупреждение о дефекте, описать новую модель.
- `CONTEXT.md` — добавить «популяция пользователей» / «возвращающийся
пользователь» (в этой итерации брейнсторма уже вносится).
- `docs/course/` — добавить урок 7 «Система живёт» по `LESSON_STANDARD` (на этапе
реализации); README курса — строку в таблицу уроков.
- `README.md` (корень) — режим `steady-stream` уже описан; синхронизировать при
реализации.
## Open questions (→ follow-up-спека по математике)
> **Закрыто 2026-06-10:** все вопросы ниже решены в
> [спеке математической модели](./2026-06-10-generator-math-model.md)
> (распределения, ротация популяции, инвариант пауз вместо session-timeout,
> активные сессии как состояние, персистентность, воспроизводимость;
> `event_type` остаётся 100% `pageview`). Список сохранён для истории.
- Конкретные распределения: события на сессию, сессии на пользователя за период,
межсессионные интервалы.
- **Вводить ли разнообразие `event_type`** (purchase/add_to_cart/click) сверх
`pageview`. Сейчас и сид, и дашборд исходят из 100% `pageview` (воронка —
страничная); добавление типов оживило бы выкинутые из дашборда колонки
purchases/add_to_cart и требует согласованной правки урока 6/дашборда. То есть
это не локальное решение генератора — взвесить в follow-up-спеке.
- Вводить ли настоящий **session-timeout** (окно неактивности как граница сессии)
— сейчас `CONTEXT.md` фиксирует `сессия ≡ визит ≡ click_id` без тайм-аута.
- Как иерархия раскладывается во времени по тикам генератора (модель активных
сессий как состояние).
- Персистентность популяции пользователей и активных сессий через рестарты
(расширение `generator_state`).
- Воспроизводимость потока при иерархической модели и `GEN_SEED`.
@@ -0,0 +1,283 @@
# Математическая модель генератора: распределения, тики, персистентность
Дата: 2026-06-10 (ревизия после адверсариального ревью в тот же день)
Статус: Draft
Связано: [ADR-0004](../adr/0004-steady-stream-synthetic-generator.md) (решение
«генератор, не реплей»), [спека формы доработки](./2026-06-09-generator-rework-hierarchical.md)
(требования к иерархии; эта спека закрывает её раздел *Open questions*),
[`CONTEXT.md`](../../CONTEXT.md) (доменный язык и **профиль сид-датасета** —
опорные цифры для калибровки), [`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md)
(диагноз дефекта).
Уровень документа: **модель и инварианты**. Детальный план реализации и код —
за исполнителем; конкретные технические решения (структуры данных, точные
алгоритмы розыгрыша, имена новых переменных окружения, не названные здесь) он
строит сам в рамках зафиксированных здесь правил.
## Картина целиком
Генератор имитирует жизнь сайта тремя уровнями:
```
популяция пользователей — кто вообще ходит на сайт
└── визиты (сессии, click_id) — пользователь время от времени заходит
└── события (event_id) — за один заход ходит по страницам
```
Поверх иерархии — сохранённая модель интенсивности (Пуассон + часовой
коэффициент + jitter из `_calculate_events_count()`): она задаёт, *сколько*
активности происходит в единицу времени. Путь по страницам внутри визита
задаёт **марковская цепочка** — таблица вероятностей переходов между страницами.
## Принятые решения (развилки закрыты 2026-06-10)
1. **Типы событий: 100% `pageview`.** Воронка остаётся страничной
(`page_url_path`), как в сиде и на дашборде. Другие типы
(purchase/add_to_cart/click) — отдельная итерация после урока 7: они тянут
согласованную правку дашборда и урока 6, что выходит за рамки переработки.
2. **Session-timeout не вводим.** Генератор порождает визиты явно и знает их
границы по построению; правило «молчал полчаса — новая сессия» — это приём
*восстановления* сессий из событий, который здесь не нужен. `CONTEXT.md`
(сессия ≡ визит ≡ `click_id`) не меняется. Вместо механизма — **инвариант
пауз** (см. ниже): данные не должны противоречить привычной 30-минутной
семантике.
3. **Популяция: постоянное ядро + медленная ротация.** Размер активной
популяции ограничен (память не растёт), но новые пользователи понемногу
приходят, давно неактивные — выбывают. Кумулятивное число уникальных
пользователей растёт со временем — стенд выглядит живым. «Медленно» — в
масштабе наблюдения: на ориентирах по умолчанию ядро полностью сменяется
примерно за 11 часов (это осознанный баланс: возвраты видны за вечер,
рост uniques — за сутки).
4. **Путь по страницам — марковская цепочка.** Для каждой страницы задана
вероятность перехода на каждую другую страницу или ухода с сайта. Один
механизм даёт сразу: длину визита, затухающую воронку, петли (возвраты на
главную, просмотр обоих товаров) и «походил после покупки» — всё то, что
реально есть в сиде. Простая «затухающая цепочка без петель» рассмотрена и
отклонена: она не может дать длины визитов как в сиде (медиана 10, до 27
событий) и беднее как учебный объект. Таблица переходов — готовая «ручка»
для урока 7: менти меняет вероятность и видит эффект на дашборде.
## Модель по уровням
### Популяция пользователей
- Активная популяция — ограниченное множество пользователей с постоянными
`user_domain_id`, не больше `GEN_POPULATION_MAX`.
- **Инициализация:** при старте с чистого листа популяция сразу предзаполняется
до потолка (детерминированно от `GEN_SEED`) — поток выходит на стационарный
режим с первых минут, без длинного «разогрева».
- **Ротация через рождение визитов:** каждый новый визит с вероятностью
`GEN_P_NEW_USER` достаётся *новому* пользователю (свежий `user_domain_id`),
иначе — пользователю из популяции, **доступному** для возврата (см. кулдаун
ниже). При переполнении популяции вытесняется дольше всех неактивный
пользователь **без активного визита**. Так приток и отток получаются из
одного простого правила.
- **Профиль пользователя:** при рождении пользователь получает полный
device/geo-контекст одной случайной сид-сессии (включая `user_custom_id`;
повторы email между пользователями допустимы — демо-данные) с заменой
`user_domain_id` на свежий. Профиль используется во всех визитах пользователя
(KISS: смену устройства не моделируем). В состоянии профиль хранится
**ссылкой на сид-сессию** (её `click_id`) плюс свежие идентификаторы, а не
копией всех полей — состояние остаётся компактным.
### Визиты (сессии)
- Визит = новый `click_id`, общий для всех его событий, с device/geo из профиля
пользователя.
- **Каденция публикации device/geo — как в сиде и у текущего генератора:**
записи `device_events` и `geo_events` публикуются **на каждое событие**
(в пределах визита дублируются с одинаковым содержимым; в сиде это
проверено: 1000 строк при 99 уникальных по содержимому). Контракт данных с
ETL/ODS не меняется — это требование, а не деталь реализации.
- **Связка с моделью интенсивности.** `_calculate_events_count()` сохраняется и
выдаёт на тик *бюджет событий*. Бюджет конвертируется в рождения визитов:
ожидаемое число новых визитов за тик = бюджет ÷ средняя длина визита
(средняя длина — производная калиброванной таблицы переходов, исполнитель
измеряет её симуляцией). События же выпускаются по внутрисессионным паузам.
Следствие: фактическое число событий за конкретный тик — производная величина,
но *средняя* интенсивность за длинное окно совпадает с целевой. Прежние
границы `GEN_MIN/MAX_EVENTS_PER_TICK` в старом смысле теряют применимость;
ограничивать нужно рождения визитов за тик (имена и значения — за
исполнителем, зафиксировать в README).
- **Кулдаун возврата:** после завершения визита пользователь недоступен для
нового минимум `GEN_MIN_RETURN_MINUTES` (ориентир 30 мин) — это гарантирует
нижнюю границу межсессионной паузы (инвариант пауз).
- **Выбор возвращающегося:** равновероятно среди **доступных** пользователей
(не в кулдауне и без активного визита) — это даёт естественный разброс пауз.
Если доступных нет (краевой случай при экстремальных параметрах) — визит
достаётся новому пользователю.
- **Межсессионные паузы — эмерджентная величина, не свободный параметр.**
В стационарном режиме полный цикл пользователя (визит + пауза) определяется
балансом потока и популяции — кулдаун уже «сидит» внутри этого баланса и
отдельно не прибавляется (он задаёт только нижнюю границу каждой паузы):
```
средняя пауза ≈ GEN_POPULATION_MAX ÷ (λ ÷ L × (1 − GEN_P_NEW_USER)) − средняя длительность визита
где λ — событий/мин, L — средняя длина визита
```
На ориентирах по умолчанию (λ=30, L≈10, популяция 300, p_new=0.15):
возвратов ≈ 2.6/мин, цикл ≈ 118 мин, средняя пауза ≈ **~110 мин (~2 часа)**;
медиана ниже среднего (распределение скошено вправо), порядка 1.5 ч — всё
равно сильно больше 30-минутного окна, и возвраты видны уже за один вечер
работы стенда. **Кто меняет λ или размер популяции — обязан пересчитать
паузу по формуле**: эти величины связаны, их нельзя крутить независимо.
Ориентиры здесь посчитаны при L=10; после калибровки таблицы переходов
производные числа пересчитываются от измеренного L.
### События и путь по страницам
- **Марковская цепочка:** страницы — `/home`, `/product_a`, `/product_b`,
`/cart`, `/payment`, `/confirmation` плюс исход «ушёл». Визит начинается со
страницы, разыгранной по **стартовому распределению** (оно — часть модели:
в сиде `/home` — лишь ~59% входов, остальные входят на страницы товаров),
дальше каждый следующий шаг разыгрывается по таблице переходов текущей
страницы. Петли разрешены (вернуться на главную, посмотреть оба товара,
продолжить ходить после `/confirmation` — как в сиде). Защита от
зацикливания — потолок длины визита `GEN_MAX_SESSION_EVENTS` (ориентир 30,
как максимум в сиде).
- **Калибровка по сиду:** стартовое распределение и значения таблицы подбирает
исполнитель под целевые показатели из профиля сида (см. `CONTEXT.md`): доля
визитов, достигших `/confirmation`, ≈ 25%, длина визита с медианой ~10 и
**средним ~10** (в сиде среднее ≈ медиане; у цепочки с геометрическим
хвостом среднее легко уезжает выше — следить за обоими). Точного совпадения
распределений не требуется — требуется сопоставимость, чтобы дашборд на
потоке показывал привычные по сиду цифры.
- **Паузы внутри визита:** интервал между соседними событиями — величина
масштаба *секунд—минут* (например, лог-нормальное: большинство пауз короткие,
изредка длинные). Ориентир: медиана десятки секунд. Форма распределения и
параметры — за исполнителем.
- **Инвариант пауз (вместо session-timeout):** все паузы внутри визита строго
меньше 30 минут (с запасом: 95-й перцентиль — единицы минут); межсессионные
паузы — всегда больше кулдауна. Данные не должны противоречить индустриальной
семантике 30-минутного окна неактивности. (Это сознательно строже сида, где
встречаются внутрисессионные паузы до 40 минут.)
- **Время монотонно:** визит живёт несколько тиков, и каждому его событию
заранее назначается *запланированный* момент (предыдущее событие + пауза).
В `event_timestamp` пишется именно запланированный момент, а не время
фактической отправки на тике — иначе метки прилипают к сетке тиков (паузы
кратны 5 с, события одного тика слипаются в одну метку). В штатной работе
запланированное время отстаёт от «сейчас» не больше чем на тик; монотонность
внутри `click_id` строгая по построению.
### Раскладка по тикам (активные визиты как состояние)
Визит длится дольше тика (5 с), поэтому генератор держит **активные визиты**
как состояние между тиками. Для каждого активного визита достаточно знать: чей
он, `click_id`, текущая страница, момент следующего события. На каждом тике
генератор:
1. выпускает события тех активных визитов, у которых подошло время;
2. рождает новые визиты по бюджету интенсивности (см. выше);
3. завершает визиты, чей путь закончился (исход «ушёл» или потолок длины).
Число одновременно активных визитов ограничено (`GEN_MAX_ACTIVE_SESSIONS`) —
защита памяти; при достижении потолка новые рождения в этот тик пропускаются
(бюджет не копится). На ориентирах по умолчанию стационарно активны ~15–30
визитов — потолок с большим запасом. **Инвариант конфигурации:**
`GEN_MAX_ACTIVE_SESSIONS < GEN_POPULATION_MAX` — иначе правило вытеснения
(«без активного визита») может не найти кандидата; валидировать при старте,
как существующие проверки `Config`.
## Персистентность через рестарты
> **Приведено в соответствие с модельным временем.** Раздел написан от настенных
> часов. Правила сохранения и восстановления состояния переописаны в спеке
> [модельного времени](./2026-06-14-generator-model-time-and-startup-history.md)
> (§«Сохранение и восстановление состояния»): те же 30 минут отсчитываются по
> часам генератора. Ниже — исходная формулировка, оставлена как след решения; при
> расхождении главенствует новая спека.
- Расширить состояние в compact-топике `generator_state` (новая `version`):
к тику и состоянию ГПСЧ добавляются **популяция** (компактно: идентификаторы,
профили, время последней активности) и **активные визиты**.
- Объём состояния ограничен потолками популяции и активных визитов — запись в
Kafka остаётся маленькой (десятки КБ).
- **Рестарт после простоя — «правило 30 минут», повизитно:** активный визит,
чьё следующее событие запланировано не дальше 30 минут в прошлом, продолжается —
созревшие за простой события досылаются со своими (прошлыми, честными)
метками. Визит, просроченный сильнее, закрывается без досылки остатка —
посетители «ушли», пока стенд спал. Популяция переживает простой любой длины;
кулдауны отсчитываются по меткам времени, не по тикам.
- Уже существующая деградация сохраняется: невалидное/отсутствующее состояние →
начать с чистого листа (свежая популяция от `GEN_SEED`), предупреждение в лог.
`GEN_STATE_RESET=true` — явный сброс, как сейчас.
## Воспроизводимость (`GEN_SEED`)
> **Приведено в соответствие с модельным временем.** Оговорка ниже — «запуск в
> другой час дня даёт другой поток» — снята: с модельными часами дневной
> коэффициент считается от точки отсчёта `T0`, а не от настенных часов.
> Переописано в спеке
> [модельного времени](./2026-06-14-generator-model-time-and-startup-history.md)
> (§«Повторяемость»). Ниже — исходная формулировка, оставлена как след решения.
При одном `GEN_SEED` **и одинаковых условиях запуска** детерминирована
последовательность решений: какие пользователи родились, какие визиты открылись,
какие пути выпали. Оговорка про условия существенна: часовой коэффициент и
метки времени зависят от настенных часов, поэтому запуск в другой час дня даёт
другой поток (как и у текущего генератора). Все случайные решения — только
через единый ГПСЧ генератора, состояние которого сохраняется; как зафиксировать
условия в тесте воспроизводимости — за исполнителем.
## Параметры (ориентиры по умолчанию)
Согласованный набор (см. формулу связи в разделе про визиты); дефолты —
ориентиры, исполнитель уточняет при калибровке.
| Параметр | Ориентир | Смысл |
|---|---|---|
| `GEN_LAMBDA_BASE_PER_MIN` | **30** (сейчас 200) | целевая интенсивность, событий/мин |
| `GEN_POPULATION_MAX` | 300 | потолок активной популяции |
| `GEN_P_NEW_USER` | 0.15 | доля визитов, достающихся новым пользователям |
| `GEN_MIN_RETURN_MINUTES` | 30 | кулдаун возврата пользователя |
| `GEN_MAX_SESSION_EVENTS` | 30 | потолок длины визита (защита от петель) |
| `GEN_MAX_ACTIVE_SESSIONS` | 200 | потолок одновременных визитов |
| средняя длина визита | ~10 событий | производная таблицы переходов (калибровка по сиду) |
| пауза внутри визита | медиана ~десятки секунд | распределение — за исполнителем |
| межсессионная пауза | ≈ 2 ч | эмерджентная, по формуле |
Таблица переходов и параметры распределения пауз — тоже конфигурация
(формат и имена — за исполнителем; таблица должна быть доступна менти для
экспериментов урока 7). Существующие `GEN_TICK_SECONDS`, `GEN_JITTER_PCT`,
`GEN_SEED`, `GEN_STATE_*` сохраняют смысл.
## Критерии приёмки
Дополняют раздел *Validation* [спеки формы доработки](./2026-06-09-generator-rework-hierarchical.md)
(пирамида, монотонность времени, принадлежность `click_id` одному пользователю,
воспроизводимость — там; здесь не дублируются):
- Средняя интенсивность событий за длинное окно (час и больше) соответствует
целевой `λ × часовой коэффициент` с разумным отклонением.
- Паузы внутри `click_id`: 95-й перцентиль — единицы минут, максимум < 30 минут.
- Межсессионные паузы одного пользователя: минимум ≥ кулдауна, **среднее** —
порядка расчётного по формуле (~2 ч на дефолтах; медиана ниже, ~1.5 ч).
- Доля визитов новых пользователей за длинное окно ≈ `GEN_P_NEW_USER`;
кумулятивное число уникальных `user_domain_id` растёт со временем, размер
состояния генератора — нет.
- Длина визита (медиана ~10, максимум ≤ потолка) и доля дошедших до
`/confirmation` (~25%) сопоставимы с профилем сида из `CONTEXT.md`.
- Воронка затухает по шагам: число визитов, посетивших шаг, монотонно убывает
вдоль `/home → товары → /cart → /payment → /confirmation` (шаг «товары» —
визит посетил хотя бы одну из страниц товаров; так же считает дашборд).
- После рестарта с простоем ≤ 30 минут активные визиты продолжаются (нет
скачка «все пользователи новые»); после долгого простоя закрываются только
просроченные визиты, популяция сохраняется.
## Влияние на документацию
- Спека формы доработки: раздел *Open questions* закрыт ссылкой на этот
документ; критерии Validation уточнены по фактическому профилю сида.
- `CONTEXT.md`: добавлен **профиль сид-датасета** (измеренные распределения —
опора калибровки) и исправлено неверное утверждение о разбросе времени внутри
`click_id` (сделано вместе с этой ревизией).
- `generator/KNOWN_ISSUES.md`, ADR-0004: исправлен неверный факт «1..7 событий
на визит» (по полному замеру — 1..27, медиана 10).
- Урок 7 «Система живёт» (пишется на этапе реализации): марковская таблица
переходов — учебная «ручка» урока (поменяй вероятность → увидь сдвиг
конверсии на дашборде). Оговорка скоупа: «увидеть на дашборде» предполагает,
что поток доезжает до витрин — это зависимость урока 7 от инкрементальной
загрузки ETL (план v2), а не требование к генератору.
- `generator/README.md` — обновляется при реализации; туда же — верхнеуровневый
обзор «как работает генератор» (два контура: интенсивность и сущности).
@@ -0,0 +1,142 @@
# Уборка сервиса генератора перед активными визитами
Дата: 2026-06-11
Статус: Draft
Связано: [спека математической модели](./2026-06-10-generator-math-model.md),
[спека иерархической переработки](./2026-06-09-generator-rework-hierarchical.md),
[`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md),
задачи `.scratch/feature-data-generator/issues/01..07`.
## Problem
После первых двух срезов новый генератор уже умеет строить связанный визит и
путь по страницам, но сервисный код начал распухать. В одном
`generator/generator.py` смешаны генеративная модель, чтение сида, тиковая
логика, Kafka, состояние, история пачек, метрики и запуск сервиса.
Следующая задача — активные визиты между тиками — добавит новый слой состояния.
Если продолжить в текущей форме, этот слой ляжет поверх временного
`generate_tick_batch()`, который набирает тик целыми визитами и не соответствует
целевой модели.
## Goals
- Разнести код генератора по понятным учебным модулям без изменения внешнего
поведения сервиса.
- Подготовить место для задачи 03: активные визиты должны появиться в отдельном
тиковом слое, а не внутри большого `generator.py`.
- Явно отделить временные части старой модели от контрактов, которые надо
сохранить.
- Сохранить прежние команды запуска, Kafka-топики, формат сообщений и полезные
метрики.
## Non-goals
- Не реализовывать активные визиты из задачи 03.
- Не добавлять популяцию пользователей, возвраты, калибровку интенсивности или
состояние версии 2.
- Не менять DDL, ETL, витрины или Superset.
- Не делать большой перепил поведения генератора под видом уборки.
## Current state
`generator/generator.py` содержит около тысячи строк и несколько разных
ответственностей:
- `Config` — настройки;
- `EventDictionary` — чтение и индексация JSONL-сида;
- `EventGenerator` — генерация визитов, страниц, пауз и интенсивности;
- `BatchRecord`, `KafkaBatchHistory` — история пачек в Kafka;
- `GeneratorState`, `KafkaStateManager` — состояние версии 1;
- `KafkaPublisher`, `ensure_topics` — работа с Kafka;
- `GeneratorService` — метрики, запуск, основной цикл.
`generate_tick_batch()` сейчас является переходным механизмом: он добирает
событийный бюджет полными визитами. Это допустимо после задач 01-02, но должно
быть заменено в задаче 03 тиковым слоем с активными визитами.
## Options considered
- **Минимальная уборка в одном файле.** Быстро, но файл останется тяжёлым и
следующая задача снова его раздует.
- **Разнести код по модулям.** Чище для чтения, лучше соответствует учебной
цели и даёт место для активных визитов.
- **Сначала выбросить лишнее.** Может заметно сократить код, но требует решения,
какие сервисные возможности больше не нужны.
## Recommended approach
Принят смешанный вариант: разнести код по модулям и по ходу явно отметить или
удалить очевидно временные части, если они не являются внешним контрактом.
Целевая форма:
- `generator/generator.py` — тонкая точка входа и совместимый фасад для старых
импортов тестов;
- `generator/src/clickstream_generator/config.py` — конфигурация и её валидация;
- `generator/src/clickstream_generator/dictionary.py` — загрузка и индексы
исходных JSONL;
- `generator/src/clickstream_generator/generation.py` — чистая генеративная
модель визита, страниц и пауз;
- `generator/src/clickstream_generator/intensity.py` — расчёт бюджета активности:
Пуассон, часовой коэффициент, jitter;
- `generator/src/clickstream_generator/runtime.py` — переходный тиковый слой до
активных визитов;
- `generator/src/clickstream_generator/kafka_io.py` — Kafka publisher, создание
топиков, Kafka-state и история batch;
- `generator/src/clickstream_generator/state.py` — сериализуемое состояние
генератора;
- `generator/src/clickstream_generator/metrics.py` — Prometheus-метрики;
- `generator/src/clickstream_generator/service.py` — основной сервисный цикл.
`generator.py` остаётся фасадом, потому что тесты и часть документации уже
используют импорт `from generator import ...`. Рабочий пакет называется
`clickstream_generator`, чтобы не путать его с каталогом `generator/` и файлом
`generator.py`. Dockerfile должен копировать `src/` и выставлять
`PYTHONPATH=/app/src`.
Точное разбиение может быть чуть проще, если это уменьшит шум, но граница между
моделью генерации, тиковым runtime и Kafka-интеграцией должна быть явной.
## Decisions and rejected alternatives
- **Принято:** уборка идёт перед задачей 03. Причина: активные визиты добавляют
новый слой состояния, и его нельзя удобно встроить в текущий монолит.
- **Принято:** внешнее поведение генератора не меняется. Цель задачи — форма
кода, а не новая модель данных.
- **Принято:** исходники живут в `generator/src/clickstream_generator/`, а не
россыпью в `generator/`. Причина: так структура ближе к обычному Python-пакету
и проще для учебного чтения.
- **Принято:** `generate_tick_batch()` считать временным переходным механизмом.
После задачи 03 тик должен выпускать созревшие события активных визитов.
- **Отклонено:** ограничиться перестановкой функций внутри `generator.py`.
Это не решает учебную читаемость и не создаёт ясного места для следующих
срезов.
- **Отклонено:** удалить историю пачек без отдельного решения. Её можно
упростить или вынести, но пока не доказано, что она не нужна для демо и
мониторинга.
## Validation
- Все существующие тесты генератора проходят.
- Импорты, которые используют тесты, либо обновлены, либо сохранены через фасад
`generator/generator.py`.
- `generate_batch()` продолжает строить один связанный визит с монотонным
временем, как после задач 01-02.
- Временный `generate_tick_batch()` не остаётся в чистой модели визита: он вынесен
в переходный тиковый слой или явно подготовлен к замене задачей 03.
- Сервисный контур продолжает публиковать в те же четыре Kafka-топика:
`browser_events`, `location_events`, `device_events`, `geo_events`.
- Dockerfile и запуск контейнера учитывают новую структуру файлов.
- Рабочий пакет импортируется как `clickstream_generator`, при этом старые
импорты через фасад `generator.py` продолжают работать.
- В коде есть ясное место, куда задача 03 добавит активные визиты без расширения
Kafka-слоя и без переписывания генерации одного визита.
## Documentation impact
- После реализации уборки обновить `generator/README.md`: описать новую
структуру `src/clickstream_generator` и убрать формулировки, которые привязаны
к одному `generator.py`.
- Если по ходу будет принято решение удалить или заменить `generator_batch_history`,
это должно быть отдельно отражено в README и в задаче реализации.
@@ -0,0 +1,387 @@
# Модельное время на практике: сохранение состояния, заливка прошлого и стартовая история
Дата: 2026-06-14
Статус: Draft
Связано: [ADR-0005](../adr/0005-generator-model-clock.md) (решение про модельные
часы — эта спека его дорабатывает), [ADR-0006](../adr/0006-generation-as-sole-analytics-source.md)
(стартовая история как источник аналитики), мат-спека
[`2026-06-10-generator-math-model.md`](./2026-06-10-generator-math-model.md)
(её разделы «Персистентность через рестарты» и «Воспроизводимость» здесь
**приводятся в соответствие** с модельным временем — не повторяются, а
переописываются ссылкой), [`CONTEXT.md`](../../CONTEXT.md), задача
[`06-state-v2-and-restart`](../../.scratch/feature-data-generator/issues/06-state-v2-and-restart.md)
(сохранение состояния построено на настенных часах).
Уровень документа — **модель и правила**, как в мат-спеке. Раздел «Рабочий
контракт реализации» ниже фиксирует внешний интерфейс для задач 02–06. Внутренние
классы, функции и разбиение кода остаются за исполнителем.
## Проблема
ADR-0005 решил отвязать время генератора от реальных часов и ввёл три скорости
его хода: обычную (×1), ускоренную (×K) и мгновенную «промотку» прошлого. Но два
правила старой модели — как генератор сохраняет состояние между перезапусками и
как добивается повторяемости — описаны от реальных часов. А ADR-0006 сделал
стартовую историю (готовое сгенерированное прошлое) единственным источником
данных стенда.
Осталось довести модель до конца: как работает промотка прошлого, его заморозка и
непрерывный запуск стенда с этого момента; как при этом меняются правила
сохранения состояния и повторяемости; и как всё это проверить. Всё это — **один
механизм**: промотать прошлое → заморозить → продолжить живьём. Это один и тот же
путь сохранения и восстановления, просто с разных сторон.
## Цели
- **Дать генератору собственные часы и сделать поток повторяемым.** Генератор
должен отсчитывать время от своей точки отсчёта (ниже — «стартовая модельная
точка `T0`»), а не от реальных часов компьютера. Тогда при одних и тех же
настройках он каждый раз порождает один и тот же поток — это нужно для тестов и
для повторяемых уроков.
- **Переписать правила сохранения и восстановления состояния по этим часам.**
Сейчас они завязаны на реальное время. Главное правило — «если посетитель молчал
дольше 30 минут, считаем, что он ушёл» — должно мерить эти 30 минут по часам
генератора. И поток больше не должен зависеть от того, в котором часу реального
дня запущен генератор.
- **Научиться быстро «проматывать» прошлое и замораживать его как стартовый набор
данных.** Генератор прокручивает время без пауз от точки отсчёта до нужного
момента, порождает события прошлого и сохраняет «слепок» своего состояния. Этот
замороженный набор — то, с чего свежий стенд начинает жить, уже имея историю:
здоровую пирамиду «пользователей меньше, чем визитов, а визитов меньше, чем
событий» с первой минуты.
- **Описать, как всё это проверить — в два шага.** Сначала числами: агент
поднимает стенд и сверяет данные в ClickHouse. Потом глазами: человек смотрит на
дашборды и видит, что распределение похоже на задуманное, а стенд «дышит» во
времени.
## Чего здесь не делаем
- **Не учим генератор придумывать «фактуру» сам** (браузеры, гео, устройства,
метки кампаний). Это отдельная спека — следствие ADR-0006. Пока её нет,
стартовая история берёт фактуру из статического сида; это осознанно временно.
- **Не описываем здесь перестройку процесса** на новый сид (загрузка
`kafka_load_dag`, витрины/Superset, уроки) — это решено в ADR-0006 и
проектируется отдельно. Эта спека — только про сам генератор.
- **Не фиксируем** внутренние имена классов и функций — это за исполнителем.
- **Не добавляем** новые типы событий и инкрементальную загрузку ETL.
## Модель
### Рабочий контракт реализации
Этот раздел — источник истины для задач 02–06. Если кодовая задача меняет любое
имя, формат state, манифест или правило проверки, сначала обновляется этот
контракт.
#### Настройки
- `GEN_MODEL_T0` — стартовая модельная точка `T0`. Формат: ISO 8601 с часовым
поясом, например `2026-01-01T00:00:00+00:00`. Внутри генератора метка
нормализуется к UTC.
- `GEN_MODEL_T_END` — граница стартовой истории `T_end`. Формат такой же, как у
`GEN_MODEL_T0`. Обязательна только для режима `backfill`.
- `GEN_MODEL_TIMEZONE` — часовой пояс модельных часов для дневного коэффициента.
Формат: имя IANA, например `UTC` или `Europe/Moscow`. Значение по умолчанию —
`UTC`.
- `GEN_MODEL_TIME_SPEED` — скорость `K`: сколько модельных секунд проходит за
одну настенную секунду. Формат: положительное число, по умолчанию `1`.
- `GEN_RUN_MODE` — режим запуска: `live` или `backfill`. Значение по умолчанию —
`live`.
- `GEN_LAUNCH_PROFILE` — имя профиля запуска для логов. Значение по умолчанию —
`ci`; на поток не влияет.
- `GEN_STARTUP_HISTORY_ARTIFACT` — путь к JSON-файлу, куда `backfill` дополнительно
пишет портативный артефакт стартовой истории. В обычном live-запуске не нужен.
- `GEN_SEED`, `GEN_TICK_SECONDS` и остальные настройки генерации остаются частью
контракта повторяемости. Если они отличаются, артефакт стартовой истории
считается другим.
Поверх этих переменных есть пользовательский слой запуска:
- `backfill` — подставляет `GEN_RUN_MODE=backfill` и `GEN_STATE_RESET=true`;
- `continue` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=false`;
- `reset` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=true`.
Профиль `ci` даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с.
Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна, а в live
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана,
чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны
не меняются относительно старого минутного тика, а пиковый тик не упирается в
`GEN_MAX_EVENTS_PER_TICK`. Вариант «тик 60 с, скорость ×60» не подходит: один
тик стал бы модельным часом и слипал бы события в грубые часовые пачки.
`GEN_HISTORY_DURATION` задаёт длительность вида `6h` или `2d`; `GEN_MODEL_T_END`
считается от `GEN_MODEL_T0` внутри слоя запуска. Старые переменные остаются
низкоуровневым механизмом.
Цена быстрого live-хода `daily-wave`: при тике 1 с сервис пишет примерно 86 400
state-записей и столько же записей истории пачек за настенные сутки. Частота
state-записей остаётся прежним контрактом возобновления после сбоя: успешный тик
сразу сохраняет новую модельную точку. Чтобы быстрый профиль не заливал журналы,
подробные строки успешного тика пишутся на DEBUG, а не на INFO.
#### Ход часов
В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно
равно `GEN_MODEL_T0`. После каждого успешного тика оно сдвигается на
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. Событийный бюджет считается по этой
модельной длительности, а не по тому, сколько процесс реально спал.
При одинаковых `GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_TIME_SPEED`, настройках
генерации и числе успешных тиков живой поток повторяется точно. Это выбранный
путь для текущей цепочки: он ближе к учебной цели и не добавляет новую публичную
матрицу режимов поверх ADR-0005.
`backfill` не спит и не измеряет настенные интервалы. Генератор детерминированно
проматывает модельное время от `GEN_MODEL_T0` до `GEN_MODEL_T_END`. Артефакт
повторяется точно при тех же настройках и чистом состоянии.
Измеренный настенный интервал между тиками не входит в текущий контракт живого
режима. Если он понадобится позже, это отдельное изменение спеки или ADR, потому
что оно меняет уровень повторяемости стенда.
#### Граница `T_end`
Стартовая история покрывает полуоткрытый отрезок `[T0, T_end)`: в неё попадают
события с `event_timestamp >= T0` и `event_timestamp < T_end`. Слепок состояния
сохраняется на модельной границе `T_end`.
Живое продолжение начинается из этого слепка с модельной точки `T_end`. Событие,
которое запланировано ровно на `T_end`, не входит в стартовую историю и может
быть выпущено первым живым тиком. Так на стыке нет дублей и дыр.
#### Модельные и настенные метки
Модельными считаются:
- `event_timestamp` во всех событиях;
- метки внутри визитов и популяции: начало визита, запланированные смещения,
`last_finished_at`;
- `GEN_MODEL_T0`, `GEN_MODEL_T_END`, точка возобновления и метка слепка state.
Настенными остаются операционные метки: время записи логов, метрики здоровья,
длительность тика, история отправки пачек, время сохранения state в Kafka и
служебная метка `generated_at` в манифесте. Они помогают обслуживать сервис, но
не должны менять `event_timestamp` и бизнес-логику визитов.
#### Восстановление после сбоя
Следующая версия state должна хранить связку:
- `model_timestamp` — модельная точка последнего сохранённого состояния;
- `wall_timestamp` — настенная UTC-метка, когда это состояние было сохранено;
- `model_time_speed`, `model_timezone` и `model_t0`.
State v3 также хранит для каждого активного визита `base_click_id` — click_id
донора браузерной и source-фактуры из статического сида. При восстановлении
активный визит берёт per-event поля от этого донора; неизвестный донор считается
битым state, а не поводом выбрать запасную фактуру.
При восстановлении живого режима после сбоя модельная точка считается так:
```text
resume_model_at =
state.model_timestamp
+ max(0, wall_now_utc - state.wall_timestamp) * state.model_time_speed
```
Для восстановления из стартовой истории эта формула не применяется:
`resume_model_at = manifest.model_t_end`. Иначе долгий простой между созданием
артефакта и запуском стенда искусственно оборвёт активные визиты.
Короткий или долгий простой считается только по модельному времени. При большом
`GEN_MODEL_TIME_SPEED` короткая настенная пауза может стать долгой модельной
паузой, и тогда просроченные активные визиты закрываются.
Если state отсутствует или повреждён, генератор стартует чисто и пишет
предупреждение. Если state читается, `GEN_STATE_RESET=false`, но настройки
продолжения несовместимы (`GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_TIMEZONE`,
`GEN_MODEL_TIME_SPEED`, а для стартовой истории ещё и `GEN_MODEL_T_END` или
manifest), генератор должен упасть с перечнем разошедшихся полей и подсказкой
использовать `GEN_STATE_RESET=true` для осознанного нового мира.
#### Манифест стартовой истории
Стартовая история состоит из трёх частей: события, слепок состояния и манифест.
В рабочем стенде манифест хранится как JSON в Kafka compact-topic
`generator_startup_history_manifest`, ключ `default`. Слепок состояния хранится
в `generator_state`, ключ `default`.
Портативный файл-артефакт хранит тот же связный набор: сообщения топиков
`browser_events`, `location_events`, `device_events`, `geo_events`, слепок state
и manifest. Для событий хранится raw JSON value, чтобы импорт мог воспроизвести
Kafka-сообщения без повторной сериализации dict. Импорт артефакта воспроизводит
сообщения в Kafka и записывает state с manifest в служебные compact-топики.
Напрямую в ClickHouse импорт не пишет: ClickHouse наполняется штатным путём через
Kafka engine и Materialized View.
Импорт рассчитан на чистый стенд. Перед записью он проверяет, что data-топики
Kafka пустые. Так как текущий Python-клиент Kafka не даёт транзакционный producer
для нескольких топиков, при ошибке записи импорт удаляет import-топики Kafka,
чтобы повторный импорт не дописал дубли; если ClickHouse уже успел прочитать
частичные сообщения, стенд очищается как clean-stand сценарий.
Манифест минимум содержит:
- `manifest_version`;
- `generated_at` — настенная UTC-метка создания артефакта;
- `gen_seed`;
- `model_t0`, `model_t_end`, `model_timezone`;
- `run_mode = "backfill"`;
- настройки генерации, влияющие на поток, в `generation_settings`;
- `state_version` и ссылку на запись слепка состояния;
- контрольные числа по каждому топику: количество строк, минимум и максимум
`event_timestamp`, контрольная сумма;
- итоговые контрольные числа для проверки в ClickHouse: события, визиты,
пользователи и диапазон модельного времени.
Нельзя смешивать события, state и манифест от разных `GEN_SEED`, `T0`, `T_end`
или настроек генерации. Live-запуск использует manifest как стартовую историю
только если `state.last_batch_id`, `state.model_timestamp`, `GEN_SEED`,
`GEN_MODEL_T0`, `GEN_MODEL_T_END`, `GEN_MODEL_TIMEZONE`, `GEN_MODEL_TIME_SPEED`
и `generation_settings` совпадают. Startup-history state без подходящего
manifest считается несовместимым читаемым state и даёт жёсткий отказ при
`GEN_STATE_RESET=false`.
#### Повторяемая проверка в ClickHouse
Для этой цепочки выбираем чистый прогон, а не идемпотентную дозаливку. Перед
повторной проверкой нужно сбросить:
- таблицы ClickHouse в слоях STG, ODS, DDS и DM, куда попадают события стенда;
- Kafka-топики данных: `browser_events`, `location_events`, `device_events`,
`geo_events`;
- состояние генератора: compact topic `generator_state` или явный сброс
состояния при старте, если он гарантированно не читает старую запись.
После такого сброса один и тот же артефакт стартовой истории должен давать те же
контрольные числа в ClickHouse. Если проверка запускается без чистки, это уже
другой сценарий и его нужно описывать отдельно.
### Точка отсчёта и скорость хода часов
У генератора своя точка отсчёта времени — **стартовая модельная точка `T0`**
(задаётся в настройках). В метку события (`event_timestamp`) пишется это
внутреннее время. Скорость, с которой оно идёт относительно реальных часов,
задаётся режимами из ADR-0005:
- **×1** — как реальное время;
- **×K** — в `K` раз быстрее;
- **заливка прошлого** — время гонится без пауз от `T0` до нужного момента
(особый случай очень большого `K`).
### Повторяемость
При одном и том же зерне `GEN_SEED`, одной и той же `T0` и одной скорости
генератор каждый раз даёт **один и тот же поток** в `backfill` и в живом режиме
при одинаковом числе успешных тиков. Дневной коэффициент считается по модельному
времени в `GEN_MODEL_TIMEZONE`, а не по реальным часам. Так раздел
«Воспроизводимость» мат-спеки приводится в соответствие с модельным временем.
### Заливка прошлого и стартовая история
«Промотать» прошлое — значит прогнать время без пауз от `T0` до момента `T_end` и
сгенерировать события этого отрезка. На выходе — события за `[T0, T_end)` и
**слепок состояния** генератора на момент `T_end`.
События плюс слепок и есть **стартовая история** («стартовый сид» или «новый сид» —
синонимы, новое значение слова «сид» не заводим). При создании стенда это прошлое заливается, и
генератор готов продолжить ровно с `T_end`. Стенд сразу живёт с готовой историей —
с настоящей пирамидой «пользователей меньше, чем визитов, визитов меньше, чем
событий», без вырождения статического сида, где пользователей ровно столько же,
сколько визитов.
### Сохранение и восстановление состояния
Правило «молчал дольше 30 минут — посетитель ушёл» остаётся; меняется только **от
какого момента отсчитывать эти 30 минут**:
- **после сбоя** — от реального «сейчас» (время и правда прошло) → как сегодня;
- **при запуске со стартовой истории** — от метки слепка (`T_end`) → разрыва
почти нет, активные визиты не обрываются, мир продолжается без шва.
Паузы между визитами одного пользователя и обязательная пауза перед возвратом
тоже считаются по внутренним меткам времени, а не по числу тактов. Если
сохранённое состояние повреждено или его нет — генератор стартует с чистого листа
(свежая популяция от `GEN_SEED`) и пишет предупреждение в лог. Так раздел
«Персистентность» мат-спеки приводится в соответствие с модельным временем.
### С какой скоростью стенд живёт дальше
После старта стенд продолжает жить на выбранной скорости. Скорость — это **ручка
под задачу**: чтобы увидеть медленные вещи (возвраты, сдвиг воронки после правки
таблицы переходов, «дыхание» суточной нагрузки) за учебное время, нужно ускорение
(×K) — иначе суточная волна разворачивается реальные сутки. Какой скорость будет
по умолчанию (×1 «как настоящий сайт» или ускоренная «учебная») — решает
урок/исполнитель; спека лишь фиксирует, что это ручка и что именно на ней держится
наблюдаемость медленных явлений.
### Временная опора на сид (фактура)
Пока генератор не умеет придумывать фактуру сам (ADR-0006), стартовая история
одевает события в данные из статического сида (браузер, гео, устройство, метки
кампаний). Это временно и не мешает: механизм времени и стартовой истории не ждёт
синтеза фактуры, а сид до его появления остаётся кладовкой готовых значений — но
уже не источником аналитики.
## Проверка
Два шага — это разделение труда: первый объективный и повторяемый (его делает
агент или CI), второй — человеческий взгляд.
### Шаг 1 — агент сверяет числа в ClickHouse
На чистом стенде с фиксированными `GEN_SEED` и `T0` залить стартовую историю,
прогнать STG→DM и проверить:
- **пирамида:** уникальных пользователей меньше, чем визитов, а визитов меньше,
чем событий;
- **воронка** убывает по шагам `/home → товары → /cart → /payment →
/confirmation`; доля дошедших до `/confirmation` — в нужном коридоре;
- **возвраты:** у части пользователей больше одного визита;
- **длина визита** и доля коротких визитов — в коридорах мат-спеки.
Главное: проверки **повторяемы** (тот же `GEN_SEED` и `T0` → те же числа в
пределах допуска) и записаны как команды и запросы, чтобы их мог прогнать агент
или CI без человека. Сами запросы и числовые коридоры — при реализации.
### Шаг 2 — человек смотрит на дашборды
Глазами убедиться, что:
- **распределение похоже на задуманное** — на наши спроектированные распределения
(мат-спека), а не на профиль чужого сида;
- **стенд «дышит»** — видна суточная волна нагрузки, копятся возвраты и воронка
(заметно на ×K).
Здесь всплывает нехватка: дашборда с распределением сгенерированных данных пока
нет. Superset построен на сиде, а Grafana показывает только скорость работы
сервиса, не форму данных. Закрыть можно двумя способами: переключить Superset на
генерацию (часть большой миграции — позже) или добавить простую панель
«распределение из ClickHouse» в Grafana. Пошаговые действия шага 2 — в будущий
runbook «проверка генератора на стенде».
## Решения и отклонённые варианты
- **Приняли:** одна спека на время, сохранение состояния и стартовую историю —
это один механизм, а не три задачи.
- **Приняли:** своя точка отсчёта `T0` как опора повторяемости; дневной
коэффициент считается по внутреннему времени.
- **Приняли:** проверка в два шага (агент — числа, человек — глаза).
- **Отклонили:** жёстко подгонять генерацию под профиль сида (см. ADR-0006).
- **Отклонили:** приводить сохранение состояния в соответствие с модельным
временем отдельно от стартовой истории — это разрезало бы один механизм надвое.
## Влияние на документацию
- **Мат-спека:** разделы «Персистентность через рестарты» и «Воспроизводимость»
пометить как переописанные в модельном времени этой спекой (ссылкой, без
повтора).
- **ADR-0005:** направление «стартовая история» — отмечено как запущенное.
- **CONTEXT.md:** «стартовый сид» / «новый сид» = «стартовая история стенда»
(синонимы, не новое значение); роль статического сида — по ADR-0006.
- **Будущий runbook** «проверка генератора на стенде» — шаги шага 2.
- **`generator/README.md`, `docs/OPERATIONS.md`** — при реализации: скорость хода
часов, ×K, стартовая история, проверки шага 1.
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
# Установка uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Копируем зависимости отдельно для кэширования слоёв
COPY requirements.txt .
# Установка зависимостей через uv (компактный venv)
RUN uv venv /app/.venv && \
uv pip install --python /app/.venv/bin/python -r requirements.txt
# Копируем точку входа и пакет генератора
COPY generator.py .
COPY src ./src
# Не-root пользователь для безопасности
RUN useradd -m -u 1000 generator && chown -R generator:generator /app
USER generator
# Активируем venv через PATH
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app/src"
# Запуск генератора
CMD ["python", "generator.py"]
+139
View File
@@ -0,0 +1,139 @@
# Генератор: известные проблемы и контекст для доработки
> **Статус (2026-06-11):** исторический дефект старой плоской генерации закрыт
> для режима `steady-stream`. Генератор строит визиты с общим `click_id`,
> монотонным временем событий, путём по страницам воронки, популяцией
> возвращающихся пользователей и состоянием для активных визитов.
>
> Эта заметка больше не является предупреждением «генератор концептуально
> сломан». Она оставлена как учебный разбор старого дефекта и как место для
> небольших остаточных ограничений.
Заметка написана при дизайне Superset-дашборда (ветка
`docs/advanced-clickstream-course`): разбирались, почему на дашборде
`Unique Users == Unique Sessions`, и по ходу вскрылось, что старая реализация
генератора на тот момент семантику сессии не чинила, а ломала сильнее. Разбор
оставлен, чтобы не переоткрывать этот дефект заново и показать, почему новая
модель устроена иначе.
## TL;DR
- **Модель интенсивности потока (сколько событий и когда) — нормальная.**
Poisson по тикам + дневной коэффициент + jitter сохранены.
- **Генеративная модель сущностей переписана.** Поток больше не штампует свежий
`click_id` на каждое событие: визит живёт несколько событий, пользователь
может вернуться в новом визите после кулдауна.
- **Вывод:** старый дефект `Sessions == Events` не считается актуальным
блокером. Дальше генератор можно улучшать уже как работающую учебную модель,
а не как концептуально сломанный источник.
## Текущие ограничения
- Тип события остаётся `pageview` для всех событий. Это осознанное ограничение:
текущий дашборд и уроки строят воронку по `page_url_path`, а не по
`event_type`.
- Device/geo-профиль пользователя стабилен между визитами. Смену устройства
генератор пока не моделирует.
- При долгом простое больше 30 минут активный визит закрывается без досылки
остатка. Популяция пользователей при этом сохраняется.
## Доменная модель (как задумано в DDL)
В этой модели сущности связаны иерархически:
```
user_domain_id (постоянный пользователь, cookie)
└── click_id (визит/сессия — envelope из 1..N событий с общим device/geo)
└── event_id (отдельное событие: pageview, click, purchase ...)
```
- `click_id` — это **визит**, а не одиночный клик: в статическом сиде один
`click_id` честно группирует 1..27 событий, медиана 10 (`dds.click` =
«контекст сессии пользователя», см. `sql/ddl/dds/30_dds.sql`; точные цифры —
профиль сид-датасета в `CONTEXT.md`, ранняя оценка «1..7» была замером по
срезу файла).
- `user_domain_id` живёт в `device_events`, привязан к `click_id`.
## Что генератор делает правильно
Файл `generator/src/clickstream_generator/intensity.py`,
`calculate_events_count()`:
- **Poisson-процесс** прихода событий: λ на минуту → λ на тик → розыгрыш Пуассона.
- **Дневной коэффициент** `_hour_factor()`: день (9–18) ×1.2, ночь (0–5) ×0.7.
- **Jitter** ±`GEN_JITTER_PCT`% и границы `min/max_events_per_tick`.
Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
## Исторический корневой дефект: старая модель сущностей
До переработки 2026-06-11 прежняя реализация в монолитном
`generator/generator.py` на каждое событие в батче делала примерно следующее:
```python
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
...
new_event_id = self._new_uuid()
new_click_id = self._new_uuid() # СВЕЖИЙ click_id на КАЖДОЕ событие
new_timestamp = self._current_timestamp() # ~now() для всех событий батча
...
device_event = {**base_device, "click_id": new_click_id} # user_domain_id наследуется из сида
```
Три уровня иерархии (пользователь → сессия → событие) схлопываются в плоскую
выборку:
1. **`click_id` свежий на каждое событие** → один визит = одно событие. Визит как
группа из нескольких событий **никогда не формируется**.
2. **`user_domain_id` переиспользуется** из сид-пула (~99 значений), но без
привязки к сессиям — события одного пользователя разлетаются отдельными
`click_id`. Иерархию «пользователь → его сессии → события сессии» из потока
**не восстановить**.
3. **Время** у всех событий батча ≈ `now()` — даже временно́й структуры визита
(последовательность событий внутри сессии) нет.
### Что это даёт в данных
| | Статический сид (`data/*.jsonl`) | Поток из генератора |
|---|---|---|
| `click_id` | визит-envelope (1..27 событий, медиана 10) | свежий на событие → `click_id` ≈ событие |
| `user_domain_id` | 1:1 с `click_id` | переиспользуется (потолок ~99) |
| Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) |
Парадокс был таким: **статический сид как учебная основа был лучше**, потому что
на нём `click_id` нёс осмысленную семантику визита, а старый генератор её
ломал.
## Что сделано в новой модели
Чинили именно **генеративную модель сущностей**, не переписывая математику
интенсивности:
1. **Иерархическая генерация вместо плоской выборки:**
- есть ограниченная популяция пользователей с постоянным `user_domain_id`;
- пользователь со временем открывает 1..N визитов;
- визит порождает последовательность событий с одним `click_id`, общим
device/geo-контекстом и путём по страницам.
2. **Тиковый слой:**
- событийный бюджет тика превращается в рождения визитов через среднюю длину
визита;
- активные визиты живут между тиками;
- выпускаются только события, у которых наступила запланированная метка
времени.
3. **Время событий** внутри визита строго растёт и не прилипает к одному
`now()` для всего батча.
4. **Состояние** сохраняет популяцию, активные визиты, накопленный бюджет
рождения визитов, номер тика и состояние ГПСЧ.
После этой переделки поток на длинном окне и при штатных параметрах даёт
здоровую пирамиду `users < sessions < events`, и дашборд может показывать
разницу «пользователь vs сессия» честными числами.
## Ссылки
- Код: `generator/src/clickstream_generator/intensity.py` —
`calculate_events_count()`; `generator/src/clickstream_generator/generation.py`
— `EventGenerator.generate_batch()`.
- Доменная модель: `sql/ddl/dds/30_dds.sql`, `sql/ddl/dm/40_dm.sql`.
- Контекст обсуждения: ветка `docs/advanced-clickstream-course`, дизайн
Superset-дашборда (урок 6 курса).
+400
View File
@@ -0,0 +1,400 @@
# Генератор событий
Автономный генератор событий для Kafka. В штатном стенде он создаёт стартовую
историю через `backfill`, а затем может продолжить поток в режиме `live`.
Генератор строит поток по иерархии `пользователь → визит → событие`: один
`click_id` живёт весь визит, события визита идут по страницам воронки с
монотонно растущим временем, а тиковый слой держит популяцию возвращающихся
пользователей и активные визиты между тиками. Исторический дефект старой
плоской генерации описан в [KNOWN_ISSUES.md](./KNOWN_ISSUES.md).
## Архитектура
```
generator-service -> Kafka topics -> (потребители отдельно)
```
Генератор работает автономно и не зависит от потребителей (Airflow, ClickHouse).
### Структура кода
Код разнесён в пакет `src/clickstream_generator/`. Сам `generator.py` остаётся
точкой входа и совместимым фасадом для старых импортов из тестов.
| Файл | Назначение |
|------|------------|
| `src/clickstream_generator/config.py` | переменные окружения и валидация настроек |
| `src/clickstream_generator/dictionary.py` | загрузка и индексы исходных JSONL |
| `src/clickstream_generator/generation.py` | генерация одного связанного визита |
| `src/clickstream_generator/intensity.py` | расчёт событийного бюджета тика |
| `src/clickstream_generator/runtime.py` | тиковый слой: активные визиты и выпуск созревших событий |
| `src/clickstream_generator/kafka_io.py` | Kafka publisher, история batch, Kafka-state и служебные топики |
| `src/clickstream_generator/state.py` | сериализуемое состояние генератора v3 |
| `src/clickstream_generator/metrics.py` | Prometheus-метрики |
| `src/clickstream_generator/service.py` | основной цикл сервиса |
| `generator.py` | запуск сервиса и совместимый фасад |
## Режим работы: `live`
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
- На каждом тике отправляем небольшую порцию сообщений
- Держим целевую интенсивность `events/min` без крупных минутных batch:
рассчитанный событийный бюджет тика копится как бюджет рождения визитов через
ожидаемую среднюю длину визита, а события выходят позже по своим
запланированным меткам времени
- Распределяем события по 4 топикам:
- `browser_events`
- `location_events`
- `device_events`
- `geo_events`
- Публичный вызов генеративного ядра строит один визит: общий `click_id`,
разные `event_id`, общий device/geo-контекст, путь по страницам воронки и
строго растущие запланированные `event_timestamp`.
- Тиковый слой хранит активные визиты между вызовами и выпускает только события,
у которых наступил `event_timestamp`; завершённые визиты удаляются из памяти.
- Тиковый слой ведёт ограниченную популяцию пользователей: один
`user_domain_id` может вернуться в новом `click_id` после кулдауна, а при
переполнении вытесняется давно неактивный пользователь.
- Сохраняем связи `event_id <-> location`, `click_id <-> device/geo`.
## Конфигурация (env)
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `KAFKA_BOOTSTRAP_SERVERS` | Адрес Kafka | `kafka:29092` |
| `GEN_TICK_SECONDS` | Интервал между тиками | `5` (1-10 сек рекомендуется) |
| `GEN_LAMBDA_BASE_PER_MIN` | Базовая интенсивность (событий/мин) | `30` |
| `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита, защита от петель | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
| `GEN_P_NEW_USER` | Вероятность отдать новый визит новому пользователю | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_T_END` | Правая граница стартовой истории для `backfill` | — |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим генератора | `live` |
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` |
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | — |
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
| `GEN_SEED` | Сид для воспроизводимости | — |
| `GEN_ENABLED` | Включить генерацию | `true` |
| `GEN_METRICS_PORT` | Порт для Prometheus | `9109` |
| `GEN_STATE_ENABLED` | Сохранять состояние между рестартами | `true` |
| `GEN_STATE_RESET` | Сбросить состояние при старте | `false` |
В `docker-compose.yml` параметры генеративной модели и режима проброшены через
подстановку окружения, то есть их можно менять без правки файла:
```bash
GEN_LAMBDA_BASE_PER_MIN=60 GEN_POPULATION_MAX=500 docker compose up -d generator
```
В живом режиме `event_timestamp` берётся из модельного времени: первый чистый
тик стартует от `GEN_MODEL_T0`, дальше модельная точка сдвигается на
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. Событийный бюджет тика считается по
этой же модельной длительности, поэтому ×K даёт больше событий за короткий
реальный прогон. Дневной коэффициент считается по `GEN_MODEL_TIMEZONE`, а не по
реальному часу запуска процесса.
В режиме `backfill` генератор без сна проходит от `GEN_MODEL_T0` до
`GEN_MODEL_T_END`, публикует события только за `[T0, T_end)`, сохраняет state v3
на `T_end` в `generator_state` и пишет manifest в compact-topic
`generator_startup_history_manifest`. Live-запуск с теми же настройками
использует этот manifest, чтобы продолжить ровно с `T_end` без настенной дельты.
Экспорт и импорт портативного файла описаны в
[`docs/runbooks/startup-history.md`](../docs/runbooks/startup-history.md).
Для одноразового backfill-запуска через compose используйте `docker compose run
--rm generator`, а не `docker compose up generator`: у штатного сервиса включён
restart policy.
Контейнерные значения `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose
оставлены безопасными внутренними значениями `kafka:29092` и `/data`.
Штатный чистый путь всего стенда запускается из корня репозитория:
```bash
make generated-history-analytics
```
Эта команда очищает ClickHouse, Kafka-топики данных, state и manifest
генератора, создаёт стартовую историю, прогоняет STG -> ODS -> DDS -> DM и
проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka:
они пока используются только как фактура для генератора.
По умолчанию используется быстрый профиль `ci`: 6 часов модельного времени.
Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна. В live он
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
проходят примерно за 24 настенные минуты.
```bash
PROFILE=daily-wave make generated-history-analytics
```
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
```bash
GEN_HISTORY_DURATION=2d make generated-history-analytics
```
### Режим "раз в минуту" (для ручных экспериментов)
Для ручных экспериментов можно установить:
```bash
GEN_TICK_SECONDS=60
GEN_MIN_EVENTS_PER_TICK=50
GEN_MAX_EVENTS_PER_TICK=500
```
`GEN_MIN_EVENTS_PER_TICK` и `GEN_MAX_EVENTS_PER_TICK` задают нижнюю и верхнюю
границы событийного бюджета тика. Этот бюджет сначала превращается в рождения
визитов через среднюю длину визита, поэтому фактическое число отправленных
событий в конкретном тике может отличаться.
## Управление через Makefile
```bash
# Запустить только генератор
make generator-up
# Остановить генератор
make generator-down
# Смотреть логи
make generator-logs
# Перезапуск с пересборкой
make generator-restart
# Промотать стартовую историю
make generator-backfill
# Продолжить live-поток из state
make generator-continue
# Начать live-поток как новый мир на чистом стенде
make generator-reset
# Чистый аналитический прогон всего стенда
make generated-history-analytics
# Быстрые проверки перед коммитом
make test
make lint
```
Основной ручной путь запуска — глаголы `generator-backfill`, `generator-continue`
и `generator-reset`. Старые `GEN_RUN_MODE`, `GEN_STATE_RESET` и
`GEN_MODEL_T_END` остаются низкоуровневым способом для отладки.
`generator-backfill`, `startup-history-import` и `generator-reset` перед записью
проверяют, что Kafka data-топики и STG пустые. `generator-continue` продолжает
только совместимый state; старый формат state при `GEN_STATE_RESET=false` даёт
отказ с подсказкой очистить стенд или явно начать новый мир на чистом стенде.
## Метрики Prometheus
Генератор экспортирует метрики на `:9109/metrics`:
| Метрика | Тип | Описание |
|---------|-----|----------|
| `generator_events_total` | Counter | Всего отправлено событий (по топикам) |
| `generator_publish_errors_total` | Counter | Ошибки публикации (по топикам) |
| `generator_tick_duration_seconds` | Histogram | Длительность тика |
| `generator_last_success_timestamp` | Gauge | Время последнего успешного тика |
### Проверка метрик
```bash
curl http://localhost:9109/metrics
curl http://localhost:9090/api/v1/targets | grep generator
```
## Мониторинг в Grafana
**Dashboard URL:** `http://localhost:3000/d/generator-overview`
Дашборд "Generator Overview" предоставляет полную визуализацию работы генератора:
### Ключевые панели
| Панель | Метрика | Описание |
|--------|---------|----------|
| **Events/min** | `rate(generator_events_total[1m]) * 60` | Текущая скорость генерации |
| **Tick Duration** | `generator_tick_duration_seconds` | p50 и p99 длительности тика |
| **Last Successful Tick** | `generator_last_success_timestamp` | Время последнего успешного тика |
| **Events per Hour** | `increase(generator_events_total[1h])` | 24-часовое распределение по топикам (bar chart) |
| **Errors** | `generator_publish_errors_total` | Общее число и rate ошибок |
| **Generator Status** | derived | Активен ли генератор |
### Структура дашборда
Дашборд разделён на 6 секций:
1. **Overview** — ключевые метрики (events/min, tick duration, last success)
2. **Events by Topic** — bar chart Events per Hour, rate by topic, total counters
3. **Errors** — total errors, error rate, errors by topic
4. **Tick Statistics** — duration distribution (p50/p95/p99), events per tick, hour factor
5. **Status** — generator status, generator health (heartbeat), time since last tick
6. **Info** — полезные команды и параметры конфигурации
### Доступ к дашборду
Дашборд автоматически загружается в Grafana при старте контейнера (provisioning).
```bash
# Открыть дашборд
open http://localhost:3000/d/generator-overview
# Перезагрузить provisioning (если дашборд не появился)
curl -s -u admin:admin -X POST http://localhost:3000/api/admin/provisioning/dashboards/reload
```
## История batch
История пишется в Kafka-топик `generator_batch_history` (JSON).
**Контракт топика:**
- Название фиксировано: `generator_batch_history` (не конфигурируется)
- Формат: JSON с ключом `batch_id`
**Важно:** генератор требует работающей Kafka. Без Kafka генератор упадёт при старте или потеряет события. Для мониторинга доступности используйте Prometheus-метрики (`generator_last_success_timestamp`).
Поля сообщения:
- `batch_id` — идентификатор батча
- `started_at` / `finished_at` — время начала/окончания (ISO format)
- `sent_total` — всего отправлено
- `sent_browser/location/device/geo` — по топикам
- `status` — success/partial/error
- `error_message` — описание ошибки (если есть)
### Чтение истории из Kafka
```bash
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 \
--topic generator_batch_history \
--from-beginning
```
## Восстановление состояния
Генератор сохраняет своё состояние между перезапусками в Kafka-топик `generator_state` (compact topic). Это позволяет:
- Продолжить нумерацию тиков с места остановки (continuity)
- Сохранить последовательность случайных чисел (RNG state)
- Восстановить популяцию пользователей
- Продолжить активные визиты после короткого простоя
### Как работает
1. После каждого успешного тика состояние v3 сохраняется в `generator_state`.
2. При старте генератор читает последнее состояние из топика.
3. Если состояние найдено, сервис восстанавливает номер тика, состояние ГПСЧ,
популяцию пользователей, накопленный бюджет рождения визитов и активные
визиты. Для активного визита state хранит `base_click_id` — донора браузерной
и source-фактуры из статического сида.
4. Если состояния нет или оно невалидно, генератор начинает с чистого листа.
Активный визит после простоя до 30 минут продолжается со своими исходными
запланированными метками времени. Если следующий шаг визита просрочен больше
чем на 30 минут, визит закрывается без досылки остатка: для демо это выглядит
как пользователь, который ушёл, пока стенд был остановлен.
### Топик `generator_state`
- **Название**: фиксировано `generator_state`
- **Тип**: compact topic (хранится только последнее значение для каждого ключа)
- **Ключ**: `default` (для возможности нескольких генераторов в будущем)
- **Конфигурация**: `cleanup.policy=compact`, минимальный retention
### Просмотр текущего состояния
```bash
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 \
--topic generator_state \
--from-beginning \
--property print.key=true
```
### Сброс состояния (начать сначала)
```bash
# Вариант 1: через env (рекомендуется)
GEN_STATE_RESET=true docker compose up -d generator
# Вариант 2: удалить топик полностью
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:29092 \
--delete \
--topic generator_state
```
### Отключение сохранения состояния
```bash
GEN_STATE_ENABLED=false docker compose up -d generator
```
При отключенном сохранении состояния генератор всегда начинает с `tick=1`, а
ГПСЧ инициализируется с `GEN_SEED` или случайно.
## Тестирование
Тесты написаны на **pytest**.
### Запуск тестов
```bash
# Через Makefile (рекомендуется)
make test
make lint
# Вручную через Docker
docker build -t generator:test .
docker run --rm -v $(PWD):/workspace -w /workspace/generator generator:test pytest tests/ -q
# Конкретный файл тестов
docker run --rm -v $(PWD):/workspace -w /workspace/generator generator:test pytest tests/test_generation.py -v
```
`make test` запускает тесты генератора, контрактные тесты верхнего уровня и
проверку compose-конфигурации. `make lint` проверяет синтаксис Python и Bash,
compose-конфиг и пробелы в diff. Долгие стендовые проверки вроде
`make generated-history-runtime-check` запускаются отдельно.
### Структура тестов
```
generator/tests/
├── conftest.py # Fixtures pytest
├── test_config.py # Тесты конфигурации
├── test_generation.py # Тесты генерации событий
├── test_history.py # Тесты структуры BatchRecord
├── test_kafka_history.py # Тесты KafkaBatchHistory
├── test_service.py # Тесты GeneratorService
├── test_service_cleanup.py # Контракт разбиения сервиса на модули
└── test_state.py # Тесты GeneratorState и KafkaStateManager
```
### Интеграционный тест
```bash
# Запустить стек с генератором
make generator-up
# Проверить логи
make generator-logs
# Проверить метрики
curl http://localhost:9109/metrics
# Проверить сообщения в Kafka
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 --topic browser_events --from-beginning
```
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Совместимый фасад и точка входа генератора.
Основной код разнесён по модулям рядом с этим файлом. Старые импорты вида
`from generator import Config` сохраняются для тестов и внешних запусков.
"""
import logging
import sys
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from clickstream_generator.config import Config
from clickstream_generator.dictionary import EventDictionary
from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator
from clickstream_generator.intensity import calculate_events_count, hour_factor
from clickstream_generator.kafka_io import (
BatchRecord,
KafkaBatchHistory,
KafkaPublisher,
KafkaStateManager,
KafkaStartupHistoryManifest,
_import_kafka,
_with_retry,
ensure_topics,
)
from clickstream_generator.metrics import (
METRICS_ERRORS_TOTAL,
METRICS_EVENTS_TOTAL,
METRICS_LAST_SUCCESS,
METRICS_TICK_DURATION,
)
from clickstream_generator.runtime import TickStreamGenerator, generate_tick_batch
from clickstream_generator.service import GeneratorService, main
from clickstream_generator.state import GeneratorState, _nested_list_to_tuple
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
__all__ = [
"BatchRecord",
"Config",
"EventDictionary",
"EventGenerator",
"EXPECTED_VISIT_EVENTS",
"GeneratorService",
"GeneratorState",
"KafkaBatchHistory",
"KafkaPublisher",
"KafkaStateManager",
"KafkaStartupHistoryManifest",
"METRICS_ERRORS_TOTAL",
"METRICS_EVENTS_TOTAL",
"METRICS_LAST_SUCCESS",
"METRICS_TICK_DURATION",
"TickStreamGenerator",
"_import_kafka",
"_nested_list_to_tuple",
"_with_retry",
"calculate_events_count",
"ensure_topics",
"generate_tick_batch",
"hour_factor",
"main",
]
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
# Kafka клиент
kafka-python==2.0.6
# Prometheus метрики
prometheus-client==0.21.1
# Тестирование
pytest==8.3.4
# Утилиты
python-json-logger==2.0.7
@@ -0,0 +1,2 @@
"""Пакет генератора кликстрима."""
@@ -0,0 +1,364 @@
"""Чистая логика пульта Airflow для стартовой истории."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator
from clickstream_generator.config import Config
from clickstream_generator.kafka_io import (
KafkaStateManager,
KafkaStartupHistoryManifest,
ensure_topics,
)
from clickstream_generator.launch import build_launch_env
from clickstream_generator.service import GeneratorService
from clickstream_generator.startup_history_artifact import (
KafkaRawPublisher,
KafkaTopicInspector,
compare_clickhouse_stats_to_manifest,
import_startup_history_artifact,
load_startup_history_artifact,
manifest_boundaries,
validate_startup_history_artifact,
validate_manifest_state,
)
from clickstream_generator.stand_clean import (
STG_EMPTY_SQL,
assert_kafka_data_topics_empty,
assert_stg_counts_empty,
)
AIRFLOW_DATA_DIR = "/opt/airflow/data"
KAFKA_BOOTSTRAP_SERVERS = "kafka:29092"
GENERATOR_METRICS_URL = "http://generator:9109/metrics"
WORLD_OVERRIDE_KEYS = {
"GEN_SEED",
"GEN_MODEL_T0",
"GEN_MODEL_TIMEZONE",
"GEN_MODEL_TIME_SPEED",
"GEN_TICK_SECONDS",
"GEN_LAMBDA_BASE_PER_MIN",
"GEN_JITTER_PCT",
"GEN_MIN_EVENTS_PER_TICK",
"GEN_MAX_EVENTS_PER_TICK",
}
NEXT_DAY_SETTING_ENV_KEYS = {
"tick_seconds": "GEN_TICK_SECONDS",
"lambda_base_per_min": "GEN_LAMBDA_BASE_PER_MIN",
"jitter_pct": "GEN_JITTER_PCT",
"min_events_per_tick": "GEN_MIN_EVENTS_PER_TICK",
"max_events_per_tick": "GEN_MAX_EVENTS_PER_TICK",
"max_session_events": "GEN_MAX_SESSION_EVENTS",
"max_active_sessions": "GEN_MAX_ACTIVE_SESSIONS",
"population_max": "GEN_POPULATION_MAX",
"p_new_user": "GEN_P_NEW_USER",
"min_return_minutes": "GEN_MIN_RETURN_MINUTES",
"model_time_speed": "GEN_MODEL_TIME_SPEED",
}
CLICKHOUSE_STATS_SQL = """
WITH
toDateTime64('{model_t0}', 6) AS t0,
toDateTime64('{model_t_end}', 6) AS t_end
SELECT
count() AS events,
uniqExact(click_id) AS visits,
uniqExact(user_domain_id) AS users,
toString(min(event_ts)) AS min_event_timestamp,
toString(max(event_ts)) AS max_event_timestamp
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
"""
def build_control_env(
operation: str,
*,
profile_name: str,
duration: str | None = None,
artifact_path: str | None = None,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Готовит env для операции пульта без доступа к Docker."""
if operation not in {"backfill", "import"}:
raise ValueError("operation must be backfill or import")
if operation == "import" and not artifact_path:
raise ValueError("artifact_path is required for import")
selected_overrides = {
key: value
for key, value in (overrides or {}).items()
if key in WORLD_OVERRIDE_KEYS and value != ""
}
env = build_launch_env(
"backfill",
profile_name=profile_name,
duration=duration or None,
overrides=selected_overrides,
)
env["KAFKA_BOOTSTRAP_SERVERS"] = KAFKA_BOOTSTRAP_SERVERS
env["GEN_DATA_DIR"] = AIRFLOW_DATA_DIR
env["GEN_METRICS_ENABLED"] = "false"
if artifact_path:
env["GEN_STARTUP_HISTORY_ARTIFACT"] = artifact_path
return env
def build_next_day_env(manifest: dict) -> dict[str, str]:
"""Восстанавливает окружение следующего дня из manifest мира."""
manifest_boundaries(manifest)
settings = manifest.get("generation_settings") or {}
missing = [key for key in NEXT_DAY_SETTING_ENV_KEYS if key not in settings]
if missing:
raise ValueError(
"в manifest не хватает generation_settings: "
+ ", ".join(sorted(missing))
)
overrides = {
"GEN_SEED": str(manifest["gen_seed"]),
"GEN_MODEL_T0": str(manifest["model_t0"]),
"GEN_MODEL_T_END": str(manifest["model_t_end"]),
"GEN_MODEL_TIMEZONE": str(manifest["model_timezone"]),
}
env = build_launch_env(
"next-day",
profile_name=str(manifest.get("launch_profile") or "ci"),
overrides=overrides,
)
for setting, env_key in NEXT_DAY_SETTING_ENV_KEYS.items():
env[env_key] = str(settings[setting])
env["KAFKA_BOOTSTRAP_SERVERS"] = KAFKA_BOOTSTRAP_SERVERS
env["GEN_DATA_DIR"] = AIRFLOW_DATA_DIR
env["GEN_METRICS_ENABLED"] = "false"
return env
def assert_expected_t_end(expected_t_end: str, actual_t_end: str) -> None:
"""Отвергает повторный запуск от уже сдвинутой границы."""
if not expected_t_end:
return
try:
expected = _parse_utc_timestamp(expected_t_end)
actual = _parse_utc_timestamp(actual_t_end)
except ValueError as exc:
raise RuntimeError(f"Некорректная граница next-day: {exc}") from exc
if expected != actual:
raise RuntimeError(
"Граница next-day изменилась: "
f"ожидалась {expected_t_end}, фактическая {actual_t_end}."
)
def assert_next_day_snapshot(manifest: dict | None, state) -> None:
"""Проверяет наличие и согласованность точки продолжения next-day."""
if not manifest:
raise RuntimeError("Manifest стартовой истории не найден в Kafka.")
if state is None:
raise RuntimeError("State генератора не найден в Kafka.")
try:
manifest_boundaries(manifest)
validate_manifest_state(manifest, state)
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError(
f"state не согласован с T_end manifest: {exc}"
) from exc
def _parse_utc_timestamp(value: str):
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timestamp.tzinfo is None:
raise ValueError("время должно содержать часовой пояс")
return timestamp.astimezone(timezone.utc)
def assert_stand_clean(kafka_bootstrap_servers: str, clickhouse_hook) -> None:
"""Проверяет, что backfill/import не смешает миры."""
assert_kafka_data_topics_empty(kafka_bootstrap_servers)
assert_stg_tables_empty(clickhouse_hook)
def assert_stg_tables_empty(clickhouse_hook) -> None:
"""Падает, если в STG уже есть строки."""
result = clickhouse_hook.execute(STG_EMPTY_SQL)
counts = result[0] if result else ()
assert_stg_counts_empty(counts)
def assert_live_generator_not_running(url: str = GENERATOR_METRICS_URL) -> None:
"""Мягко предупреждает о live-сервисе по HTTP-метрикам."""
_assert_metrics_not_responding(
url,
"Live-генератор отвечает на metrics-порту. Остановите его с консоли "
"перед backfill/import, чтобы не смешать миры.",
)
def assert_live_generator_not_running_for_next_day(
url: str = GENERATOR_METRICS_URL,
) -> None:
"""Падает, если next-day может смешаться с работающим live-потоком."""
_assert_metrics_not_responding(
url,
"Live-генератор отвечает на metrics-порту. Остановите его с консоли "
"перед next-day, чтобы не смешать дни.",
)
def _assert_metrics_not_responding(url: str, error_message: str) -> None:
"""Проверяет, что HTTP-метрики live-генератора недоступны."""
try:
urllib.request.urlopen(url, timeout=2).close()
except (urllib.error.URLError, TimeoutError, OSError):
return
raise RuntimeError(error_message)
def run_backfill(env: dict[str, str]) -> None:
"""Запускает backfill в процессе Airflow worker."""
with _patched_environ(env):
config = Config()
GeneratorService(config).start()
def run_next_day(env: dict[str, str]) -> None:
"""Запускает ограниченную доливку следующего дня в Airflow worker."""
with _patched_environ(env):
config = Config()
GeneratorService(config).start()
def run_import(env: dict[str, str], artifact_path: str) -> dict:
"""Импортирует портативный артефакт в Kafka."""
with _patched_environ(env):
config = Config()
artifact = load_startup_history_artifact(artifact_path)
ensure_topics(config.kafka_bootstrap_servers)
publisher = KafkaRawPublisher(config.kafka_bootstrap_servers)
state_manager = KafkaStateManager(config.kafka_bootstrap_servers)
manifest_manager = KafkaStartupHistoryManifest(config.kafka_bootstrap_servers)
topic_inspector = KafkaTopicInspector(config.kafka_bootstrap_servers)
try:
return import_startup_history_artifact(
artifact,
publisher=publisher,
state_manager=state_manager,
manifest_manager=manifest_manager,
expected_config=config,
topic_inspector=topic_inspector,
)
finally:
publisher.close()
state_manager.close()
manifest_manager.close()
def validate_import_artifact(env: dict[str, str], artifact_path: str) -> None:
"""Проверяет артефакт и настройки import до записи в Kafka."""
with _patched_environ(env):
config = Config()
artifact = load_startup_history_artifact(artifact_path)
validate_startup_history_artifact(artifact, expected_config=config)
def load_manifest_from_kafka(
kafka_bootstrap_servers: str = KAFKA_BOOTSTRAP_SERVERS,
) -> dict:
"""Читает manifest стартовой истории из compact-топика."""
manager = KafkaStartupHistoryManifest(kafka_bootstrap_servers)
try:
manifest = manager.load()
finally:
manager.close()
if not manifest:
raise RuntimeError("Manifest стартовой истории не найден в Kafka.")
return manifest
def load_state_from_kafka(
kafka_bootstrap_servers: str = KAFKA_BOOTSTRAP_SERVERS,
):
"""Читает state генератора из compact-топика."""
manager = KafkaStateManager(kafka_bootstrap_servers)
try:
return manager.load()
finally:
manager.close()
def assert_clickhouse_matches_manifest(manifest: dict, clickhouse_hook) -> None:
"""Сверяет контрольные числа ClickHouse с manifest."""
model_t0 = _clickhouse_datetime_literal(str(manifest["model_t0"]))
model_t_end = _clickhouse_datetime_literal(str(manifest["model_t_end"]))
result = clickhouse_hook.execute(
CLICKHOUSE_STATS_SQL.format(model_t0=model_t0, model_t_end=model_t_end)
)
if not result:
raise RuntimeError("ClickHouse не вернул контрольные числа.")
row = result[0]
stats = {
"events": str(row[0]),
"visits": str(row[1]),
"users": str(row[2]),
"min_event_timestamp": str(row[3]),
"max_event_timestamp": str(row[4]),
}
mismatches = compare_clickhouse_stats_to_manifest(manifest, stats)
if mismatches:
raise RuntimeError(
"ClickHouse расходится с manifest: " + ", ".join(mismatches)
)
def target_dag_trigger_error(dag_id: str, dag_model) -> str | None:
"""Возвращает причину, почему зависимый DAG нельзя запускать."""
if dag_model is None:
return (
f"DAG {dag_id} ещё не найден Airflow. Подождите парсинга DAG-файлов "
"или перезапустите Airflow через make up."
)
if dag_model.is_paused:
return (
f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, "
"затем повторите generator_control."
)
return None
def default_artifact_path(operation: str) -> str:
"""Возвращает путь артефакта по умолчанию в общем томе data."""
filename = (
"startup-history-import.json"
if operation == "import"
else "startup-history.json"
)
return str(Path(AIRFLOW_DATA_DIR) / filename)
@contextmanager
def _patched_environ(env: dict[str, str]) -> Iterator[None]:
old_values = {key: os.environ.get(key) for key in env}
os.environ.update(env)
try:
yield
finally:
for key, value in old_values.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _clickhouse_datetime_literal(value: str) -> str:
normalized = value.replace("T", " ").removesuffix("Z")
if len(normalized) >= 6 and normalized[-6] in "+-" and normalized[-3] == ":":
normalized = normalized[:-6]
return normalized
@@ -0,0 +1,164 @@
"""Конфигурация генератора из переменных окружения."""
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _parse_model_timestamp(value: str) -> datetime:
"""Разбирает ISO-метку модельного времени и нормализует её к UTC."""
normalized = value.replace("Z", "+00:00")
timestamp = datetime.fromisoformat(normalized)
if timestamp.tzinfo is None:
raise ValueError("GEN_MODEL_T0 must include timezone")
return timestamp.astimezone(timezone.utc)
@dataclass(frozen=True)
class Config:
"""Конфигурация генератора из переменных окружения."""
kafka_bootstrap_servers: str = field(
default_factory=lambda: os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:29092")
)
tick_seconds: int = field(
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
)
lambda_base_per_min: int = field(
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "30"))
)
jitter_pct: int = field(
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
)
min_events_per_tick: int = field(
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "1"))
)
max_events_per_tick: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
)
max_session_events: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
)
max_active_sessions: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_ACTIVE_SESSIONS", "200"))
)
population_max: int = field(
default_factory=lambda: int(os.getenv("GEN_POPULATION_MAX", "300"))
)
p_new_user: float = field(
default_factory=lambda: float(os.getenv("GEN_P_NEW_USER", "0.15"))
)
min_return_minutes: int = field(
default_factory=lambda: int(os.getenv("GEN_MIN_RETURN_MINUTES", "30"))
)
data_dir: Path = field(
default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data"))
)
seed: int | None = field(
default_factory=lambda: int(os.getenv("GEN_SEED"))
if os.getenv("GEN_SEED")
else None
)
enabled: bool = field(
default_factory=lambda: os.getenv("GEN_ENABLED", "true").lower() == "true"
)
metrics_port: int = field(
default_factory=lambda: int(os.getenv("GEN_METRICS_PORT", "9109"))
)
metrics_enabled: bool = field(
default_factory=lambda: os.getenv("GEN_METRICS_ENABLED", "true").lower()
== "true"
)
state_enabled: bool = field(
default_factory=lambda: os.getenv("GEN_STATE_ENABLED", "true").lower() == "true"
)
state_reset: bool = field(
default_factory=lambda: os.getenv("GEN_STATE_RESET", "false").lower() == "true"
)
model_t0: datetime = field(
default_factory=lambda: _parse_model_timestamp(
os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00")
)
)
model_t_end: datetime | None = field(
default_factory=lambda: _parse_optional_model_timestamp(
os.getenv("GEN_MODEL_T_END")
)
)
model_timezone: str = field(
default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC")
)
model_time_speed: float = field(
default_factory=lambda: float(os.getenv("GEN_MODEL_TIME_SPEED", "1"))
)
run_mode: str = field(
default_factory=lambda: os.getenv("GEN_RUN_MODE", "live")
)
startup_history_artifact: Path | None = field(
default_factory=lambda: Path(os.getenv("GEN_STARTUP_HISTORY_ARTIFACT"))
if os.getenv("GEN_STARTUP_HISTORY_ARTIFACT")
else None
)
launch_profile: str = field(
default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "ci")
)
def __post_init__(self):
if self.tick_seconds < 1:
raise ValueError("GEN_TICK_SECONDS must be >= 1")
if self.lambda_base_per_min < 1:
raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1")
if self.max_session_events < 1:
raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1")
if self.max_active_sessions < 1:
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be >= 1")
if self.population_max < 1:
raise ValueError("GEN_POPULATION_MAX must be >= 1")
if not 0 <= self.p_new_user <= 1:
raise ValueError("GEN_P_NEW_USER must be between 0 and 1")
if self.min_return_minutes < 0:
raise ValueError("GEN_MIN_RETURN_MINUTES must be >= 0")
if self.max_active_sessions >= self.population_max:
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be < GEN_POPULATION_MAX")
if not self.data_dir.exists():
raise ValueError(f"Data directory does not exist: {self.data_dir}")
if self.model_t0.tzinfo is None:
raise ValueError("GEN_MODEL_T0 must include timezone")
object.__setattr__(
self,
"model_t0",
self.model_t0.astimezone(timezone.utc),
)
try:
ZoneInfo(self.model_timezone)
except ZoneInfoNotFoundError as e:
raise ValueError(f"Unknown GEN_MODEL_TIMEZONE: {self.model_timezone}") from e
if self.model_time_speed <= 0:
raise ValueError("GEN_MODEL_TIME_SPEED must be > 0")
if self.run_mode not in {"live", "backfill", "next-day"}:
raise ValueError("GEN_RUN_MODE должен быть live, backfill или next-day")
if self.model_t_end is not None:
object.__setattr__(
self,
"model_t_end",
self.model_t_end.astimezone(timezone.utc),
)
if self.run_mode == "backfill":
if self.model_t_end is None:
raise ValueError("GEN_MODEL_T_END is required for backfill")
if self.model_t_end <= self.model_t0:
raise ValueError("GEN_MODEL_T_END must be after GEN_MODEL_T0")
if self.run_mode == "next-day":
if self.model_t_end is None:
raise ValueError("для next-day требуется GEN_MODEL_T_END")
if self.model_t_end <= self.model_t0:
raise ValueError("GEN_MODEL_T_END должен быть позже GEN_MODEL_T0")
def _parse_optional_model_timestamp(value: str | None) -> datetime | None:
"""Разбирает необязательную ISO-метку модельного времени."""
if not value:
return None
return _parse_model_timestamp(value)
@@ -0,0 +1,65 @@
"""Загрузка и индексация исходных JSONL-событий."""
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger("generator")
@dataclass
class EventDictionary:
"""Базовый словарь событий из JSONL файлов."""
browser_events: list[dict[str, Any]]
location_events: list[dict[str, Any]]
device_events: list[dict[str, Any]]
geo_events: list[dict[str, Any]]
browser_by_click_id: dict[str, list[dict]] = field(default_factory=dict)
location_by_event_id: dict[str, dict] = field(default_factory=dict)
device_by_click_id: dict[str, dict] = field(default_factory=dict)
geo_by_click_id: dict[str, dict] = field(default_factory=dict)
def __post_init__(self):
for browser in self.browser_events:
self.browser_by_click_id.setdefault(browser["click_id"], []).append(browser)
for loc in self.location_events:
self.location_by_event_id[loc["event_id"]] = loc
for dev in self.device_events:
self.device_by_click_id[dev["click_id"]] = dev
for geo in self.geo_events:
self.geo_by_click_id[geo["click_id"]] = geo
@classmethod
def load(cls, data_dir: Path) -> "EventDictionary":
"""Загружает события из JSONL файлов."""
logger.info(f"Loading event dictionary from {data_dir}")
def load_jsonl(filename: str) -> list[dict]:
path = data_dir / filename
events = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
events.append(json.loads(line))
logger.info(f" Loaded {len(events)} events from {filename}")
return events
browser_events = load_jsonl("browser_events.jsonl")
location_events = load_jsonl("location_events.jsonl")
device_events = load_jsonl("device_events.jsonl")
geo_events = load_jsonl("geo_events.jsonl")
if not browser_events:
raise ValueError("browser_events.jsonl is empty or missing")
return cls(
browser_events=browser_events,
location_events=location_events,
device_events=device_events,
geo_events=geo_events,
)
@@ -0,0 +1,256 @@
"""Чистая генеративная модель визита."""
import math
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from clickstream_generator.config import Config
from clickstream_generator.dictionary import EventDictionary
from clickstream_generator.intensity import calculate_events_count, hour_factor
PAGE_START_DISTRIBUTION = [
("/home", 0.59),
("/product_a", 0.20),
("/product_b", 0.14),
("/cart", 0.04),
("/payment", 0.02),
("/confirmation", 0.01),
]
PAGE_TRANSITIONS = {
"/home": [
("/home", 0.41),
("/product_a", 0.28),
("/product_b", 0.18),
("/cart", 0.04),
(None, 0.09),
],
"/product_a": [
("/home", 0.34),
("/product_a", 0.13),
("/product_b", 0.18),
("/cart", 0.27),
(None, 0.08),
],
"/product_b": [
("/home", 0.36),
("/product_a", 0.15),
("/product_b", 0.15),
("/cart", 0.27),
(None, 0.07),
],
"/cart": [
("/home", 0.20),
("/product_a", 0.12),
("/product_b", 0.10),
("/cart", 0.11),
("/payment", 0.42),
(None, 0.05),
],
"/payment": [
("/home", 0.12),
("/cart", 0.24),
("/payment", 0.14),
("/confirmation", 0.38),
(None, 0.12),
],
"/confirmation": [
("/home", 0.37),
("/product_a", 0.15),
("/product_b", 0.10),
("/confirmation", 0.05),
(None, 0.33),
],
}
# Калибровочный ориентир из профиля сида: средний визит около 10 событий.
# Тиковый слой использует это число, чтобы переводить событийный бюджет в
# рождения визитов; тесты ниже защищают фактическую длину визита от дрейфа.
EXPECTED_VISIT_EVENTS = 10.0
class EventGenerator:
"""Генератор одного связанного визита."""
def __init__(self, dictionary: EventDictionary, config: Config):
self.dictionary = dictionary
self.config = config
self.rng = random.Random(config.seed)
def _new_uuid(self) -> str:
"""Генерирует новый UUID."""
return str(uuid.UUID(int=self.rng.getrandbits(128), version=4))
def _current_timestamp(self) -> str:
"""Возвращает текущую метку времени в формате JSONL."""
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
def _format_timestamp(self, timestamp: datetime) -> str:
"""Форматирует запланированную метку времени для JSONL."""
return timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")
def _weighted_choice(self, choices: list[tuple[Any, float]]) -> Any:
"""Разыгрывает значение по списку весов."""
point = self.rng.random()
cumulative = 0.0
for value, weight in choices:
cumulative += weight
if point < cumulative:
return value
return choices[-1][0]
def _generate_visit_path(self, max_events: int, min_events: int = 1) -> list[str]:
"""Генерирует путь визита по страницам с защитой от бесконечных петель."""
page = self._weighted_choice(PAGE_START_DISTRIBUTION)
path = []
while page is not None and len(path) < max_events:
path.append(page)
page = self._weighted_choice(PAGE_TRANSITIONS[page])
while len(path) < min_events and len(path) < max_events:
transitions = [
(next_page, weight)
for next_page, weight in PAGE_TRANSITIONS[path[-1]]
if next_page is not None
]
path.append(self._weighted_choice(transitions))
return path
def _visit_pause_seconds(self) -> float:
"""Разыгрывает паузу между событиями визита."""
pause = self.rng.lognormvariate(math.log(20.0), 0.9)
return max(1.0, min(pause, 29 * 60.0))
def _hour_factor(self, now: datetime | None = None) -> float:
"""Совместимый wrapper над расчётом часового коэффициента."""
return hour_factor(now or self.config.model_t0, self.config.model_timezone)
def _calculate_events_count(self, now: datetime | None = None) -> int:
"""Совместимый wrapper над расчётом событийного бюджета."""
return calculate_events_count(self.config, self.rng, now=now)
def generate_batch(
self,
batch_size: int,
planned_start_at: datetime | None = None,
user_profile: dict[str, dict] | None = None,
) -> dict[str, list[dict]]:
"""Генерирует один визит с сохранением связей."""
batch, _base_click_id = self.generate_visit_batch(
batch_size,
planned_start_at=planned_start_at,
user_profile=user_profile,
)
return batch
def generate_visit_batch(
self,
batch_size: int,
planned_start_at: datetime | None = None,
user_profile: dict[str, dict] | None = None,
) -> tuple[dict[str, list[dict]], str | None]:
"""Генерирует визит и возвращает click_id донора фактуры."""
if not self.dictionary.browser_events:
return {
"browser_events": [],
"location_events": [],
"device_events": [],
"geo_events": [],
}, None
batch = {
"browser_events": [],
"location_events": [],
"device_events": [],
"geo_events": [],
}
if batch_size <= 0:
return batch, None
max_visit_events = min(batch_size, self.config.max_session_events)
min_visit_events = min(2, max_visit_events)
visit_path = self._generate_visit_path(max_visit_events, min_visit_events)
visit_candidates = [
click_id for click_id, browser_events in self.dictionary.browser_by_click_id.items()
if (
len(browser_events) >= len(visit_path)
and click_id in self.dictionary.device_by_click_id
and click_id in self.dictionary.geo_by_click_id
and all(
event["event_id"] in self.dictionary.location_by_event_id
for event in browser_events[:len(visit_path)]
)
)
]
if visit_candidates:
base_click_id = self.rng.choice(visit_candidates)
base_browser_events = self.dictionary.browser_by_click_id[base_click_id][:len(visit_path)]
else:
base_browser = self.rng.choice(self.dictionary.browser_events)
# Запасная ветка тоже восстановима: state хранит донора, а индекс идёт по кругу.
base_click_id = base_browser["click_id"]
source_events = self.dictionary.browser_by_click_id[base_click_id]
if not all(
event["event_id"] in self.dictionary.location_by_event_id
for event in source_events
):
raise ValueError(f"Fixture base_click_id has incomplete locations: {base_click_id}")
base_browser_events = [
source_events[event_index % len(source_events)]
for event_index in range(len(visit_path))
]
base_device = (
user_profile["device"]
if user_profile is not None
else self.dictionary.device_by_click_id.get(base_click_id)
)
base_geo = (
user_profile["geo"]
if user_profile is not None
else self.dictionary.geo_by_click_id.get(base_click_id)
)
new_click_id = self._new_uuid()
planned_timestamp = planned_start_at or datetime.now(timezone.utc)
if planned_timestamp.tzinfo is not None:
planned_timestamp = planned_timestamp.astimezone(timezone.utc).replace(tzinfo=None)
for base_browser, page_url_path in zip(base_browser_events, visit_path):
base_location = self.dictionary.location_by_event_id.get(base_browser["event_id"])
new_event_id = self._new_uuid()
new_timestamp = self._format_timestamp(planned_timestamp)
browser_event = {
**base_browser,
"event_id": new_event_id,
"click_id": new_click_id,
"event_timestamp": new_timestamp,
}
batch["browser_events"].append(browser_event)
if base_location:
location_event = {
**base_location,
"event_id": new_event_id,
"page_url": f"http://www.dummywebsite.com{page_url_path}",
"page_url_path": page_url_path,
}
batch["location_events"].append(location_event)
if base_device:
batch["device_events"].append({**base_device, "click_id": new_click_id})
if base_geo:
batch["geo_events"].append({**base_geo, "click_id": new_click_id})
planned_timestamp += timedelta(seconds=self._visit_pause_seconds())
return batch, base_click_id
@@ -0,0 +1,65 @@
"""Расчёт событийного бюджета тика."""
import math
import random
from datetime import datetime
from zoneinfo import ZoneInfo
from clickstream_generator.config import Config
POISSON_KNUTH_MAX_LAMBDA = 100.0
def hour_factor(now: datetime | None = None, model_timezone: str = "UTC") -> float:
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
current = now
if current is None:
raise ValueError("now is required for hour_factor")
if current.tzinfo is None:
current = current.replace(tzinfo=ZoneInfo("UTC"))
current = current.astimezone(ZoneInfo(model_timezone))
hour = current.hour
if 9 <= hour <= 18:
return 1.2
if 0 <= hour <= 5:
return 0.7
return 1.0
def _sample_poisson(lambda_tick: float, rng: random.Random) -> int:
"""Разыгрывает Poisson без underflow на больших λ."""
if lambda_tick >= POISSON_KNUTH_MAX_LAMBDA:
return max(0, round(rng.gauss(lambda_tick, math.sqrt(lambda_tick))))
count = 0
threshold = math.exp(-lambda_tick)
product = 1.0
while product > threshold:
product *= rng.random()
count += 1
return count - 1
def calculate_events_count(
config: Config,
rng: random.Random,
now: datetime | None = None,
) -> int:
"""Вычисляет количество событий для текущего тика (Poisson + jitter)."""
model_time = now or config.model_t0
factor = hour_factor(model_time, config.model_timezone)
lambda_minute = config.lambda_base_per_min * factor
model_tick_seconds = config.tick_seconds * config.model_time_speed
lambda_tick = lambda_minute * (model_tick_seconds / 60.0)
count = _sample_poisson(lambda_tick, rng)
if config.jitter_pct > 0:
jitter_factor = 1.0 + rng.uniform(
-config.jitter_pct / 100.0,
config.jitter_pct / 100.0,
)
count = int(count * jitter_factor)
return max(config.min_events_per_tick, min(count, config.max_events_per_tick))
@@ -0,0 +1,530 @@
"""Kafka-интеграция генератора."""
import json
import logging
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from clickstream_generator.metrics import METRICS_ERRORS_TOTAL, METRICS_EVENTS_TOTAL
from clickstream_generator.state import GeneratorState
from clickstream_generator.state import UnsupportedStateVersionError
logger = logging.getLogger("generator")
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
_kafka_imported = False
KafkaProducer = None
KafkaError = None
def _import_kafka():
"""Лениво импортирует Kafka-клиент, чтобы тесты могли работать без Kafka."""
global _kafka_imported, KafkaProducer, KafkaError
if not _kafka_imported:
from kafka import KafkaProducer
from kafka.errors import KafkaError
_kafka_imported = True
return KafkaProducer, KafkaError
def _with_retry(operation, max_retries: int = 5, base_delay: float = 1.0, max_delay: float = 30.0):
"""Выполняет операцию с экспоненциальным backoff."""
last_exception = None
for attempt in range(max_retries):
try:
return operation()
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
f"Operation failed (attempt {attempt + 1}/{max_retries}): "
f"{e}. Retrying in {delay:.1f}s..."
)
time.sleep(delay)
else:
logger.error(f"Operation failed after {max_retries} attempts: {e}")
raise last_exception
def _facade_attr(name: str, fallback):
"""Берёт совместимый mock из фасада generator, если тест его подменил."""
facade = sys.modules.get("generator")
return getattr(facade, name, fallback) if facade else fallback
def _retry(operation, **kwargs):
return _facade_attr("_with_retry", _with_retry)(operation, **kwargs)
def _kafka_importer():
return _facade_attr("_import_kafka", _import_kafka)
def kafka_event_value_json(event: dict) -> str:
"""Возвращает JSON value ровно в формате Kafka producer генератора."""
return json.dumps(event)
def kafka_event_value_bytes(event: dict) -> bytes:
"""Возвращает Kafka value bytes для события генератора."""
return kafka_event_value_json(event).encode("utf-8")
@dataclass
class BatchRecord:
"""Запись об отправленном батче."""
batch_id: str
started_at: datetime
finished_at: datetime
sent_total: int
sent_browser: int
sent_location: int
sent_device: int
sent_geo: int
status: str
error_message: str | None = None
def to_dict(self) -> dict:
"""Конвертирует в словарь для сериализации."""
return {
"batch_id": self.batch_id,
"started_at": self.started_at.isoformat(),
"finished_at": self.finished_at.isoformat(),
"sent_total": self.sent_total,
"sent_browser": self.sent_browser,
"sent_location": self.sent_location,
"sent_device": self.sent_device,
"sent_geo": self.sent_geo,
"status": self.status,
"error_message": self.error_message,
}
class KafkaStateManager:
"""Управление состоянием генератора в Kafka compact topic."""
STATE_TOPIC = "generator_state"
STATE_KEY = "default"
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
KafkaProducerCls, _ = _kafka_importer()()
logger.info(f"Connecting to Kafka for state management at {self.bootstrap_servers}")
self.producer = KafkaProducerCls(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
retries=3,
retry_backoff_ms=1000,
)
logger.info("Connected to Kafka for state management successfully")
def save(self, state: GeneratorState) -> None:
"""Сохраняет состояние в топик."""
value = state.to_dict()
def _do_send():
self.producer.send(self.STATE_TOPIC, key=self.STATE_KEY, value=value)
_retry(_do_send, max_retries=3, base_delay=0.5)
def flush(self) -> None:
"""Сбрасывает буфер с retry."""
def _do_flush():
self.producer.flush()
_retry(_do_flush, max_retries=3, base_delay=0.5)
def close(self) -> None:
"""Закрывает соединение."""
try:
self.producer.close()
except Exception as e:
logger.debug(f"Error closing producer (ignored): {e}")
def load(self) -> GeneratorState | None:
"""Загружает последнее состояние из топика."""
from kafka import KafkaConsumer
logger.info(f"Loading state from topic {self.STATE_TOPIC}")
def _do_load():
consumer = KafkaConsumer(
self.STATE_TOPIC,
bootstrap_servers=self.bootstrap_servers,
auto_offset_reset="earliest",
enable_auto_commit=False,
consumer_timeout_ms=5000,
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
)
last_state = None
for message in consumer:
if message.key and message.key.decode("utf-8") == self.STATE_KEY:
last_state = message.value
consumer.close()
return last_state
try:
last_state = _retry(_do_load, max_retries=3, base_delay=0.5)
if last_state:
logger.info(
f"Restored state: tick={last_state.get('tick')}, "
f"last_batch_id={last_state.get('last_batch_id')}"
)
try:
restored = GeneratorState.from_dict(last_state)
except UnsupportedStateVersionError:
logger.error(
"Unsupported generator state version %s",
last_state.get("version", "1.0"),
)
raise
except ValueError:
logger.warning("State data was invalid, starting fresh")
return None
return restored
logger.info("No previous state found, starting fresh")
return None
except UnsupportedStateVersionError:
raise
except Exception as e:
logger.warning(f"Failed to load state: {e}, starting fresh")
return None
class KafkaStartupHistoryManifest:
"""Хранение манифеста стартовой истории в Kafka compact topic."""
MANIFEST_TOPIC = "generator_startup_history_manifest"
MANIFEST_KEY = "default"
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
KafkaProducerCls, _ = _kafka_importer()()
logger.info(
f"Connecting to Kafka for startup history manifest at {self.bootstrap_servers}"
)
self.producer = KafkaProducerCls(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
retries=3,
retry_backoff_ms=1000,
)
logger.info("Connected to Kafka for startup history manifest successfully")
def save(self, manifest: dict) -> None:
"""Сохраняет манифест стартовой истории."""
def _do_send():
self.producer.send(
self.MANIFEST_TOPIC,
key=self.MANIFEST_KEY,
value=manifest,
)
_retry(_do_send, max_retries=3, base_delay=0.5)
def flush(self) -> None:
"""Сбрасывает буфер с retry."""
def _do_flush():
self.producer.flush()
_retry(_do_flush, max_retries=3, base_delay=0.5)
def close(self) -> None:
"""Закрывает соединение."""
try:
self.producer.close()
except Exception as e:
logger.debug(f"Error closing manifest producer (ignored): {e}")
def load(self) -> dict | None:
"""Загружает последний манифест стартовой истории."""
from kafka import KafkaConsumer
logger.info(f"Loading startup history manifest from topic {self.MANIFEST_TOPIC}")
def _do_load():
consumer = KafkaConsumer(
self.MANIFEST_TOPIC,
bootstrap_servers=self.bootstrap_servers,
auto_offset_reset="earliest",
enable_auto_commit=False,
consumer_timeout_ms=5000,
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
)
last_manifest = None
for message in consumer:
if message.key and message.key.decode("utf-8") == self.MANIFEST_KEY:
last_manifest = message.value
consumer.close()
return last_manifest
try:
return _retry(_do_load, max_retries=3, base_delay=0.5)
except Exception as e:
logger.warning(f"Failed to load startup history manifest: {e}")
return None
class KafkaDataTopicReader:
"""Читает устойчивый снимок data-топиков для накопительного manifest."""
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
def load(self) -> dict[str, list[dict]]:
"""Возвращает все события data-топиков после остановки live-потока."""
from kafka import KafkaConsumer, TopicPartition
consumer = KafkaConsumer(
bootstrap_servers=self.bootstrap_servers,
enable_auto_commit=False,
value_deserializer=lambda value: json.loads(value.decode("utf-8")),
)
topics = {topic: [] for topic in DATA_TOPICS}
try:
partitions = []
for topic in DATA_TOPICS:
topic_partitions = consumer.partitions_for_topic(topic) or set()
partitions.extend(
TopicPartition(topic, partition)
for partition in topic_partitions
)
if not partitions:
return topics
consumer.assign(partitions)
consumer.seek_to_beginning(*partitions)
end_offsets = consumer.end_offsets(partitions)
empty_polls = 0
while any(
consumer.position(partition) < end_offsets[partition]
for partition in partitions
):
records = consumer.poll(timeout_ms=1000)
if not records:
empty_polls += 1
if empty_polls >= 5:
raise RuntimeError(
"не удалось дочитать data-топики до зафиксированных "
"конечных смещений"
)
continue
empty_polls = 0
for partition, messages in records.items():
for message in messages:
if (
message.offset < end_offsets[partition]
and message.value is not None
):
topics[message.topic].append(message.value)
finally:
consumer.close()
return topics
def ensure_topics(bootstrap_servers: str) -> None:
"""Создаёт служебные топики, если их ещё нет."""
from kafka import KafkaAdminClient
from kafka.admin import NewTopic
from kafka.errors import TopicAlreadyExistsError
def _create_topics():
admin_client = KafkaAdminClient(bootstrap_servers=bootstrap_servers)
try:
history_topic = NewTopic(
name=KafkaBatchHistory.HISTORY_TOPIC,
num_partitions=1,
replication_factor=1,
)
state_topic = NewTopic(
name=KafkaStateManager.STATE_TOPIC,
num_partitions=1,
replication_factor=1,
topic_configs={
"cleanup.policy": "compact",
"min.cleanable.dirty.ratio": "0.1",
"delete.retention.ms": "100",
},
)
manifest_topic = NewTopic(
name=KafkaStartupHistoryManifest.MANIFEST_TOPIC,
num_partitions=1,
replication_factor=1,
topic_configs={
"cleanup.policy": "compact",
"min.cleanable.dirty.ratio": "0.1",
"delete.retention.ms": "100",
},
)
for topic in [history_topic, state_topic, manifest_topic]:
try:
admin_client.create_topics([topic])
logger.info(f"Created topic: {topic.name}")
except TopicAlreadyExistsError:
logger.debug(f"Topic already exists: {topic.name}")
finally:
admin_client.close()
_retry(_create_topics, max_retries=5, base_delay=1.0)
class KafkaBatchHistory:
"""Хранение истории batch в Kafka."""
HISTORY_TOPIC = "generator_batch_history"
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
self.producer = None
self._connect()
def _connect(self):
"""Устанавливает соединение с Kafka с retry."""
KafkaProducerCls, _ = _kafka_importer()()
def _do_connect():
logger.info(f"Connecting to Kafka for history at {self.bootstrap_servers}")
self.producer = KafkaProducerCls(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
retries=3,
retry_backoff_ms=1000,
)
logger.info("Connected to Kafka for history successfully")
_retry(_do_connect, max_retries=5, base_delay=1.0)
def add(self, record: BatchRecord):
"""Добавляет запись в историю."""
key = record.batch_id
value = record.to_dict()
def _do_send():
self.producer.send(self.HISTORY_TOPIC, key=key, value=value)
try:
_retry(_do_send, max_retries=3, base_delay=0.5)
except Exception as e:
logger.warning(f"Failed to send history record after retries: {e}, attempting reconnect")
self._connect()
_retry(_do_send, max_retries=2, base_delay=0.5)
def flush(self):
"""Сбрасывает буфер с retry."""
def _do_flush():
self.producer.flush()
_retry(_do_flush, max_retries=3, base_delay=0.5)
def close(self):
"""Закрывает соединение."""
try:
if self.producer:
self.producer.close()
except Exception as e:
logger.debug(f"Error closing history producer (ignored): {e}")
class KafkaPublisher:
"""Публикация событий в Kafka с retry и реконнектом."""
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
self.producer = None
self._connect()
def _connect(self):
"""Устанавливает соединение с Kafka с retry."""
KafkaProducerCls, _ = _kafka_importer()()
def _do_connect():
logger.info(f"Connecting to Kafka at {self.bootstrap_servers}")
self.producer = KafkaProducerCls(
bootstrap_servers=self.bootstrap_servers,
value_serializer=kafka_event_value_bytes,
key_serializer=lambda k: k.encode("utf-8") if k else None,
batch_size=16384,
linger_ms=100,
retries=3,
retry_backoff_ms=1000,
)
logger.info("Connected to Kafka successfully")
_retry(_do_connect, max_retries=5, base_delay=1.0)
def _publish_with_retry(self, topic: str, events: list[dict]) -> tuple[int, int]:
"""Внутренняя функция публикации с retry на уровне batch."""
if not self.producer:
raise RuntimeError("Producer not connected")
sent = 0
errors = 0
futures = []
for event in events:
key = event.get("event_id") or event.get("click_id")
try:
future = self.producer.send(topic, key=key, value=event)
futures.append(future)
except Exception as e:
logger.error(f"Failed to send message to {topic}: {e}")
errors += 1
METRICS_ERRORS_TOTAL.labels(topic=topic).inc()
for future in futures:
try:
future.get(timeout=10)
sent += 1
METRICS_EVENTS_TOTAL.labels(topic=topic).inc()
except Exception as e:
logger.error(f"Failed to confirm message delivery: {e}")
errors += 1
METRICS_ERRORS_TOTAL.labels(topic=topic).inc()
return sent, errors
def publish(self, topic: str, events: list[dict]) -> tuple[int, int]:
"""Публикует события в топик с retry и автоматическим реконнектом."""
def _do_publish():
return self._publish_with_retry(topic, events)
try:
return _retry(_do_publish, max_retries=3, base_delay=0.5)
except Exception as e:
logger.warning(f"Publish failed after retries: {e}, attempting reconnect")
self._connect()
return _retry(_do_publish, max_retries=2, base_delay=0.5)
def flush(self):
"""Сбрасывает буфер с retry."""
def _do_flush():
if self.producer:
self.producer.flush()
_retry(_do_flush, max_retries=3, base_delay=0.5)
def close(self):
"""Закрывает соединение."""
try:
if self.producer:
self.producer.close()
except Exception as e:
logger.debug(f"Error closing producer (ignored): {e}")
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more