Merge ветки feature/data-generator в main
This commit is contained in:
commit
0e312b3c6c
135 files changed
+24248
-781
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
+6
-1
@@ -124,6 +124,8 @@ desktop.ini
|
|||||||
# Тестовые данные и артефакты
|
# Тестовые данные и артефакты
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Файлы с данными (но не папки data/ — там исходные данные проекта)
|
# Файлы с данными (но не папки data/ — там исходные данные проекта)
|
||||||
|
# JSON-артефакты генератора; сиды data/*.jsonl остаются под Git.
|
||||||
|
data/*.json
|
||||||
*.csv.gz
|
*.csv.gz
|
||||||
*.jsonl.gz
|
*.jsonl.gz
|
||||||
*.parquet
|
*.parquet
|
||||||
@@ -173,4 +175,7 @@ $RECYCLE.BIN/
|
|||||||
node_modules
|
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 дней).
|
||||||
+65
@@ -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-круг выполнен, новых блокирующих находок нет.
|
||||||
+218
@@ -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 был со свежим контекстом, но той же
|
||||||
|
родословной, а не внешней моделью.
|
||||||
+227
@@ -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.
|
||||||
+117
@@ -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`
|
||||||
+90
@@ -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. Артефакт стартовой истории для уроков должен родиться **после** них,
|
||||||
|
иначе он протухнет по антисмешиванию и проход по урокам придётся повторять.
|
||||||
+159
@@ -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.
|
||||||
+89
@@ -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 и
|
||||||
|
данных нет, правка ограничена определением чарта и документацией).
|
||||||
+58
@@ -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` динамически;
|
||||||
|
блокером не является.
|
||||||
+208
@@ -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 (артефакт курса рождается из этого
|
||||||
|
профиля — эту задачу лучше сделать до него).
|
||||||
+46
@@ -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. Здесь только граница миров и доказательства именно этой границы.
|
||||||
+50
@@ -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.
|
||||||
+48
@@ -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 должна пользоваться уже доверенными
|
||||||
|
проверками, но текст уроков не является частью этой задачи.
|
||||||
+50
@@ -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`
|
||||||
|
(появление этого гейта).
|
||||||
+73
@@ -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"]`) и про формат артефакта.
|
||||||
@@ -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
@@ -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) целевой источник
|
||||||
|
аналитики — генерация (стартовая история и живой поток); перевод загрузки, витрин
|
||||||
|
и дашбордов на неё входит в текущую работу, детальный план — отдельной спекой.
|
||||||
@@ -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
|
||||||
@@ -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) — учебная программа на этом
|
||||||
стенде.
|
стенде.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -27,4 +27,13 @@ scrape_configs:
|
|||||||
- targets: ["statsd-exporter:9102"]
|
- targets: ["statsd-exporter:9102"]
|
||||||
labels:
|
labels:
|
||||||
instance: Airflow-1
|
instance: Airflow-1
|
||||||
|
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
|
honor_labels: true
|
||||||
@@ -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
@@ -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
|
||||||
|
K-->>User: ✅ Непрерывный поток в Kafka
|
||||||
end
|
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
@@ -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 баллов
|
|
||||||
@@ -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-трансформации, модель данных или алерты.»
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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-спеку** и здесь
|
||||||
|
намеренно не фиксируется.
|
||||||
@@ -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`** — только после того, как генератор научится
|
||||||
|
своей фактуре (отдельная спека).
|
||||||
|
- **Учебная ценность растёт**: со своей фактурой проектируем интересные
|
||||||
|
распределения (перекос по странам, набор устройств, привязка кампаний, намеренный
|
||||||
|
брак) вместо случайного набора из чужого сида — но это уже шаг про фактуру, не
|
||||||
|
этот.
|
||||||
@@ -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»
|
||||||
|
|||||||
@@ -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/...`, иначе менти запутается, когда выполнит запрос на стенде.
|
||||||
- **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к
|
- **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к
|
||||||
|
|||||||
+59
-4
@@ -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,8 +30,8 @@
|
|||||||
Репозиторий — рабочий сквозной стенд кликстрим-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
@@ -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-генератор
|
||||||
|
или только стартовая история.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
|
|
||||||
Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к
|
Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к
|
||||||
|
|||||||
@@ -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 участнику |
|
||||||
|
|||||||
@@ -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` | колонка заполнена временем сообщения |
|
||||||
|
|||||||
@@ -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` рядом.
|
||||||
|
|
||||||
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
|
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
|
|||||||
@@ -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}`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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 курса).
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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()
|
||||||
@@ -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
Reference in new issue
Block a user