diff --git a/.claude/agents/deep-reasoner.md b/.claude/agents/deep-reasoner.md new file mode 100644 index 0000000..c6c61a4 --- /dev/null +++ b/.claude/agents/deep-reasoner.md @@ -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. diff --git a/.claude/agents/fast-worker.md b/.claude/agents/fast-worker.md new file mode 100644 index 0000000..22caccb --- /dev/null +++ b/.claude/agents/fast-worker.md @@ -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. diff --git a/.gitignore b/.gitignore index 6ce5b0d..8d774d6 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,8 @@ desktop.ini # Тестовые данные и артефакты # ============================================================================= # Файлы с данными (но не папки data/ — там исходные данные проекта) +# JSON-артефакты генератора; сиды data/*.jsonl остаются под Git. +data/*.json *.csv.gz *.jsonl.gz *.parquet @@ -173,4 +175,7 @@ $RECYCLE.BIN/ node_modules .playwright-cli -.playwright-mcp \ No newline at end of file +.playwright-mcp +# Локальные настройки Claude Code (агенты .claude/agents/ коммитятся) +.claude/* +!.claude/agents/ diff --git a/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md b/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md new file mode 100644 index 0000000..b91fcfc --- /dev/null +++ b/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md @@ -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 diff --git a/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md b/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md new file mode 100644 index 0000000..9edff19 --- /dev/null +++ b/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md @@ -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` оставлен тонким +фасадом и точкой входа для совместимости. diff --git a/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md b/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md new file mode 100644 index 0000000..0185172 --- /dev/null +++ b/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md @@ -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 diff --git a/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md b/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md new file mode 100644 index 0000000..3f9de5b --- /dev/null +++ b/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md @@ -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` снова трактуется +как бюджет событий, а не как число рождений визитов. diff --git a/.scratch/feature-data-generator/issues/04-user-population-and-returns.md b/.scratch/feature-data-generator/issues/04-user-population-and-returns.md new file mode 100644 index 0000000..8347557 --- /dev/null +++ b/.scratch/feature-data-generator/issues/04-user-population-and-returns.md @@ -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 теперь создаются через единый +ГПСЧ генератора, а кулдаун пользователя начинается от запланированного времени +последнего события визита, а не от времени позднего тика. diff --git a/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md b/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md new file mode 100644 index 0000000..4c7f9c6 --- /dev/null +++ b/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md @@ -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. diff --git a/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md b/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md new file mode 100644 index 0000000..084d5cc --- /dev/null +++ b/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md @@ -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. diff --git a/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md b/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md new file mode 100644 index 0000000..e979ed4 --- /dev/null +++ b/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md @@ -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` — без замечаний. diff --git a/.scratch/generator-model-time-startup-history/PRD.md b/.scratch/generator-model-time-startup-history/PRD.md new file mode 100644 index 0000000..be3e4d1 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/PRD.md @@ -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, если учебные материалы требуют нетривиальной переделки. diff --git a/.scratch/generator-model-time-startup-history/coordinator-journal.md b/.scratch/generator-model-time-startup-history/coordinator-journal.md new file mode 100644 index 0000000..0af865e --- /dev/null +++ b/.scratch/generator-model-time-startup-history/coordinator-journal.md @@ -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`. diff --git a/.scratch/generator-model-time-startup-history/hitl-findings.md b/.scratch/generator-model-time-startup-history/hitl-findings.md new file mode 100644 index 0000000..17373d6 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/hitl-findings.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 дней). diff --git a/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md b/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md new file mode 100644 index 0000000..c4ee829 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md @@ -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 diff --git a/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md b/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md new file mode 100644 index 0000000..8534269 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md @@ -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` diff --git a/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md b/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md new file mode 100644 index 0000000..e854fcc --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.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` diff --git a/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md b/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md new file mode 100644 index 0000000..75671ce --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.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-круг выполнен, новых блокирующих находок нет. diff --git a/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md b/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md new file mode 100644 index 0000000..27c1104 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md @@ -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 был со свежим контекстом, но той же + родословной, а не внешней моделью. diff --git a/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md b/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md new file mode 100644 index 0000000..5c4d983 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md @@ -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. diff --git a/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md b/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md new file mode 100644 index 0000000..d169ddb --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md @@ -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` diff --git a/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md b/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md new file mode 100644 index 0000000..4b44b0c --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md @@ -0,0 +1,90 @@ +Status: done + +# Миграция учебных материалов с архивного сида на генерацию + +## Parent + +`.scratch/generator-model-time-startup-history/PRD.md` + +## Why + +После перевода штатного аналитического контура на стартовую историю генератора +часть учебных материалов всё ещё описывает `data/*.jsonl` как основной источник +данных стенда. Это нельзя править внутри issue 06: потребуется пройти уроки и +сохранить понятный учебный путь. + +Развилка «отдельный урок про генератор vs скрытая инфраструктура» закрыта +(2026-07-04, `docs/course/PRD.md` §7): **отдельного урока не будет, генератор — +скрытая инфраструктура**. Устройство генератора — это разработка бэкенда и +математика, не инженерия данных; в цели курса не попадает. Уроки адаптируем, +не вводя марковские цепи и сложный Python в путь менти. + +## What to build + +Обновить курс и связанные материалы (тест-план) так, чтобы основной путь был: + +```text +startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset +``` + +Архивный `data/*.jsonl` оставить только как временную фактуру генератора. +Генератор в уроках подаётся как готовый источник данных стенда («откуда берутся +данные»), без погружения в его устройство. + +## Acceptance criteria + +- [x] `docs/course/` больше не ведёт ученика через `make data` или `kafka_load` + как основной путь получения аналитических данных. +- [x] Уроки явно объясняют, что `data/*.jsonl` пока остаётся кладовкой значений + для генератора, а не источником аналитического контура. +- [x] Уроки не вводят устройство генератора (марковская модель, внутренний + Python) в путь менти: генератор упоминается только как готовый источник данных. +- [x] Тест-план согласован с новым штатным путём запуска. +- [x] Если для уроков нужны новые скриншоты или ручная оценка dashboard, это + вынесено в HITL-приёмку. + +## Notes + +Реальный объём больше, чем кажется: `make data` вплетён во **все** уроки 00–05, +а не только в урок 06 (проверка 2026-07-04, `grep -rn "make data" docs/course/`): + +- `docs/course/README.md` — быстрый старт через `LIMIT=50 make data`; +- `lessons/00_kafka_intro.md` — `make data` играет роль трекера, на нём построен + весь разбор партиций и offset'ов; +- `lessons/01–05` — `LIMIT=50 make data` в запуске, «полном сбросе» и таблицах + проверок; +- `docs/TEST_PLAN.md` — сценарии на старом пути. + +Тонкое место — урок 00: там ручная заливка используется как учебный приём +(наглядно видно сообщения в топике). Решить при миграции, чем её заменить, не +потеряв наглядность; критерий приёмки запрещает `make data` как **основной путь +аналитики**, а не как локальный демонстрационный приём внутри урока. + +Схема потока в `docs/course/PRD.md` §1 уже обновлена (поправка 2026-07-04) — PRD +дополнительно править не нужно. + +Демо-материалов в скоупе нет: демо-употребление стенда устарело (демо выросло в +отдельный проект), шпаргалки удалены из репозитория 2026-07-04 (см. поправку в +шапке `docs/course/PRD.md`). + +Рекомендуемый режим ревью по coordinator-loop: обычный (документы и уроки, кода +генератора и state задача не трогает); человеческая приёмка — проход по урокам +через пульт (HITL). + +## Blocked by + +- Жёстких блокеров нет: штатный путь (`make generated-history-analytics`) уже + существует, уроки можно вести через него. +- Мягкая зависимость от + `07-startup-history-portable-artifact-and-usage-docs.md`: runbook из issue 07 + изменит команды запуска и восстановления стенда, а уроки будут на него + ссылаться. Делать эту задачу после него, иначе уроки придётся править дважды. +- Мягкая зависимость от `12-generator-control-dag.md` (решение 2026-07-04): + человеческая приёмка миграции — это проход по урокам 00–05, и вести её удобнее + через DAG-пульт, а не через консоль. Поэтому эта задача идёт последней в + очереди — после 12. Порядок всей очереди — в PRD, раздел «Задачи». +- Мягкая зависимость от `14-fast-teaching-profile.md` и + `09-seam-browser-fixture-not-preserved.md` (дополнено 2026-07-04): обе меняют + мир — 14 переводит профиль `daily-wave` на быстрый темп, 09 меняет схему + state. Артефакт стартовой истории для уроков должен родиться **после** них, + иначе он протухнет по антисмешиванию и проход по урокам придётся повторять. diff --git a/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md b/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md new file mode 100644 index 0000000..a64807a --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md @@ -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. diff --git a/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md b/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md new file mode 100644 index 0000000..37b9608 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md @@ -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 и + данных нет, правка ограничена определением чарта и документацией). diff --git a/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md b/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md new file mode 100644 index 0000000..afd0d11 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md @@ -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 и команды + экспорта/импорта появляются там; эта задача переводит их на единый стиль. diff --git a/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md b/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md new file mode 100644 index 0000000..d9cc430 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md @@ -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` динамически; + блокером не является. diff --git a/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md b/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md new file mode 100644 index 0000000..ea7e6c1 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md @@ -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 отдельно проверяет стык, где +переходящий визит гарантирован сценарием. diff --git a/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md b/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md new file mode 100644 index 0000000..68506c1 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md @@ -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 (артефакт курса рождается из этого +профиля — эту задачу лучше сделать до него). diff --git a/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md b/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md new file mode 100644 index 0000000..2517914 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md @@ -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. Здесь только граница миров и доказательства именно этой границы. diff --git a/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md b/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md new file mode 100644 index 0000000..2896a90 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md @@ -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. diff --git a/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md b/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md new file mode 100644 index 0000000..4198e7b --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md @@ -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 должна пользоваться уже доверенными + проверками, но текст уроков не является частью этой задачи. diff --git a/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md b/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md new file mode 100644 index 0000000..b08aa10 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md @@ -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 требует нового поддержанного режима + перезаливки, не прятать это в тексте урока: оформить как явное решение и + покрыть проверкой. diff --git a/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md b/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md new file mode 100644 index 0000000..78aba8a --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md @@ -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. diff --git a/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md b/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md new file mode 100644 index 0000000..a9d22fd --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md @@ -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` +(появление этого гейта). diff --git a/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md b/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md new file mode 100644 index 0000000..1a39651 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md @@ -0,0 +1,73 @@ +Status: done + +# Подготовка ветки к слиянию: .gitignore для артефактов и необязательные поля формы + +## Parent + +`.scratch/generator-model-time-startup-history/PRD.md` — закрывающая +гигиена перед слиянием `feature/data-generator` в `main`. Находка F1 — из +`.scratch/generator-model-time-startup-history/hitl-findings.md`. + +## Что не так + +1. **Побочные артефакты backfill не покрыты `.gitignore`.** После HITL в + `data/` лежит `ci_backfill.json` (49 МБ) — риск случайного коммита. + Сиды проекта в `data/` — это `*.jsonl`, они под git и должны остаться. +2. **F1 (BUG):** в форме `generator_control` (Trigger DAG w/ config) все + пять необязательных полей (`duration`, `seed`, `model_time_speed`, + `artifact_path`, `expected_t_end`) показаны обязательными: красная `*`, + браузер не даёт отправить форму с пустым полем. Канонический запуск + «выбрать профиль, остальное пусто» через UI невозможен. + +## Причина F1 (проверено) + +В `airflow/dags/generator_control_dag.py:230-265` эти `Param(...)` +объявлены с `type="string"`. Шаблон формы Airflow вешает `*` и HTML-атрибут +`required` на каждое поле, у которого в `type` нет `"null"`. + +Проверено по Context7 (`/apache/airflow/2.10.5`, 2026-07-19): идиома +необязательного строкового параметра — `Param(None, type=["null", "string"])`; +поле с `"null"` в `type` форма не помечает обязательным. + +Чтение параметров в DAG уже терпимо к `None`: везде +`str(_param(...) or "").strip()` (строки 71-161), поэтому смена пустого +значения с `""` на `None` ничего не ломает — но это надо перепроверить +глазами по каждому месту чтения. + +## Что сделать + +- [x] В `.gitignore` добавить правило `data/*.json` (раздел «Тестовые + данные и артефакты») с комментарием: артефакты генератора; сиды + `data/*.jsonl` остаются под git. +- [x] Пять необязательных `Param` в `airflow/dags/generator_control_dag.py` + перевести на `Param(None, type=["null", "string"])`. Обязательные + (`operation`, `profile`) не трогать. У изменённых Param коротким + комментарием зафиксировать: `"null"` в type = необязательное поле + формы (проверено по Context7, Airflow 2.10.5). +- [x] Убедиться, что каждое место чтения этих параметров в DAG переживает + `None` (сегодня везде `or ""` — сверить полный список). +- [x] В `generator/tests/test_generator_control_dag_contract.py` дополнить + контракт: у пяти необязательных Param в объявлении есть `"null"`, + у `operation`/`profile` — нет. +- [x] `docs/OPERATIONS.md`: сверить описание формы `generator_control`; + если там есть обход «заполняйте все поля» — убрать, поведение + «пусто = из профиля» оставить как есть. +- [x] Дешёвые проверки зелёные: `make test` и `make lint` (нужен docker; + если в песочнице исполнителя недоступен — явно сказать в отчёте, + прогонит оркестратор). + +## Границы + +- Логику генератора (`generator/src/`) не менять. +- Профили и их состав не трогать (это отдельная задача редизайна). +- Форму DAG не перестраивать (отдельный DAG `next-day` — тоже отдельная + задача, F3). +- `data/ci_backfill.json` не удалять и не коммитить. +- Коммиты не делать. + +## Сначала прочитать + +1. Этот файл. +2. `.scratch/generator-model-time-startup-history/hitl-findings.md` — F1. +3. `airflow/dags/generator_control_dag.py` — объявления и чтение Param. +4. `generator/tests/test_generator_control_dag_contract.py`. diff --git a/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md b/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md new file mode 100644 index 0000000..97b3bc5 --- /dev/null +++ b/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md @@ -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//` +по `docs/agents/issue-tracker.md` (скилл `to-issues`, если план дробить). + +## Suggested skills (для следующей сессии) + +- **`to-issues`** — если решим дробить реализацию на задачи в локальном трекере. +- **`tdd`** — для этапа реализации (pytest-набор `generator/tests/` существует, + но писался под старую модель — пересмотр под новую неизбежен). +- **`adversarial-review`** / **`code-review`** — ревью реализации против + мат-спеки перед вливанием. +- **`conventional-commits`** — коммиты по правилам репозитория. diff --git a/.scratch/handoffs/2026-06-11-generator-issues-ready.md b/.scratch/handoffs/2026-06-11-generator-issues-ready.md new file mode 100644 index 0000000..24ce751 --- /dev/null +++ b/.scratch/handoffs/2026-06-11-generator-issues-ready.md @@ -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` — при фиксации следующих изменений. diff --git a/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md b/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md new file mode 100644 index 0000000..d3c5dc1 --- /dev/null +++ b/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md @@ -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). diff --git a/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md b/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md new file mode 100644 index 0000000..69eb7c9 --- /dev/null +++ b/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md @@ -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`». Координатор классифицировал это как `чинить до +коммита`: финальный интеграционный тест должен доказывать именно уход от старой +плоской модели, а не только факт публикации. diff --git a/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md b/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md new file mode 100644 index 0000000..4e1f133 --- /dev/null +++ b/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md @@ -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) `. +- 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. diff --git a/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md b/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md new file mode 100644 index 0000000..96884ee --- /dev/null +++ b/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md @@ -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`). diff --git a/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md b/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md new file mode 100644 index 0000000..0526477 --- /dev/null +++ b/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md @@ -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. diff --git a/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md b/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md new file mode 100644 index 0000000..0f671b6 --- /dev/null +++ b/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md @@ -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). diff --git a/.scratch/handoffs/2026-07-04-generator-backlog-triage.md b/.scratch/handoffs/2026-07-04-generator-backlog-triage.md new file mode 100644 index 0000000..658fc68 --- /dev/null +++ b/.scratch/handoffs/2026-07-04-generator-backlog-triage.md @@ -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. diff --git a/.scratch/handoffs/2026-07-04-generator-chain-review.md b/.scratch/handoffs/2026-07-04-generator-chain-review.md new file mode 100644 index 0000000..687fc98 --- /dev/null +++ b/.scratch/handoffs/2026-07-04-generator-chain-review.md @@ -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. diff --git a/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md b/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md new file mode 100644 index 0000000..b38c3bd --- /dev/null +++ b/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md @@ -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-теста зелёные. diff --git a/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md b/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md new file mode 100644 index 0000000..fbbe33f --- /dev/null +++ b/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md @@ -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. diff --git a/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md b/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md new file mode 100644 index 0000000..d29eae4 --- /dev/null +++ b/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md @@ -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` — коммиты. diff --git a/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md b/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md new file mode 100644 index 0000000..77329c8 --- /dev/null +++ b/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md @@ -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. diff --git a/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md b/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md new file mode 100644 index 0000000..b9d3ac5 --- /dev/null +++ b/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md @@ -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"]`) и про формат артефакта. diff --git a/AGENTS.md b/AGENTS.md index 6cc9fc1..99e1b2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). @@ -52,7 +60,7 @@ Single-context: `CONTEXT.md` и `docs/adr/` в корне репозитория - [docs/COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов. - [docs/course/](./docs/course/) — продвинутый учебный курс «со звёздочкой» на базе стенда (PRD, план обучения, стандарт уроков); начинать с [docs/course/README.md](./docs/course/README.md). - [plans/](./plans/) — legacy-планы (использовать как исторический контекст, не как источник истины). -- `.scratch/handoffs/YYYY-MM-DD-.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md). +- `.scratch/handoffs/YYYYMMDD-HHMM-.md` — handoff'ы для продолжения работы в новой сессии (одноразовые, коммитятся, уборка best-effort; скилл `handoff` пишет сюда). См. [docs/adr/0003-handoffs-in-scratch.md](./docs/adr/0003-handoffs-in-scratch.md). ## Ограничения по структуре diff --git a/CONTEXT.md b/CONTEXT.md index 1f5b847..79775d2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,8 +22,11 @@ `domain_sessionid`). Поэтому в этой модели **сессия ≡ визит ≡ `click_id`** — группа событий под одним идентификатором, а не окно активности по тайм-ауту. Если в будущем понадобится «настоящая» сессия по 30-минутному окну неактивности — -это отдельная производная над `user_domain_id` + `event_ts`; на текущих данных -она дала бы те же группы (разброс времени внутри `click_id` ≤ 1 мин). +это отдельная производная над `user_domain_id` + `event_ts`, и на сиде она дала +бы **другие** группы: в полном сиде 11 внутрисессионных пауз превышают 30 минут, +а разброс времени внутри `click_id` доходит до 49 минут (см. профиль +сид-датасета ниже; ранее здесь ошибочно значилось «разброс ≤ 1 мин» — это был +замер по малому срезу файла). «Сессию» и «визит» используем как синонимы; в UI предпочитаем «визит», когда важно подчеркнуть, что это не сессия-по-тайм-ауту. @@ -44,9 +47,61 @@ user_domain_id (пользователь, постоянный) Здоровые данные дают пирамиду `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 пользователей = 99 `click_id` в полном файле). Поэтому `uniqExact(user_domain_id)` и `uniqExact(click_id)` дают одинаковое число. @@ -58,11 +113,32 @@ user_domain_id (пользователь, постоянный) (события на визит, доля визитов с одним событием), а различие «пользователь 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). -Витрины (`dm.*`) и дашборд Superset строятся на статическом сиде, не на потоке. +Сейчас витрины (`dm.*`) и дашборд Superset строятся на статическом сиде. По +[ADR-0006](./docs/adr/0006-generation-as-sole-analytics-source.md) целевой источник +аналитики — генерация (стартовая история и живой поток); перевод загрузки, витрин +и дашбордов на неё входит в текущую работу, детальный план — отдельной спекой. diff --git a/Makefile b/Makefile index 3c7b388..c5db886 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,33 @@ .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 \ - 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 +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: - $(COMPOSE) up -d + $(COMPOSE) up -d --build clickhouse kafka kafka-ui postgres-metadata airflow-webserver airflow-scheduler prometheus grafana kafka-exporter statsd-exporter # Остановить и удалить контейнеры/сети текущего проекта down: - $(COMPOSE) down + $(COMPOSE) --profile live-generator down # Полная очистка окружения проекта (включая volumes) clean: - $(COMPOSE) down -v --remove-orphans + $(COMPOSE) --profile live-generator down -v --remove-orphans logs: $(COMPOSE) logs -f --tail=200 $(service) @@ -35,6 +45,34 @@ data: transform: 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) reload-monitoring: @echo "=== Перезагрузка сервисов мониторинга ===" @@ -85,3 +123,72 @@ superset-ui: # Перезапуск Superset superset-restart: $(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 diff --git a/README.md b/README.md index 63e2f85..f9d8bb2 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,13 @@ витринами. Поток данных коротко: -`data/*.jsonl → Airflow (kafka_load) → Kafka → ClickHouse (слой STG) → Airflow -(etl_pipeline: STG → ODS → DDS → DM) → Superset`. +- **стартовая история**: `generator backfill → Kafka → ClickHouse (STG) → + batch STG → ODS → DDS → DM → Superset`. +- **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL + → Superset`. + +Файлы `data/*.jsonl` больше не основной источник аналитики. Пока они остаются +архивной кладовкой значений для генератора: браузеры, страны, устройства и UTM. ## Куда дальше @@ -23,36 +28,77 @@ ## Быстрый старт -Стенд управляется через Airflow — это основной рабочий способ. Отдельные shell-скрипты в -`scripts/` оставлены как запасной вариант для локальных прогонов (см. -[OPERATIONS](./docs/OPERATIONS.md)). +Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`, +`git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки. + +Для ручной работы поднимите стенд и создайте стартовую историю через Airflow: ```bash -# 1. Поднять весь стек make up -docker compose ps # убедиться, что контейнеры запустились +make ddl +docker compose ps ``` -Дальше — три шага в Airflow (веб-интерфейс `http://localhost:8080`, логин и пароль -`admin`/`admin`). Сними каждый DAG с паузы (кнопка Unpause) и запусти по очереди: +Откройте Airflow: `http://localhost:8080` (`admin/admin`). Сначала снимите +паузу с DAG `etl_pipeline` (переключатель слева от имени): на свежем стенде он +создаётся на паузе, и `backfill` откажется стартовать. Затем запустите +`generator_control` с операцией `backfill`: DAG создаст стартовую историю, +запустит ETL и выполнит `check`. `make up` не запускает live-генератор; live +включается отдельно командой `make generator-continue`. -1. `ddl_init` — создаёт базы, таблицы и представления в ClickHouse. -2. `kafka_load` — заливает события из `data/*.jsonl` в Kafka. -3. `etl_pipeline` — прогоняет цепочку STG → ODS → DDS → DM. +После обновления репозитория снова выполните `make up`: команда пересобирает +Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд +собирается позже, когда DM уже готов: через `make generated-history-analytics` +или `make superset-init`. -Те же шаги можно запускать из командной строки — это удобно для скриптов: +Для полностью автоматического чистого прогона из консоли есть команда — это +тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов +(DDL она применяет сама, отдельный `make ddl` не нужен): ```bash -docker compose exec -T airflow-webserver airflow dags trigger ddl_init - -# Загрузить первые 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}' +make generated-history-analytics ``` +По умолчанию это быстрый профиль `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 @@ -76,17 +122,16 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12 Готовый дашборд в Superset: `http://localhost:8088/superset/dashboard/ecommerce-analytics/` — он создаётся -автоматически через минуту-две после `make up`. Состав и настройка дашборда описаны в +во время `make generated-history-analytics`. Состав и настройка дашборда описаны в [SUPERSET_DASHBOARD](./docs/SUPERSET_DASHBOARD.md). ## Как устроен поток данных ```mermaid flowchart LR - subgraph AF["Airflow"] - D1["ddl_init"] - D2["kafka_load"] - D3["etl_pipeline"] + subgraph GEN["Generator"] + BF["backfill"] + LIVE["live"] end subgraph Kafka["Kafka"] @@ -100,11 +145,12 @@ flowchart LR DM["DM: витрины VIEW"] end - D2 -->|загрузка JSONL| Kafka -->|Kafka MV| STG + BF -->|стартовая история| Kafka + LIVE -->|продолжение| Kafka + Kafka -->|Kafka MV| STG STG -->|batch| ODS -->|batch| DDS -->|VIEW| DM - D1 -.->|DDL| CH - D3 -.->|batch| ODS & DDS + DDL["DDL"] -.-> CH ``` «Грязные» записи не роняют пайплайн: ошибки разбора складываются в `ods.*_errors` и в @@ -119,6 +165,8 @@ flowchart LR обоснование решений. - [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов, мониторинг, частые проблемы. +- [Runbook стартовой истории](./docs/runbooks/startup-history.md) — экспорт, + импорт и live-продолжение из готового артефакта. - [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять. - [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом стенде. diff --git a/airflow/dags/generator_control_dag.py b/airflow/dags/generator_control_dag.py new file mode 100644 index 0000000..3b80735 --- /dev/null +++ b/airflow/dags/generator_control_dag.py @@ -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 diff --git a/airflow/requirements.txt b/airflow/requirements.txt index 9504b3c..94184f2 100644 --- a/airflow/requirements.txt +++ b/airflow/requirements.txt @@ -6,5 +6,8 @@ psycopg2-binary==2.9.9 # ClickHouse operator/hook для DAG'ов airflow-clickhouse-plugin==1.6.0 -# Kafka client для загрузки данных (DAG kafka_load) +# Kafka client для загрузки данных и пульта генератора kafka-python==2.0.6 + +# Метрики импортируются кодом генератора; HTTP-сервер в Airflow-задачах выключен +prometheus-client==0.21.1 diff --git a/configs/grafana/provisioning/dashboards/generator-overview.json b/configs/grafana/provisioning/dashboards/generator-overview.json new file mode 100644 index 0000000..276635a --- /dev/null +++ b/configs/grafana/provisioning/dashboards/generator-overview.json @@ -0,0 +1,1354 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Обзор метрик Generator — автономного генератора событий для Kafka", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlPu" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "evts/min" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": true, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum(rate(generator_events_total[1m])) * 60", + "refId": "A" + } + ], + "title": "Total Events/min (all 4 topics)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 1 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "histogram_quantile(0.99, sum(rate(generator_tick_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p99", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "histogram_quantile(0.50, sum(rate(generator_tick_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "median", + "refId": "B" + } + ], + "title": "Tick Duration (p50/p99)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "dateTimeFromNow" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 1 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "generator_last_success_timestamp * 1000", + "refId": "A" + } + ], + "title": "Last Successful Tick", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 5, + "panels": [], + "title": "Events by Topic", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 6, + "maxDataPoints": 50, + "options": { + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "reverse": false, + "unit": "short" + } + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum by (topic) (increase(generator_events_total[1h]))", + "format": "time_series", + "legendFormat": "{{topic}}", + "refId": "A" + } + ], + "title": "Events per Hour by Topic", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "evts/min" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "rate(generator_events_total[1m]) * 60", + "legendFormat": "{{topic}}", + "refId": "A" + } + ], + "title": "Events Rate by Topic", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "browser_events" + }, + "properties": [ + { + "id": "color", + "value": { + "fixed": "#5794F2" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "location_events" + }, + "properties": [ + { + "id": "color", + "value": { + "fixed": "#F2CC0C" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "device_events" + }, + "properties": [ + { + "id": "color", + "value": { + "fixed": "#73BF69" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "geo_events" + }, + "properties": [ + { + "id": "color", + "value": { + "fixed": "#B877D9" + } + } + ] + } + ] + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 8, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum by (topic) (generator_events_total)", + "format": "table", + "legendFormat": "{{topic}}", + "refId": "A" + } + ], + "title": "Total Events by Topic", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Value": "Count" + } + } + } + ], + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 9, + "panels": [], + "title": "Errors", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 19 + }, + "id": 10, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum(generator_publish_errors_total) or on() vector(0)", + "refId": "A" + } + ], + "title": "Total Errors", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "err/min" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 19 + }, + "id": 11, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum(rate(generator_publish_errors_total[5m])) * 60 or on() vector(0)", + "refId": "A" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 19 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "sum by (topic) (rate(generator_publish_errors_total[5m]) * 60) or on() vector(0)", + "legendFormat": "{{topic}}", + "refId": "A" + } + ], + "title": "Errors by Topic", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 13, + "panels": [], + "title": "Tick Statistics", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 14, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "histogram_quantile(0.50, sum(rate(generator_tick_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "histogram_quantile(0.95, sum(rate(generator_tick_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "histogram_quantile(0.99, sum(rate(generator_tick_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "Tick Duration Distribution", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 15, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "**Hour Factor**\n\n🌙 Night (00-05): **0.7x**\n📈 Peak (09-18): **1.2x**\n☀️ Other: **1.0x**\n\n*Dashboard optimized for GEN_TICK_SECONDS=5s*", + "mode": "markdown" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "refId": "A" + } + ], + "title": "Hour Factor", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 16, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "**Tick Interval**\n\n⏱️ **5 seconds**\n\n*Configurable via*\n`GEN_TICK_SECONDS`", + "mode": "markdown" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "refId": "A" + } + ], + "title": "Tick Interval", + "type": "text" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 17, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "mean" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "avg_over_time(sum(increase(generator_events_total[1m]))[5m:]) / 12", + "refId": "A" + } + ], + "title": "Events per Tick (avg)", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 18, + "panels": [], + "title": "Status", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "index": 0, + "text": "DISABLED" + }, + "1": { + "color": "green", + "index": 1, + "text": "ENABLED" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 33 + }, + "id": 19, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "(time() - generator_last_success_timestamp) \u003c bool 120", + "refId": "A" + } + ], + "title": "Generator Status", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "index": 0, + "text": "DEGRADED" + }, + "1": { + "color": "green", + "index": 1, + "text": "ACTIVE" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 33 + }, + "id": 20, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "(time() - generator_last_success_timestamp) < bool 300", + "refId": "A" + } + ], + "title": "Generator Health (Heartbeat)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 33 + }, + "id": 21, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "expr": "time() - generator_last_success_timestamp", + "refId": "A" + } + ], + "title": "Time Since Last Tick", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 37 + }, + "id": 22, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "**Generator Info**\n\n- **Topics:** browser_events, location_events, device_events, geo_events (4 linked topics)\n- **Events/min:** Total = sum of all 4 topics (each tick generates events across all topics)\n- **Tick Interval:** 5s (configurable via GEN_TICK_SECONDS)\n- **Target Rate:** ~200 events/min per topic × 4 topics = ~800 total events/min\n- **Hour Factor:** Night (00-05): 0.7x | Peak (09-18): 1.2x | Other: 1.0x\n- **State:** Saved to Kafka topic 'generator_state'\n- **History:** Batch history in 'generator_batch_history'\n- **⚠️ Note:** This dashboard is optimized for GEN_TICK_SECONDS=5s. Changing the tick interval will affect metrics accuracy (Events per Tick formula, Status thresholds). Generator Health shows "alive" status only (last successful tick), not state persistence. Check logs for state save errors.\n\n**Useful Commands:**\n```bash\n# View generator logs\nmake generator-logs\n\n# Check metrics endpoint\ncurl http://localhost:9109/metrics\n\n# Read batch history\ndocker compose exec kafka kafka-console-consumer \\\n --bootstrap-server localhost:9092 \\\n --topic generator_batch_history \\\n --from-beginning | head -20\n```", + "mode": "markdown" + }, + "pluginVersion": "11.0.8", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus_uid" + }, + "refId": "A" + } + ], + "title": "Info & Commands", + "type": "text" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [ + "generator", + "kafka", + "monitoring" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "browser", + "title": "Generator Overview", + "uid": "generator-overview", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/configs/prometheus.yml b/configs/prometheus.yml index bda012d..dcfea80 100644 --- a/configs/prometheus.yml +++ b/configs/prometheus.yml @@ -27,4 +27,13 @@ scrape_configs: - targets: ["statsd-exporter:9102"] labels: 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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 223b9b6..a12caff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ x-airflow-env: &airflow-default-env AIRFLOW__METRICS__STATSD_HOST: "statsd-exporter" AIRFLOW__METRICS__STATSD_PORT: "8125" AIRFLOW__METRICS__STATSD_PREFIX: "airflow" + PYTHONPATH: /opt/airflow/generator_src services: @@ -115,6 +116,7 @@ services: - "8080:8080" volumes: - ./airflow/dags:/opt/airflow/dags + - ./generator/src:/opt/airflow/generator_src:ro - ./sql:/opt/airflow/sql:ro - ./data:/opt/airflow/data networks: @@ -140,6 +142,7 @@ services: " volumes: - ./airflow/dags:/opt/airflow/dags + - ./generator/src:/opt/airflow/generator_src:ro - ./sql:/opt/airflow/sql:ro - ./data:/opt/airflow/data networks: @@ -162,6 +165,7 @@ services: <<: *airflow-default-env volumes: - ./airflow/dags:/opt/airflow/dags + - ./generator/src:/opt/airflow/generator_src:ro - ./sql:/opt/airflow/sql:ro - ./data:/opt/airflow/data networks: @@ -284,6 +288,58 @@ services: postgres-metadata: 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: image: danielqsj/kafka-exporter:v1.9.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c61195c..eb079c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,10 +27,14 @@ flowchart LR subgraph AF["Airflow"] DAG1["ddl_init"] - DAG2["kafka_load"] + DAG2["generator_control"] DAG3["etl_pipeline"] end + subgraph GEN["Generator"] + G["generator-service"] + end + subgraph Kafka["Kafka"] K[4 топика] end @@ -53,7 +57,9 @@ flowchart LR V[витрины VIEW] end - DAG2 -->|JSONL| K -->|MV| S + DAG2 -->|startup-history backfill/import| K + G -->|live после make generator-continue| K + K -->|MV| S S -->|batch| O O -->|argMax + JOIN| D1 & D2 O -.->|ошибки| OE @@ -63,16 +69,25 @@ flowchart LR DAG3 -.->|batch| ODS & DDS ``` +В учебном стенде предусмотрены два пути загрузки: + +- `startup-history`: DAG `generator_control` создаёт или импортирует историю, добавляет следующий модельный день, запускает ETL и проверяет витрины; +- `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий. + ### Слои и их назначение ```mermaid flowchart LR subgraph AF["Airflow"] DAG1["ddl_init"] - DAG2["kafka_load"] + DAG2["generator_control"] DAG3["etl_pipeline"] end + subgraph GEN["Generator"] + G["generator-service"] + end + subgraph L1["STG"] KAFKA["Kafka Engine"] STG_T["*_raw таблицы"] @@ -91,7 +106,9 @@ flowchart LR DM_T["VIEW"] end - DAG2 -->|JSONL| KAFKA -->|MV| STG_T + DAG2 -->|startup-history| KAFKA + G -->|live| KAFKA + KAFKA -->|MV| STG_T STG_T -->|batch| ODS_T ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T ODS_T -.->|ошибки| DQ @@ -347,6 +364,7 @@ sequenceDiagram participant User as Пользователь participant Compose as Docker Compose participant Airflow as Airflow + participant Gen as generator-service participant K as Kafka participant CH as ClickHouse participant STG as stg.*_raw @@ -368,14 +386,21 @@ sequenceDiagram Airflow->>CH: sql/ddl/dm/40_dm.sql CH-->>User: ✅ Структура БД создана - User->>Airflow: Trigger kafka_load - Airflow->>K: precheck + prepare_topics - loop 4 файла - Airflow->>K: KafkaProducer.send(topic, json_line) + alt Startup-history режим + User->>Airflow: Trigger generator_control (backfill/import) + Airflow->>K: события стартовой истории + 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 + K->>CH: Потребление сообщений CH->>STG: INSERT через MV - K-->>User: ✅ Данные в Kafka CH-->>User: ✅ Данные в STG User->>Airflow: Trigger etl_pipeline @@ -607,27 +632,34 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic; ### Airflow-оркестрация -Инфраструктура Airflow развёрнута и готова к использованию: +Инфраструктура Airflow развёрнута и отвечает за DDL/ETL и стартовую историю. +Живой генератор контейнеров запускается отдельно через Makefile. ```python -# airflow/dags/ddl_init_dag.py — создание баз/таблиц (ручной запуск при bootstrap) -# airflow/dags/kafka_load_dag.py — загрузка JSONL в Kafka (через kafka-python) +# airflow/dags/ddl_init_dag.py — создание баз/таблиц +# airflow/dags/generator_control_dag.py — backfill/import/next-day/check стартовой истории # airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM) +# airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур # Учебный формат: # - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator; # - SQL-файлы вызываются по фиксированным путям; -# - загрузка данных в Kafka выполняется через DAG `kafka_load`. +# - загрузка может идти двумя путями: +# 1) startup-history через DAG `generator_control`; +# 2) live-поток через явный `make generator-continue`. # -# Основной demo-сценарий: -# ddl_init -> kafka_load -> etl_pipeline +# Базовый demo-сценарий: +# ddl_init -> generator_control(backfill/import) -> etl_pipeline -> check +# Расширенный учебный сценарий: +# make generator-continue + периодический etl_pipeline ``` -**DAG `kafka_load`**: -- Загрузка данных из `data/*.jsonl` в Kafka через `kafka-python` -- TaskGroup `precheck`: проверка Kafka, файлов, параметров -- TaskGroup `ingest`: создание топиков → параллельная загрузка 4 потоков → проверка -- Параметры: `limit` (0 = все), `reset_topics` +**DAG `generator_control`**: +- `backfill`: создаёт стартовую историю через генератор +- `import`: импортирует портативный артефакт стартовой истории +- `next-day`: пакетно добавляет следующий модельный день от текущего слепка мира +- `check`: сверяет ClickHouse с manifest стартовой истории +- После `backfill` и `import` запускает `etl_pipeline` с `full_refresh` **Подключение к ClickHouse:** - Connection: `clickhouse_default` diff --git a/docs/DE-task.md b/docs/DE-task.md index 357fba9..c0aa514 100644 --- a/docs/DE-task.md +++ b/docs/DE-task.md @@ -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** -* Postgresql + dagster + Grafana -* dlt + duckdb + Airflow + [Panel](https://panel.holoviz.org/) -* Clickhouse + Airflow + Redis + [Streamlit](https://streamlit.io/) -* sqlitedb + скрипты питона + Flask + d3.js +- "Грязные" записи не должны валить пайплайн: ошибки фиксируются в ODS/DQ-слое. +- Генератор работает отдельно от потребителей и Airflow-триггеров. +- Решение должно быть воспроизводимым и пригодным для учебной демонстрации. -Начните с маленького среза данных, чтобы ознакомиться, что за данные вам предоставляют: +## Границы MVP -* browser_events.jsonl — данные с информацией о просмотрах браузера -* device_events.jsonl — данные об устройствах, с которых пользователи пользовались сайтом -* geo_events.jsonl — данные о локации пользователя -* location_events.jsonl — вопреки названию, это данные не о локации пользователя, а непосредственно о положении пользователя на сайте и информации о том, откуда пользователь попал на страницу +- Один простой режим генератора (`steady-stream`) без сложных сценариев. +- Без production-гарантий уровня exactly-once. +- Фокус на рабочем end-to-end контуре, а не на полной симуляции реального продакшена. -Смысл задачи на **своей ВМ** развернуть инструменты и загрузить данные в Kafka и из нее прогнать их в Clickhouse /другая DB по слоям. +## Критерии готовности -В Airflow взять данные из таблиц и преобразовать, потом выгрузить в Clickhouse / другая DB в агрегированном формате под дашборд. +1. Стенд стабильно запускается и работает несколько часов. +2. Данные корректно проходят путь Kafka -> STG -> ODS -> DDS -> DM. +3. Поток событий в Kafka идёт постепенно малыми порциями, без искусственного минутного burst. +4. Есть базовый дашборд и метрики для разбора работы стенда. -Дашборд не оценивается, но будет плюсом, если получиться собрать удобный визуал. +## Где детали реализации -Записать можно на видео и отправить контактному лицу или продемонстрировать на собеседовании - -Можно использовать любые инструменты. - -Данные могут быть(скорее всего) с ошибками и компания которая их предоставляем заранее об этом сообщила т.к. они не хотят делиться 100% качественными данными полноценно с продакшена. - -### ИНФРАСТРУКТУРА - -* инфраструктура клевая и работает = 18 баллов -* инфраструктура работает, но есть баги = 12 баллов -* инфраструктура почти есть, но не совсем = 6 баллов -* инфраструктуры нет = 0 баллов - -### ДАШБОРДЫ - -* дашборды есть и удобные и работают = 12 баллов -* дашборды работают, но неудобные = 8 баллов -* дашборды почти работают = 4 балла -* дашбордов нет = 0 баллов +- Техническая архитектура: `docs/ARCHITECTURE.md` +- План по генератору: `plans/generator_demo_stream_plan.md` +- Эксплуатация и запуск: `docs/OPERATIONS.md` diff --git a/docs/DEMO_CHEATSHEET_5MIN.md b/docs/DEMO_CHEATSHEET_5MIN.md deleted file mode 100644 index 12ab372..0000000 --- a/docs/DEMO_CHEATSHEET_5MIN.md +++ /dev/null @@ -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-трансформации, модель данных или алерты.» diff --git a/docs/DEMO_SCRIPT_10_15MIN.md b/docs/DEMO_SCRIPT_10_15MIN.md deleted file mode 100644 index 16a82c3..0000000 --- a/docs/DEMO_SCRIPT_10_15MIN.md +++ /dev/null @@ -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-проверки или наблюдаемость системы.» diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 375df1c..10e16f6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -6,18 +6,33 @@ Базовые команды: -- `make up` (или `docker compose up -d`) +- `make up` (поднимает базовый стенд без Superset init; пересобирает Airflow-образ) - `make down` (остановить и удалить контейнеры/сети проекта) - `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 data` (пересоздаёт топики и заливает данные в Kafka; по умолчанию полный объём, срез — `LIMIT=50 make data`) +- `make data` (архивный путь: заливает `data/*.jsonl` в Kafka; не основной источник аналитики) - `make transform` (запускает batch-процесс ODS -> DDS -> DM) - `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд) +- `make test` (быстрый предкоммитный набор: тесты генератора, верхние + контрактные тесты и проверка compose-конфигурации) +- `make lint` (статические проверки: синтаксис Python и Bash, compose-конфиг, + пробелы в diff) - `docker compose ps` - `docker compose logs -f --tail=200 ` - `docker compose down` (сохраняет named volumes, включая `clickhouse-data`) - `docker compose down -v` (удаляет named volumes, использовать осознанно) +`make test` и `make lint` не заменяют стендовые проверки, которые управляют +volumes или live-генератором. Для стыка backfill/live отдельно запускайте +`make generated-history-runtime-check`. + ## Порты Порты задаются в `docker-compose.yml`: @@ -33,6 +48,79 @@ ## 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` - Запуск: ручной (`Trigger DAG`) @@ -41,6 +129,7 @@ ### `kafka_load` +- Архивный путь, не основной источник аналитики. - Запуск: ручной (`Trigger DAG with config`) - Параметры: - `limit` (`int`, default `0`) — количество строк (`0` = все) @@ -61,40 +150,550 @@ - Параметры: - `full_refresh` (`bool`, default `true`) — очистить DDS перед загрузкой - `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` считает события без клика, а `assert_dds_integrity` роняет DAG при `orphan_events > 0`. Проверка идёт после `load_dds` и до `load_dm_summary`, чтобы DM не собирался поверх нарушенной связи `dds.event -> dds.click`. Для `assert_dds_integrity` задано `retries=0`: повтор не чинит уже собранную сироту и только задерживает явный failed-статус. -## Рекомендуемый сценарий (фаза 2) +## Генератор событий (автономный стриминг) + +Автономный сервис для непрерывной генерации событий в Kafka. Работает независимо +от Airflow DAGs. `make up` его не запускает: live включается только явной +командой. + +### Управление ```bash -# 1. Запуск инфраструктуры -make up +# Продолжить live-поток из state +make generator-continue -# 2. Инициализация схемы (один раз) -# Airflow UI -> DAGs -> ddl_init -> Trigger DAG +# Запустить генератор с текущими env напрямую +make generator-up -# 3. Загрузка данных через Airflow -# Airflow UI -> DAGs -> kafka_load -> Trigger DAG with config -# Параметры по умолчанию: limit=0, reset_topics=true +# Остановить генератор +make generator-down -# 4. Запуск ETL -# Airflow UI -> DAGs -> etl_pipeline -> Trigger DAG with config -# {"full_refresh": true} +# Перезапуск с пересборкой +make generator-restart -# 5. Проверка результатов -docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM ods.browser_event" -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" +# Логи +make generator-logs ``` +### Конфигурация (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.*`. -- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `kafka_load`, `etl_pipeline`. +- Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`. +- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `generator_control`, + `kafka_load`, `etl_pipeline`; основной ручной пульт генератора — `generator_control`. - 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` - Mapping конфигурация: `configs/statsd_mapping.yml` - **Grafana provisioning** (`configs/grafana/provisioning/`): - - Дашборды: ClickHouse Overview, Kafka Overview, Airflow Overview + - Дашборды: ClickHouse Overview, Kafka Overview, Airflow Overview, Generator Overview - Алерты: ClickHouse, Kafka, Airflow ### После `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 --force-recreate superset-init superset ``` -- После `docker compose down -v` нужно повторно прогнать: `ddl_init` -> `kafka_load` -> `etl_pipeline`. -- После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или отсутствуют до прогона ETL; после `ddl_init` -> `kafka_load` -> `etl_pipeline` выполнить `make superset-init`. -- Для демо по умолчанию использовать малый срез данных; полный прогон делать осознанно. +- После `docker compose down -v` нужно повторно прогнать `make generated-history-analytics`. +- После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или + отсутствуют до прогона стартовой истории; используйте `make generated-history-analytics`. +- Архивную загрузку `make data` использовать только для ручных экспериментов. diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 05bebb1..faa0254 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -4,16 +4,23 @@ ## Исполняемые файлы -### Airflow (основной путь запуска) +### Airflow (ручной и учебный путь запуска) - `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/utils/kafka_helpers.py` — helper-функции для Kafka - `airflow/dags/utils/sql_helpers.py` — чтение и подготовка SQL-файлов для DAG - `airflow/dags/utils/airflow_params.py` — разбор и валидация параметров DAG - `airflow/requirements.txt` — зависимости Airflow/ClickHouse plugin +### Generator + +- `generator/src/clickstream_generator/` — исходники генератора кликстрима (модельное время, состояние мира, запись в Kafka) +- `generator/tests/` — тесты генератора +- `generator/Dockerfile` — образ generator-service + ### SQL DDL (форма таблиц): @@ -35,17 +42,27 @@ DDL (форма таблиц): - `superset/init_superset.py` — подключение к ClickHouse + создание датасетов - `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/load_kafka_data.sh` — загрузка в Kafka +- `scripts/load_kafka_data.sh` — архивная загрузка `data/*.jsonl` в Kafka - `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/prometheus.yml` — конфигурация Prometheus (scrape targets для ClickHouse, Kafka, Airflow) - `configs/statsd_mapping.yml` — маппинг StatsD → Prometheus метрик для Airflow diff --git a/docs/SUPERSET_DASHBOARD.md b/docs/SUPERSET_DASHBOARD.md index 78b4541..a27022b 100644 --- a/docs/SUPERSET_DASHBOARD.md +++ b/docs/SUPERSET_DASHBOARD.md @@ -9,20 +9,13 @@ ### 1. Запуск инфраструктуры ```bash -# Запуск всех сервисов -make up - -# Применение DDL в ClickHouse -make ddl - -# Загрузка данных в Kafka -make data - -# Запуск ETL-пайплайна (ODS → DDS → DM) -make transform +# Чистый прогон: стартовая история генератора -> DM -> Superset +make generated-history-analytics ``` -### 2. Инициализация Superset +Команда очищает volumes, генерирует стартовую историю, прогоняет batch +STG -> ODS -> DDS -> DM и создаёт metadata Superset. Если данные уже +подготовлены и нужно только пересобрать Superset: ```bash # Автоматическая инициализация (создание подключения и датасетов) @@ -32,7 +25,13 @@ make superset-init make superset-dashboard ``` -### 3. Доступ к UI +Повторный `make superset-dashboard` синхронизирует чарты этого учебного +дашборда с `CHARTS_CONFIG`: обновляет параметры, переименовывает старые имена и +может удалить лишний чарт-дубль. Удаление ограничено dashboard +`ecommerce-analytics`, поэтому одноимённые чарты менти в других dashboard не +трогаются. + +### 2. Доступ к UI Откройте в браузере: http://localhost:8088 @@ -63,17 +62,29 @@ make superset-dashboard - **🎯 Conversion to /confirmation** — доля просмотров `/confirmation` от просмотров `/home` KPI разложены в одну строку по 12-колоночной сетке Superset: четыре блока по 3 колонки. -`Unique Sessions` не вынесен отдельной KPI-плиткой, потому что в демо-данных -`user_domain_id` и `click_id` идут 1:1 и дают то же число, что `Unique Users`. +`Unique Sessions` не вынесен отдельной KPI-плиткой: в текущем дашборде важнее +развести события, пользователей и среднюю глубину визита. Генератор создаёт +повторные визиты, поэтому `user_domain_id` и `click_id` уже не идут 1:1. #### Динамика трафика - **📅 Events over Time** — линейный график событий с 5-минутными бакетами - (все события стенда укладываются в ~50 минут, поэтому часовая гранулярность - давала бы всего 2 точки и прямую линию) + (быстрый проверочный профиль покрывает 6 часов модельного времени, поэтому + 5-минутные бакеты дают видимую динамику без лишнего шума) - **📱 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-меток @@ -92,11 +103,9 @@ KPI разложены в одну строку по 12-колоночной с > **Почему именно одно зерно, а не сумма по слою.** Чарт берёт по одной > канонической таблице на слой (`browser_raw → browser_event → event → > v_events_enriched`). Если суммировать `total_rows` по всем таблицам слоя, -> в один столбец складываются таблицы разного зерна (события `1000` + визиты `99` -> + пустые error-таблицы) и получается **ложная «воронка потерь»**, которой нет. -> На одном зерне убывание становится настоящим: видимый шаг **1050 → 1000** — -> это дедупликация at-least-once потока по `event_id` в ODS -> (`ReplacingMergeTree`), а дальше число стабильно до витрины. +> в один столбец складываются таблицы разного зерна: события, визиты и +> error-таблицы. Получается **ложная «воронка потерь»**, которой нет. На одном +> зерне видно прохождение event-строк по слоям, а не сумму несравнимых таблиц. > > Настоящие сигналы качества (`rows_with_errors` в ODS, `orphan_events` в DDS) > на чистых демо-данных равны нулю и живут в `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` | | 📱 Device Type | `device_type` | Multi-select | Charts на `dm.v_events_enriched` | | 🌐 Browser | `browser_name` | Multi-select | Charts на `dm.v_events_enriched` | Фильтры работают через левую панель Superset. Click-to-filter между виджетами не включен: -клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. +клик по сектору pie chart, столбцу Top Countries, строке таблицы или funnel не меняет остальные charts. Фильтр применяется только к charts, где есть нужное поле. Агрегированные витрины `dm.v_utm_effectiveness` и `dm.v_top_pages_daily` содержат `event_date`, но не содержат @@ -135,8 +144,10 @@ make clean # Остановка с удалением volumes make logs service=superset # Логи сервиса # ETL +make generated-history-analytics # Чистый прогон генерации до Superset +CHECK_LIVE_SEAM=0 make generated-history-check # Проверка DM и Superset после backfill make ddl # Применение DDL в ClickHouse -make data # Загрузка данных в Kafka +make data # Архивная загрузка data/*.jsonl в Kafka make transform # Запуск batch-процесса # Superset @@ -177,7 +188,7 @@ python /app/superset_init/init_superset.py 1. Перейдите в **Charts → + Chart** 2. Выберите датасет (например, `dm.v_events_enriched`) 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 ...) - **Dimensions:** группировки - **Filters:** фильтры @@ -247,12 +258,7 @@ make superset-restart # Полная переинициализация docker compose down -v -docker compose up -d -make ddl -make data -make transform -make superset-init -make superset-dashboard +make generated-history-analytics ``` ### Нет данных в чартах diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 3a9ea5d..37f3e9e 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -8,15 +8,17 @@ Проверить, что стек `Kafka + ClickHouse + Airflow + Superset + Prometheus/Grafana`: - стабильно поднимается; -- загружает данные по пути `kafka_load -> STG -> etl_pipeline -> ODS/DDS/DM`; +- загружает данные по пути `startup-history/backfill -> Kafka -> STG -> ODS/DDS/DM`; - корректно обрабатывает «грязные» записи (ошибки фиксируются в ODS, пайплайн не падает); - отдает метрики и дашборды мониторинга. ## Общие принципы -- По умолчанию используем малый срез (`limit=50`) для быстрых и повторяемых проверок. -- Полный прогон (`limit=0`) выполняем отдельно как long-run сценарий. -- Основной путь запуска — через Airflow DAG. +- По умолчанию используем быстрый профиль стартовой истории (`PROFILE=ci`). +- Полный прогон выполняем отдельно через `PROFILE=daily-wave`. +- Основной ручной путь запуска — через Airflow DAG `generator_control`. +- Консольный чистый прогон `make generated-history-analytics` остаётся коротким + повторяемым сценарием для smoke и CI. - Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга. --- @@ -28,10 +30,10 @@ ### A.1 Подготовка окружения ```bash -# Полная очистка стенда -make clean +# Чистый быстрый прогон: backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset +make generated-history-analytics -# Запуск инфраструктуры +# Поднять остальные UI-сервисы после чистого прогона make up # Проверка контейнеров @@ -42,16 +44,7 @@ docker compose ps - `airflow-init` в `Exited (0)`; - остальные сервисы в `Up` (включая `superset`, `prometheus`, `grafana`, `kafka-exporter`, `statsd-exporter`). -### A.2 DDL и минимальная загрузка данных - -```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}' -``` +### A.2 Проверка стартовой истории в STG Проверки: @@ -72,12 +65,7 @@ SELECT 'geo_raw', count() FROM stg.geo_raw Ожидаем: во всех 4 таблицах `cnt > 0`. -### A.3 ETL и проверки слоев - -```bash -docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \ - --conf '{"full_refresh": true}' -``` +### A.3 Проверки слоев Проверки: @@ -123,7 +111,7 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie Критерий успеха smoke: - сервисы подняты; -- 3 DAG (`ddl_init`, `kafka_load`, `etl_pipeline`) успешны; +- стартовая история доведена до DM; - данные проходят до DM; - мониторинг и дашборды доступны. @@ -133,29 +121,22 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie Ожидаемая длительность: ~30-60 минут. -### B.1 Полная загрузка и полный ETL +### B.1 Полная стартовая история и ETL ```bash -# Чистый старт -make clean && 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}' +# История на 2 суток с видимой суточной волной +PROFILE=daily-wave make generated-history-analytics +make up ``` ### B.2 Проверка объемов и DQ ```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 " @@ -185,6 +166,7 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12 Критерии успеха full: - STG заполнен по всем 4 потокам; - ODS/DDS/DM заполнены; +- диапазон `event_ts` соответствует созданной стартовой истории; - `dm.dq_summary` не пуста и содержит метрики всех слоев. ### B.3 Тест восстановления stop/start diff --git a/docs/adr/0004-steady-stream-synthetic-generator.md b/docs/adr/0004-steady-stream-synthetic-generator.md new file mode 100644 index 0000000..ca0ccbf --- /dev/null +++ b/docs/adr/0004-steady-stream-synthetic-generator.md @@ -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-спеку** и здесь + намеренно не фиксируется. diff --git a/docs/adr/0005-generator-model-clock.md b/docs/adr/0005-generator-model-clock.md new file mode 100644 index 0000000..c945261 --- /dev/null +++ b/docs/adr/0005-generator-model-clock.md @@ -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); + продолжение-из-сида — отдельная будущая задача после готовности генератора. +- Воспроизводимость улучшается при фиксированной стартовой модельной точке; + реконсиляция разделов «Персистентность» и «Воспроизводимость» мат-спеки — + отдельный заход после ревью эксперимента с автономной петлёй. diff --git a/docs/adr/0006-generation-as-sole-analytics-source.md b/docs/adr/0006-generation-as-sole-analytics-source.md new file mode 100644 index 0000000..374e935 --- /dev/null +++ b/docs/adr/0006-generation-as-sole-analytics-source.md @@ -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`** — только после того, как генератор научится + своей фактуре (отдельная спека). +- **Учебная ценность растёт**: со своей фактурой проектируем интересные + распределения (перекос по странам, набор устройств, привязка кампаний, намеренный + брак) вместо случайного набора из чужого сида — но это уже шаг про фактуру, не + этот. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 7a1fa05..d100558 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -11,6 +11,8 @@ понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`) - Задачи реализации — `.scratch//issues/-.md`, нумерация с `01` - Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`) +- Завершённая задача — `Status: done`: локальный аналог «закрыть issue» в GitHub. + Это состояние жизненного цикла трекера, а не роль триажа (в `triage-labels.md` его нет) - Комментарии и история — в конец файла под заголовком `## Comments` ## Когда скилл говорит «опубликовать в issue tracker» diff --git a/docs/course/LESSON_STANDARD.md b/docs/course/LESSON_STANDARD.md index 04f0971..1f46624 100644 --- a/docs/course/LESSON_STANDARD.md +++ b/docs/course/LESSON_STANDARD.md @@ -21,7 +21,7 @@ запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. **Верни как было** — каждая правка завершается явным шагом отката к чистому - состоянию (откатить изменение либо `make clean/up/ddl/data/transform`), чтобы + состоянию (откатить изменение либо `make generated-history-analytics && make up`), чтобы самостоятельный менти не застрял со сломанным стендом без ментора. (Урок 0 — без этого шага, только наблюдение.) 5. **Проверь себя** — самопроверка (раздел 4). @@ -112,7 +112,7 @@ - штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; - встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или нарушении целостности); -- `make clean/up/ddl/data/transform` для сброса и повтора. +- `make generated-history-analytics && make up` для чистого сброса и повтора штатного пути. В каждом уроке — маленькая табличка самопроверки в формате **действие → где смотреть → что ожидать**, а для управляемой правки — какой @@ -125,8 +125,8 @@ - **Проверяй на стенде, а не «на глаз».** Любой категоричный claim про числа или целостность подтверждай командой на стенде. Так нашёлся баг с `kafka_ts` в уроке 1 (значение типа `DateTime64` молча резалось через `toInt64`, и время по всему стенду уехало в `1970`) и - подтвердилось, что «26 из 50» в уроке 2 — это схлопывание повторов по `click_id` (проверено - `uniqExact`), а не потеря данных. + подтвердилось, что меньший размер таблиц контекста в уроке 2 — это схлопывание повторов + по `click_id` (проверяется через `uniqExact`), а не потеря данных. - **Имена таблиц и колонок в тексте сверяй с реальным DDL.** То, что написано в уроке, должно совпадать с `sql/ddl/...`, иначе менти запутается, когда выполнит запрос на стенде. - **Один паттерн на урок; будущие темы только анонсируй.** Не раскрывай то, что относится к diff --git a/docs/course/PRD.md b/docs/course/PRD.md index 81b5c9f..3ce0043 100644 --- a/docs/course/PRD.md +++ b/docs/course/PRD.md @@ -9,6 +9,13 @@ > Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не > «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор > разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5. +> Поправка 2026-07-04 (целеполагание): §1 расширен — зафиксировано место стенда в +> экосистеме менторской программы (теория и лабы по ClickHouse живут в других местах, +> здесь — интеграции «как в жизни») и иерархия целей вплоть до генератора. Демо-материалы +> (шпаргалки 5 и 10–15 минут) устарели и удалены: демо выросло в отдельный проект и целью +> стенда больше не является. Схема потока в §1 обновлена под генератор (источник данных +> сменился, см. ADR-0006). В §7 закрыта развилка про урок о генераторе и добавлена +> развилка про кластерную конфигурацию. > Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем > он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план > реализации и не стандарт уроков (см. раздел «Связанные документы»). @@ -23,8 +30,8 @@ Репозиторий — рабочий сквозной стенд кликстрим-DWH: ``` -data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset - ↘ Prometheus / Grafana (мониторинг) +generator (backfill/live) → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset + ↘ Prometheus / Grafana (мониторинг) ``` Стенд близок к продакшену и показывает несколько паттернов инженерии данных на @@ -32,6 +39,33 @@ data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ET программу обучения — это **продвинутый курс «со звёздочкой»** для менти, уже прошедших базу (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. Решение не принято. diff --git a/docs/course/README.md b/docs/course/README.md index bfaa579..5b0608e 100644 --- a/docs/course/README.md +++ b/docs/course/README.md @@ -25,16 +25,25 @@ слова с живым стендом. - **Железо:** стек тяжёлый — Kafka, ClickHouse, Airflow, Superset, Prometheus и Grafana поднимаются одновременно. Нужна машина, которая это потянет. -- **Подними стенд и залей малый срез** (из корня репозитория) — этого хватит, чтобы - начать, и прогон быстрый: +- **Инструменты:** `Docker` с `docker compose`, `make`, `bash`, `curl`, `git` и `uv`. + `uv` нужен для локальных Python-проверок и команд разработки. +- **Подними стенд и создай стартовую историю** (из корня репозитория) — этого хватит, + чтобы начать, и прогон быстрый: ```bash - make up # поднять контейнеры - make ddl # создать схему в ClickHouse (базы, таблицы, VIEW) - LIMIT=50 make data # залить по 50 строк на топик в Kafka + make generated-history-analytics + make up ``` - Дальше каждый урок в секции «Руки» сам напоминает, что перезапустить и с каким срезом. + Эта команда проводит штатный путь стенда: готовый источник данных создаёт стартовую + историю, события попадают в 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). ## Уроки @@ -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-генератор + или только стартовая история. + ## Границы Материалы под конкретного менти (привязка к работодателю, легенда, подготовка к diff --git a/docs/course/lessons/00_kafka_intro.md b/docs/course/lessons/00_kafka_intro.md index b24d436..53544f6 100644 --- a/docs/course/lessons/00_kafka_intro.md +++ b/docs/course/lessons/00_kafka_intro.md @@ -7,7 +7,7 @@ > Эталонный путь: сам Kafka UI на `http://localhost:8082`. > > Поток данных одной строкой: -> `make data → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` +> `готовый источник данных стенда → топики Kafka (партиции, offset'ы) → consumer-группа ClickHouse вычитывает` > > О чём урок простыми словами: ходим по Kafka UI и разглядываем поток — где лежат события, > кто их читает и как Kafka помнит, до какого места уже дочитано. @@ -22,11 +22,15 @@ consumer читает в своём темпе, не трогая producer'а. Если читатель отстал или прилёг — события не теряются, они лежат в топике и ждут. -На нашем стенде роли уже расставлены: `make data` играет роль трекера и заливает события -в топики, а ClickHouse — это consumer, который их вычитывает. В этом уроке мы не запускаем -пайплайн и ничего не меняем — мы открываем Kafka UI и **узнаём в живом кластере** те самые -понятия из видео. Это разминка: в уроке 1 ты уже руками увидишь, как эти же сообщения -доезжают до таблиц ClickHouse. +На нашем стенде роли уже расставлены: готовый источник данных стенда играет роль трекера +и пишет события в топики, а ClickHouse — это consumer, который их вычитывает. Для курса нам +важно не устройство источника, а сам путь данных: Kafka → STG → ODS → DDS → DM → Superset. +Файлы `data/*.jsonl` здесь не источник аналитики; пока это только кладовка готовых значений +для источника данных стенда. + +В этом уроке мы не запускаем пайплайн и ничего не меняем — мы открываем Kafka UI и +**узнаём в живом кластере** те самые понятия из видео. Это разминка: в уроке 1 ты уже руками +увидишь, как эти же сообщения доезжают до таблиц ClickHouse. > **В проде так же, только крупнее.** Здесь один брокер и крошечный срез данных. В бою > брокеров несколько, топик разбит на много партиций, читателей в группе — тоже несколько, @@ -37,8 +41,14 @@ consumer читает в своём темпе, не трогая producer'а. ## 2. Наблюдай: открой Kafka UI -Стенд уже должен быть поднят (`make up`) и в топиках должны лежать события -(`LIMIT=50 make data` из урока 1 — или любой прошлый прогон). Открой Kafka UI: +Стенд уже должен быть поднят, а в топиках должны лежать события после стартовой истории: + +```bash +make generated-history-analytics +make up +``` + +Открой Kafka UI: `http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя. Пройди по трём экранам и просто посмотри. @@ -50,24 +60,34 @@ consumer читает в своём темпе, не трогая producer'а. - `geo_events` — гео; - `location_events` — местоположение. -Рядом может быть служебный топик `__consumer_offsets` (если включён показ внутренних -топиков) — его Kafka использует сама, мы его не трогаем. У каждого нашего топика в колонке -с партициями стоит **1**: топик маленький, делить не на что. +Рядом могут быть служебные топики: -**Сообщения в топике.** Открой `browser_events` → вкладку *Messages*. Это и есть события, -которые залил `make data`. У каждого сообщения видно: +- `__consumer_offsets` — внутренний топик Kafka. В нём Kafka хранит прогресс + consumer-групп; +- `generator_state` — слепок состояния генератора на правой границе стартовой истории. + По нему live-продолжение понимает, откуда продолжать тот же мир; +- `generator_startup_history_manifest` — паспорт стартовой истории: seed, границы + модельного времени и контрольные числа; +- `generator_batch_history` — журнал батчей генератора: сколько сообщений он + отправил и чем закончилась каждая пачка записи. + +Служебные топики нужны стенду, но в упражнениях курса мы их не меняем. У каждого нашего +топика событий в колонке с партициями стоит **1**: топик маленький, делить не на что. + +**Сообщения в топике.** Открой `browser_events` → вкладку *Messages*. Это и есть события +стенда. У каждого сообщения видно: - **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …); - **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события); - **Value** — тело: 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"} ``` Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось, -здесь — 2022 год), и оно отличается от Kafka-Timestamp (когда оно попало в топик — при заливке стенда). +модельное время стенда), и оно отличается от Kafka-Timestamp (когда оно попало в топик). Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на стороне 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 события | | сравнить два времени | Value (`event_timestamp`) vs Kafka *Timestamp* | время события и время доставки в Kafka различаются | | открыть consumers | Kafka UI → *Consumers* | 4 группы `ch_stg_*`, статус `STABLE`, по 1 участнику | diff --git a/docs/course/lessons/01_kafka_to_clickhouse.md b/docs/course/lessons/01_kafka_to_clickhouse.md index 2c12e1a..e345d91 100644 --- a/docs/course/lessons/01_kafka_to_clickhouse.md +++ b/docs/course/lessons/01_kafka_to_clickhouse.md @@ -49,13 +49,14 @@ ## 2. Руки: убедись, что данные текут -Поднимаем стенд, создаём схему и заливаем **малый срез** (50 строк на топик — этого -хватает, чтобы всё увидеть, и прогон быстрый): +Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник +данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит +ODS, DDS и DM. Файлы `data/*.jsonl` в этом пути не источник аналитики; пока это только +кладовка значений для источника данных стенда. ```bash -make up # поднять инфраструктуру (Kafka, ClickHouse, ...) -make ddl # создать базы и таблицы в ClickHouse (в т.ч. слой STG) -LIMIT=50 make data # залить по 50 строк каждого файла в Kafka-топики +make generated-history-analytics +make up ``` Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль: @@ -188,19 +189,20 @@ SELECT FROM stg.kafka_browser_raw; ``` -**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим таблицу — иначе -рядом останутся строки из секции 2, вставленные ещё *до* `ADD COLUMN`, и в них -`kafka_msg_ts` будет пустой (`1970-01-01`): +**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим только таблицу +приёмника — иначе рядом останутся строки из секции 2, вставленные ещё *до* +`ADD COLUMN`, и в них `kafka_msg_ts` будет пустой (`1970-01-01`): ```sql TRUNCATE TABLE stg.browser_raw; ``` -Затем перезаливаем — с пересозданием топиков, чтобы Kafka-движок перечитал сообщения -с начала (без этого он считает их уже прочитанными и ничего нового не подхватит): +Затем повторно создаём стартовую историю **без очистки volumes**. Это важно: +`CLEAN_START=0` сохраняет твою новую колонку и пересозданное MV, но добавляет свежие +сообщения в Kafka, чтобы ClickHouse прочитал их уже с новой схемой. ```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 -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 целиком, неразобранный | | глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей | | правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения | diff --git a/docs/course/lessons/02_stg_to_ods.md b/docs/course/lessons/02_stg_to_ods.md index 8265017..6c75c39 100644 --- a/docs/course/lessons/02_stg_to_ods.md +++ b/docs/course/lessons/02_stg_to_ods.md @@ -84,27 +84,27 @@ ODS мы пересобираем целиком, одной задачей Airf ## 2. Руки: смотрим базовый прогон -Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем -трансформацию: +Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник +данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит +ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого +источника, а не источником аналитического контура. ```bash -make up # поднять инфраструктуру -make ddl # создать базы и таблицы (в т.ч. слой ODS) -LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG -make transform # батч STG → ODS → DDS → DM (нас интересует первый шаг) +make generated-history-analytics +make up ``` -`make transform` прогоняет всю цепочку слоёв сразу, но прямо в консоли печатает то, что нам -нужно сейчас, — блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам -слоя (четыре основных и четыре с ошибками): +Команда прогоняет всю цепочку слоёв и прямо в консоли печатает то, что нам нужно сейчас, — +блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам слоя (четыре +основных и четыре с ошибками). Пример формы вывода: ``` Статистика ODS: ┌─table──────────────────────┬─rows─┐ - │ ods.browser_event │ 50 │ - │ ods.location_event │ 50 │ - │ ods.device_by_click │ 26 │ - │ ods.geo_by_click │ 26 │ + │ ods.browser_event │ ... │ + │ ods.location_event │ ... │ + │ ods.device_by_click │ ... │ + │ ods.geo_by_click │ ... │ │ ods.browser_event_errors │ 0 │ │ ods.location_event_errors │ 0 │ │ ods.device_by_click_errors │ 0 │ @@ -114,35 +114,34 @@ make transform # батч STG → ODS → DDS → DM (нас инте Прочитаем эту табличку — в ней три вещи, которые стоит заметить. -**Все четыре `*_errors` — по нулям.** Значит, наш срез чистый: ни одна запись не дала ошибки -разбора, столбец `parse_errors` у всех пустой. Это нормально — данные в демо аккуратные. +**Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала +ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные. Ошибки мы увидим в секции 4, когда сами их устроим. -**`browser` и `location` дали 50 из 50.** Сколько событий пришло — столько и легло, один к -одному. +**`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк +и ожидаем увидеть после типизации, если ключи валидны. -**А `device` и `geo` — только 26 из 50.** Вот это уже интересно. Половина куда-то делась? Нет. -И это важно понять, иначе дальше будет казаться, что данные текут. +**А `device` и `geo` обычно меньше, чем событий.** Вот это уже интересно. Часть строк +куда-то делась? Нет. И это важно понять, иначе дальше будет казаться, что данные текут. Дело в том, что эти две таблицы хранят не события, а **контекст клика**: с какого устройства -был клик и из какой точки на карте. Ключ у них — `click_id`. А в срезе на 50 событий разных -кликов всего 26: на один клик приходится несколько событий, и `click_id` у них повторяется. -Движок таблицы (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке -на клик. Отсюда и 26. +был клик и из какой точки на карте. Ключ у них — `click_id`. Разных кликов меньше, чем событий: +на один клик приходится несколько событий, и `click_id` у них повторяется. Движок таблицы +(про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке на клик. Проверь это сам, а не верь на слово. Открой SQL-консоль `http://localhost:9123/play` -(пользователь `default`, пароль `123456`) и посчитай, сколько в срезе *различных* `click_id`: +(пользователь `default`, пароль `123456`) и посчитай, сколько в STG *различных* `click_id`: ```sql --- Всего строк в STG — 50, но различных click_id среди них — ровно 26 +-- Строк событий больше, чем различных click_id SELECT count() AS stg_rows, uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks FROM stg.geo_raw; ``` -Получишь `stg_rows = 50`, `distinct_clicks = 26` — ровно столько, сколько строк в -`ods.geo_by_click`. Значит, 26 — это схлопнутые повторы, а не пропавшие данные. Ничего не -потерялось молча. +`distinct_clicks` должен быть меньше или равен `stg_rows` и совпадать с числом строк в +`ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие данные. +Ничего не потерялось молча. --- @@ -174,7 +173,7 @@ toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS Весь смысл — в суффиксе `OrNull`. Если значение **не** приводится к нужному типу (вместо UUID пришёл мусор), функция не падает с ошибкой, а просто возвращает `NULL`. Это ровно то правило стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов. -Один кривой `event_id` станет `NULL` и будет помечен, а остальные 49 строк спокойно доедут. +Один кривой `event_id` станет `NULL` и будет помечен, а остальные строки спокойно доедут. > Кстати, про `AS`: эти строки живут в блоке `WITH` в начале запроса. `WITH` — это просто > способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него @@ -230,7 +229,7 @@ arrayFilter(x -> x != '', [ > если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим > воспользуемся — и увидим, чем грозит такой рассинхрон. -### Движок: откуда взялись 26 строк +### Движок: почему строк контекста меньше И последнее место — строчка про движок основных таблиц: @@ -241,8 +240,8 @@ ORDER BY (click_id) `ReplacingMergeTree` — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся из `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.geo_by_click │ 26 │ - │ ods.geo_by_click_errors │ 50 │ ← было 0 + │ ods.geo_by_click │ ... │ + │ ods.geo_by_click_errors │ ... │ ← было 0 ``` А в самой основной таблице широта пропала — но не молча, рядом стоит метка: @@ -295,11 +294,10 @@ LIMIT 4; └──────────────┴──────────────┴───────────────┴──────────────────────┘ ``` -Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. 26 строк -остались в основной таблице (ключ `click_id` цел) с пометкой `bad_geo_latitude`. И те же -записи попали в число 50 строк `geo_by_click_errors`. Долгота на месте, а широты больше нет: -один неверный тип — и целое поле потеряно по всему слою. Заметили это `parse_errors` и таблица -ошибок — для того DQ-split и нужен. +Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным +`click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в +`geo_by_click_errors`. Долгота на месте, а широты больше нет: один неверный тип — и целое поле +потеряно по всему слою. Заметили это `parse_errors` и таблица ошибок — для того DQ-split и нужен. > **Бывает и хуже — тихо, совсем без метки.** Здесь нас спас суффикс `OrNull`: неверный тип > дал `NULL`, а `NULL` мы умеем замечать (на него и сработал `parse_errors`). По-настоящему @@ -319,7 +317,7 @@ make transform ``` После этого `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 | -| почему 26, а не 50 | запрос `uniqExact(click_id)` по `stg.geo_raw` | 26 различных `click_id` — это схлопывание повторов, а не потеря | -| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул `0 → 50` | +| базовый прогон | блок «Статистика ODS» | основные таблицы не пустые, все `*_errors` = 0 | +| почему `device`/`geo` меньше событий | запрос `uniqExact(click_id)` по `stg.geo_raw` | число различных `click_id` совпадает с `ods.geo_by_click` | +| правка из секции 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` | --- @@ -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` рядом. И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md index 86ff12b..495c10e 100644 --- a/docs/course/lessons/03_ods_to_dds.md +++ b/docs/course/lessons/03_ods_to_dds.md @@ -65,35 +65,35 @@ ## 2. Руки: смотрим базовый прогон -Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем -трансформацию: +Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник +данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит +ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого +источника, а не источником аналитического контура. ```bash -make up # поднять инфраструктуру -make ddl # создать базы и таблицы (в т.ч. слой DDS) -LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG -make transform # батч STG → ODS → DDS → DM +make generated-history-analytics +make up ``` -`make transform` прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика -DDS»** — счётчики строк по двум нашим сущностям: +Команда прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика DDS»** — +счётчики строк по двум нашим сущностям: ``` Статистика DDS: ┌─table─────┬─rows─┐ - │ dds.click │ 26 │ - │ dds.event │ 50 │ + │ dds.click │ ... │ + │ dds.event │ ... │ └───────────┴──────┘ ``` Прочитаем эти две строки. -**`dds.event` — 50.** Сколько событий пришло, столько карточек и собралось: одно событие — одна -строка. Ровно как `ods.browser_event` из прошлого урока. +**`dds.event` — события.** Сколько событий пришло с валидным ключом, столько карточек и +собралось: одно событие — одна строка. Ровно как `ods.browser_event` из прошлого урока. -**`dds.click` — 26, а не 50.** И это та же история, что мы уже разбирали в уроке 2. Карточка -клика — одна на клик, а в срезе на 50 событий разных кликов всего 26 (на один клик приходится -несколько событий). Поэтому 50 событий ссылаются на 26 кликов — это нормально, так и должно быть. +**`dds.click` обычно меньше, чем `dds.event`.** И это та же история, что мы уже разбирали +в уроке 2. Карточка клика — одна на клик, а событий на один клик может быть несколько. +Поэтому много событий ссылаются на меньшее число кликов — это нормально, так и должно быть. Теперь — главный счётчик урока. Он печатается чуть ниже, в блоке **«Сводка по качеству данных»** (это таблица `dm.dq_summary`, куда стенд складывает метрики по всем слоям). Найди в ней строку @@ -108,9 +108,9 @@ DDS»** — счётчики строк по двум нашим сущност В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя дата — не пугайся, если она не совпадёт с примером. -`orphan_events = 0` — ни одной сироты. Каждое из 50 событий нашло свой клик в `dds.click`. На -чистом демо-срезе так и должно быть: данные аккуратные, ничего не потерялось. В секции 4 мы -сироту устроим сами — и эта строка оживёт. +`orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На +чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции +4 мы сироту устроим сами — и эта строка оживёт. Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play` (пользователь `default`, пароль `123456`) и посчитай сирот напрямую: @@ -158,9 +158,9 @@ SELECT click_id FROM ods.geo_by_click ... строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал в `device`. -> На нашем срезе `device` и `geo` содержат один и тот же набор из 26 кликов, так что универсум -> тоже 26. Но код написан так, чтобы пережить случай, когда наборы **разойдутся**, — и это -> правильно: в проде они расходятся постоянно. +> На чистой стартовой истории `device` и `geo` должны содержать один и тот же набор кликов, +> так что универсум совпадает с обоими источниками. Но код написан так, чтобы пережить случай, +> когда наборы **разойдутся**, — и это правильно: в проде они расходятся постоянно. ### `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: **а на каждую ли ссылку есть карточка клика?** @@ -237,16 +237,16 @@ WHERE click_id IS NOT NULL Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события** нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по -пустым полям внутри карточки, вторую — отдельным счётчиком. На чистом срезе сирот ноль — сейчас -мы это изменим. +пустым полям внутри карточки, вторую — отдельным счётчиком. На чистой стартовой истории сирот +ноль — сейчас мы это изменим. --- ## 4. Управляемая правка: заведём сироту -Сирота на чистом срезе не появится сама — данные слишком аккуратные. Поэтому **создадим её -руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого в `dds.click` -нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`. +Сирота на чистой стартовой истории не появится сама — данные слишком аккуратные. Поэтому +**создадим её руками**: добавим в `dds.event` одно событие, которое ссылается на клик, которого +в `dds.click` нет. И посмотрим, как оживёт счётчик сирот и как себя поведёт `LEFT JOIN`. Открой SQL-консоль `http://localhost:9123/play` и вставь придуманное событие: @@ -309,7 +309,7 @@ make transform ``` После этого `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` | -| почему `click` = 26, а `event` = 50 | запрос `count()` по `dds.click` и `dds.event` | 50 событий ссылаются на 26 кликов — норма | +| почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма | | правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` | | та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` | diff --git a/docs/course/lessons/04_airflow_orchestration.md b/docs/course/lessons/04_airflow_orchestration.md index 2b3ec5c..a62d3e8 100644 --- a/docs/course/lessons/04_airflow_orchestration.md +++ b/docs/course/lessons/04_airflow_orchestration.md @@ -56,12 +56,14 @@ Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Gra ## 2. Руки: запускаем DAG и смотрим зелёный прогон -Подними стенд, создай схему и залей малый срез: +Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник +данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит +ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого +источника, а не источником аналитического контура. ```bash +make generated-history-analytics make up -make ddl -LIMIT=50 make data ``` Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG @@ -75,13 +77,13 @@ LIMIT=50 make data заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый, старые эксперименты не мешают. -Когда DAG завершится, открой его граф. На чистом срезе все задачи должны быть зелёными. Найди -внутри группы `transform` две задачи подряд: +Когда DAG завершится, открой его граф. На чистой стартовой истории все задачи должны быть +зелёными. Найди внутри группы `transform` две задачи подряд: - `check_dds_integrity` — SQL-задача, которая считает сирот; - `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше. -На чистом срезе `assert_dds_integrity` зелёная: сирот нет, пайплайн прошёл в DM. +На чистой стартовой истории `assert_dds_integrity` зелёная: сирот нет, пайплайн прошёл в DM. Проверь то же число в 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); ``` -Снова должно быть `0`. Если стенд после экспериментов совсем запутался, полный сброс остаётся -тем же: +Снова должно быть `0`. Если стенд после экспериментов совсем запутался, сделай штатный +чистый прогон: ```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}`. --- diff --git a/docs/course/lessons/05_monitoring.md b/docs/course/lessons/05_monitoring.md index db96b03..0555a1c 100644 --- a/docs/course/lessons/05_monitoring.md +++ b/docs/course/lessons/05_monitoring.md @@ -57,15 +57,20 @@ ## 2. Руки: открываем дашборды и targets -Подними стенд и прогони маленький срез, если он ещё не поднят: +Подними стенд и создай стартовую историю, если он ещё не поднят. Это штатный путь курса: +готовый источник данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем +batch строит ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений +для этого источника, а не источником аналитического контура. ```bash +make generated-history-analytics make up -make ddl -LIMIT=50 make data ``` -После этого запусти `etl_pipeline` в Airflow с конфигом: +Учти: путь `generated-history-analytics` собирает витрины напрямую, без запуска +`etl_pipeline`, поэтому панели про задачи Airflow в `Airflow Overview` останутся +пустыми, пока ты хотя бы раз не запустишь `etl_pipeline` сам (это делалось в +уроке 4). Запустить его можно в Airflow с конфигом: ```json {"full_refresh": true} @@ -78,16 +83,19 @@ LIMIT=50 make data Открой Prometheus: `http://localhost:9090`. В меню зайди в **Status → Targets**. -Ожидаем три job: +Ожидаем основные job: | Job | Target внутри Docker | Что это значит | |-----|----------------------|----------------| | `clickhouse` | `clickhouse:9126` | ClickHouse отдаёт встроенный Prometheus endpoint | | `kafka` | `kafka-exporter:9308` | `kafka-exporter` подключился к Kafka и отдаёт метрики | | `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus | +| `generator` | `generator:9109` | live-генератор отдаёт свои метрики, только когда явно запущен | -У всех трёх состояние должно быть `UP`. Если один target `DOWN`, Grafana дальше будет показывать -`No data` или старые значения. +У `clickhouse`, `kafka` и `airflow` состояние должно быть `UP`. `generator` на штатном +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` или `Airflow Overview`, если меню выглядит иначе. -В **Dashboards** должны быть три дашборда: +В **Dashboards** должны быть четыре дашборда: - `ClickHouse 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-ы по партициям. **Consumer lag** — это разница между тем, что уже лежит в топике, и тем, что consumer group -успела прочитать. В нашем стенде lag обычно быстро возвращается к нулю: данных мало, ClickHouse -читает быстро. Если lag растёт и не снижается, downstream не успевает за Kafka. +успела прочитать. В нашем стенде ClickHouse обычно читает быстро, поэтому большой устойчивый +lag не ожидается. + +Есть важная оговорка из урока 0: ClickHouse-движок не всегда показывает свой прогресс как +обычная Kafka-группа. Поэтому в Kafka UI колонки offset/lag у `ch_stg_*` могут быть пустыми, +а в Grafana панель lag может быть пустой или нулевой. Это не конфликт между уроками. Для +точной проверки чтения со стороны ClickHouse смотри `system.kafka_consumers`, а Grafana здесь +используй как общий сигнал: появился ли большой lag, который не уходит. ### Airflow Overview @@ -184,7 +200,7 @@ URL: `http://localhost:3000/d/airflow-overview/airflow-overview` ### `scrape_configs`: кого опрашивает Prometheus -В файле три блока: +В файле четыре блока: ```yaml scrape_configs: @@ -199,10 +215,12 @@ scrape_configs: `localhost`: Prometheus живёт внутри compose-сети и ходит к соседним контейнерам по их service name. -Kafka и Airflow устроены так же, но с exporter-ами: +Kafka, Airflow и generator устроены так же, но источники разные: - `kafka` → `kafka-exporter:9308`; -- `airflow` → `statsd-exporter:9102`. +- `airflow` → `statsd-exporter:9102`; +- `generator` → `generator:9109`, только когда live-генератор явно запущен. + Без live этот target может быть `DOWN`, как в проверке Prometheus выше. ### Почему Airflow идёт через StatsD @@ -254,9 +272,21 @@ Grafana. | Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks | | 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` не пустые | | открыть `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` | diff --git a/docs/course/lessons/06_superset_bi.md b/docs/course/lessons/06_superset_bi.md index df5b0ba..1addf96 100644 --- a/docs/course/lessons/06_superset_bi.md +++ b/docs/course/lessons/06_superset_bi.md @@ -63,42 +63,28 @@ DM-витрина — это SQL-объект в ClickHouse. Она задаёт ## 2. Руки: запускаем Superset и смотрим дашборд -Подними стенд и прогони полный демо-датасет: +Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник +данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит +ODS, DDS, DM и обновляет Superset. Файлы `data/*.jsonl` пока остаются только кладовкой +значений для этого источника, а не источником аналитического контура. ```bash +make generated-history-analytics make up -make ddl -make data -make transform ``` -`make transform` прогоняет цепочку STG → ODS → DDS → DM вне Airflow. Для этого урока так -быстрее: нам нужен готовый DM-слой, а не разбор DAG. Отладочный срез через -`LIMIT=50 make data` можно использовать для быстрых экспериментов, но эталонный dashboard -и числа урока рассчитаны на полном наборе данных. - -Теперь инициализируй Superset: - -```bash -make superset-init -``` - -Эта команда создаёт или обновляет: +Команда уже прогоняет цепочку STG → ODS → DDS → DM и создаёт metadata Superset: - подключение `clickhouse_dwh`; - 6 datasets поверх `dm.*`. - -После этого создай или обнови charts и сам dashboard: - -```bash -make superset-dashboard -``` - -Эта команда создаёт или обновляет: - - 10 charts; - 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 предлагает сменить пароль после первого входа, для учебного стенда можно нажать @@ -119,7 +105,7 @@ http://localhost:8088/superset/dashboard/ecommerce-analytics/ - KPI сверху: `Total Events`, `Unique Users`, `Avg Events/Visit`, `Conversion to /confirmation`; - динамика: `Events over Time`, `Traffic by Device`; -- география: `Geography Map`; +- география: `Top Countries by Events`; - маркетинг: `UTM Effectiveness Table`, `Page Funnel`; - прохождение строк по слоям: `Rows by Layer (event)`. @@ -133,33 +119,33 @@ http://localhost:8088/superset/dashboard/ecommerce-analytics/ > **Зерно (grain), он же уровень гранулярности — это что считается одной строкой > таблицы.** У события зерно > «одно событие = одна строка» (ключ `event_id`), у визита — «один визит = одна -> строка» (ключ `click_id`). Это разные зёрна: событий 1000, а визитов 99, потому -> что в одном визите много событий. Складывать строки разного зерна в одно число -> бессмысленно — это всё равно что сложить «штуки яблок» и «корзины яблок». +> строка» (ключ `click_id`). Это разные зёрна: событий обычно больше, чем визитов, +> потому что в одном визите много событий. Складывать строки разного зерна в одно +> число бессмысленно — это всё равно что сложить «штуки яблок» и «корзины яблок». Поэтому чарт держит **одно зерно — event**: берёт по одной канонической таблице событий на слой (`browser_raw → browser_event → event → v_events_enriched`), а не сумму по слою. Если просуммировать все таблицы слоя, в один столбец попадут таблицы разного -зерна (события 1000 + визиты 99 + пустые error-таблицы) и получится «воронка потерь», -которой на самом деле нет. +зерна: события, визиты и технические таблицы ошибок. Получится «воронка потерь», которой +на самом деле нет. -Шаг **1050 → 1000** на первом переходе — это не потеря данных, а дедупликация -at-least-once потока по `event_id` в ODS (`ReplacingMergeTree`): в STG приехало 1050 строк, -но уникальных `event_id` среди них — 1000 (часть событий Kafka доставила повторно). Дальше -число стабильно. Настоящие проблемы качества (ошибки парсинга, осиротевшие события) на -чистых демо-данных равны нулю и лежат в `dm.dq_summary` отдельными `check_name` — их -разбирали уроки 3–4. +Если на первом переходе число уменьшается, это не обязательно потеря данных. В ODS работает +дедупликация at-least-once потока по `event_id` (`ReplacingMergeTree`): в STG могут приехать +повторы, а дальше остаётся каноническое число уникальных событий. Настоящие проблемы качества +(ошибки парсинга, осиротевшие события) на чистой стартовой истории равны нулю и лежат в +`dm.dq_summary` отдельными `check_name` — их разбирали уроки 3–4. ### Фильтр даты -В демо-данных события датированы `2022-11-28`. В текущей конфигурации dashboard фильтр даты -открывается как `No filter`. Если у тебя осталась старая metadata Superset и native filter -**Date Range** стоит в значении `Last week`, часть графиков может быть пустой, хотя данные есть. +В стартовой истории события идут по модельному времени стенда. В текущей конфигурации +dashboard фильтр даты открывается как `No filter`. Если у тебя осталась старая metadata +Superset и native filter **Date Range** стоит в значении `Last week`, часть графиков может +быть пустой, хотя данные есть. Для этого урока поставь в фильтре даты одно из двух: - `No filter`; -- или ручной диапазон вокруг `2022-11-28`. +- или ручной диапазон вокруг дат, которые видны в `event_timestamp`. После этого нажми **Apply filters**. Теперь смотри на dashboard как аналитик: какие графики отвечают на бизнес-вопросы, а какие только показывают техническое устройство конвейера. @@ -324,7 +310,7 @@ Native filters создаются в `build_dashboard_metadata`. Там есть - `Browser` по `browser_name`. Эти фильтры задаются в левой панели dashboard. Click-to-filter между виджетами не включен: -клик по сектору pie chart, карте, строке таблицы или funnel не меняет остальные charts. +клик по сектору pie chart, столбцу Top Countries, строке таблицы или funnel не меняет остальные charts. Фильтр применяется только к charts, где есть нужное поле. `Country`, `Device Type` и `Browser` работают с charts на `dm.v_events_enriched`; агрегированные витрины для UTM, @@ -415,7 +401,7 @@ metadata Superset. | `make superset-init` | Superset → **Settings → Database Connections** | есть подключение `clickhouse_dwh` | | открыть **Datasets** | Superset UI | есть datasets `v_events_enriched`, `v_top_pages_daily`, `dq_summary` | | открыть 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` на `20` и запустить `make superset-dashboard` | chart `Page Funnel` | ограничение снова до 20 страниц | diff --git a/docs/research/2026-06-11-subagent-coordinator-experiment.md b/docs/research/2026-06-11-subagent-coordinator-experiment.md new file mode 100644 index 0000000..0954b0e --- /dev/null +++ b/docs/research/2026-06-11-subagent-coordinator-experiment.md @@ -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` diff --git a/docs/runbooks/startup-history.md b/docs/runbooks/startup-history.md new file mode 100644 index 0000000..81cc5c3 --- /dev/null +++ b/docs/runbooks/startup-history.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`. diff --git a/docs/specs/2026-06-06-superset-dashboard-redesign.md b/docs/specs/2026-06-06-superset-dashboard-redesign.md index ce13449..31ef9ef 100644 --- a/docs/specs/2026-06-06-superset-dashboard-redesign.md +++ b/docs/specs/2026-06-06-superset-dashboard-redesign.md @@ -32,8 +32,9 @@ ## Non-goals -- Не модернизируем **типы** виджетов (`pie`/`world_map`/`dist_bar` → ECharts) — - отдельный косметический заход. +- Не модернизируем **типы** виджетов (`pie`/геовизуализация/`dist_bar` → ECharts) — + отдельный косметический заход. Геоблок позже заменён задачей 10 + `generator-model-time-startup-history`. - Не трогаем эмодзи в тайтлах, секции-заголовки, языковой винегрет. - Не трогаем генератор. - Не правим код `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` | | Top Pages | dist_bar | **апгрейд в Funnel** (центральный учебный объект) | | UTM Effectiveness | table | оставить; **выкинуть колонки purchases/add_to_cart** (всегда 0) | -| Geography Map | world_map | оставить (40 стран; тип модернизировать отдельно) | +| Geography Map | прежняя геовизуализация | позже заменена на `Top Countries by Events` | | Traffic by Device | pie | оставить | | Events by Hour | line | **проверить на пустоту**, иначе дропнуть | | Data Quality Summary | dist_bar | оставить (⚠️ позже пересмотрено — см. ниже) | diff --git a/docs/specs/2026-06-09-generator-rework-hierarchical.md b/docs/specs/2026-06-09-generator-rework-hierarchical.md new file mode 100644 index 0000000..629325b --- /dev/null +++ b/docs/specs/2026-06-09-generator-rework-hierarchical.md @@ -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`. diff --git a/docs/specs/2026-06-10-generator-math-model.md b/docs/specs/2026-06-10-generator-math-model.md new file mode 100644 index 0000000..1dec1b8 --- /dev/null +++ b/docs/specs/2026-06-10-generator-math-model.md @@ -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` — обновляется при реализации; туда же — верхнеуровневый + обзор «как работает генератор» (два контура: интенсивность и сущности). diff --git a/docs/specs/2026-06-11-generator-service-cleanup.md b/docs/specs/2026-06-11-generator-service-cleanup.md new file mode 100644 index 0000000..6e917a1 --- /dev/null +++ b/docs/specs/2026-06-11-generator-service-cleanup.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 и в задаче реализации. diff --git a/docs/specs/2026-06-14-generator-model-time-and-startup-history.md b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md new file mode 100644 index 0000000..9954e44 --- /dev/null +++ b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md @@ -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. diff --git a/generator/Dockerfile b/generator/Dockerfile new file mode 100644 index 0000000..3e85845 --- /dev/null +++ b/generator/Dockerfile @@ -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"] diff --git a/generator/KNOWN_ISSUES.md b/generator/KNOWN_ISSUES.md new file mode 100644 index 0000000..3ee5751 --- /dev/null +++ b/generator/KNOWN_ISSUES.md @@ -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 курса). diff --git a/generator/README.md b/generator/README.md new file mode 100644 index 0000000..389ab04 --- /dev/null +++ b/generator/README.md @@ -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 +``` diff --git a/generator/generator.py b/generator/generator.py new file mode 100644 index 0000000..2952f7c --- /dev/null +++ b/generator/generator.py @@ -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() diff --git a/generator/requirements.txt b/generator/requirements.txt new file mode 100644 index 0000000..badfad0 --- /dev/null +++ b/generator/requirements.txt @@ -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 diff --git a/generator/src/clickstream_generator/__init__.py b/generator/src/clickstream_generator/__init__.py new file mode 100644 index 0000000..7015c64 --- /dev/null +++ b/generator/src/clickstream_generator/__init__.py @@ -0,0 +1,2 @@ +"""Пакет генератора кликстрима.""" + diff --git a/generator/src/clickstream_generator/airflow_control.py b/generator/src/clickstream_generator/airflow_control.py new file mode 100644 index 0000000..eacc21a --- /dev/null +++ b/generator/src/clickstream_generator/airflow_control.py @@ -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 diff --git a/generator/src/clickstream_generator/config.py b/generator/src/clickstream_generator/config.py new file mode 100644 index 0000000..e870370 --- /dev/null +++ b/generator/src/clickstream_generator/config.py @@ -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) diff --git a/generator/src/clickstream_generator/dictionary.py b/generator/src/clickstream_generator/dictionary.py new file mode 100644 index 0000000..d8d27d1 --- /dev/null +++ b/generator/src/clickstream_generator/dictionary.py @@ -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, + ) diff --git a/generator/src/clickstream_generator/generation.py b/generator/src/clickstream_generator/generation.py new file mode 100644 index 0000000..9f63f51 --- /dev/null +++ b/generator/src/clickstream_generator/generation.py @@ -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 diff --git a/generator/src/clickstream_generator/intensity.py b/generator/src/clickstream_generator/intensity.py new file mode 100644 index 0000000..e718ad0 --- /dev/null +++ b/generator/src/clickstream_generator/intensity.py @@ -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)) diff --git a/generator/src/clickstream_generator/kafka_io.py b/generator/src/clickstream_generator/kafka_io.py new file mode 100644 index 0000000..de77cb6 --- /dev/null +++ b/generator/src/clickstream_generator/kafka_io.py @@ -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}") diff --git a/generator/src/clickstream_generator/launch.py b/generator/src/clickstream_generator/launch.py new file mode 100644 index 0000000..7659a9a --- /dev/null +++ b/generator/src/clickstream_generator/launch.py @@ -0,0 +1,185 @@ +"""Глаголы и профили запуска генератора.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + + +LAUNCH_ENV_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", +) + + +@dataclass(frozen=True) +class LaunchProfile: + """Именованный набор настроек запуска.""" + + duration: str + env: dict[str, str] + + +PROFILES = { + "ci": LaunchProfile( + duration="6h", + env={ + "GEN_SEED": "4242", + "GEN_MODEL_T0": "2026-01-01T00: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", + }, + ), + "daily-wave": LaunchProfile( + duration="2d", + env={ + "GEN_SEED": "4242", + "GEN_MODEL_T0": "2026-01-01T00:00:00+00:00", + "GEN_MODEL_TIMEZONE": "UTC", + "GEN_MODEL_TIME_SPEED": "60", + "GEN_TICK_SECONDS": "1", + "GEN_LAMBDA_BASE_PER_MIN": "60", + "GEN_JITTER_PCT": "0", + "GEN_MIN_EVENTS_PER_TICK": "1", + "GEN_MAX_EVENTS_PER_TICK": "1000", + }, + ), +} + + +def parse_duration(value: str) -> timedelta: + """Разбирает короткую длительность: 30m, 6h, 2d.""" + if len(value) < 2: + raise ValueError("Duration must look like 6h or 2d") + amount_text = value[:-1] + unit = value[-1] + if not amount_text.isdigit(): + raise ValueError("Duration amount must be a positive integer") + amount = int(amount_text) + if amount <= 0: + raise ValueError("Duration amount must be > 0") + if unit == "s": + return timedelta(seconds=amount) + if unit == "m": + return timedelta(minutes=amount) + if unit == "h": + return timedelta(hours=amount) + if unit == "d": + return timedelta(days=amount) + raise ValueError("Duration unit must be one of: s, m, h, d") + + +def build_launch_env( + verb: str, + *, + profile_name: str = "ci", + duration: str | None = None, + overrides: dict[str, str] | None = None, +) -> dict[str, str]: + """Возвращает старые env-переменные для нового глагола запуска.""" + if verb not in {"backfill", "continue", "next-day", "reset"}: + raise ValueError("глагол должен быть backfill, continue, next-day или reset") + if profile_name not in PROFILES: + known = ", ".join(sorted(PROFILES)) + raise ValueError(f"Unknown launch profile {profile_name!r}; known: {known}") + + profile = PROFILES[profile_name] + overrides = overrides or {} + env = dict(profile.env) + for key in LAUNCH_ENV_KEYS: + if key in overrides and overrides[key] != "": + env[key] = overrides[key] + + selected_duration = ( + duration + or overrides.get("GEN_HISTORY_DURATION") + or profile.duration + ) + env["GEN_LAUNCH_VERB"] = verb + env["GEN_LAUNCH_PROFILE"] = profile_name + + if verb == "backfill": + env["GEN_RUN_MODE"] = "backfill" + env["GEN_STATE_RESET"] = "true" + env["GEN_HISTORY_DURATION"] = selected_duration + env["GEN_MODEL_T_END"] = _model_t_end(env["GEN_MODEL_T0"], selected_duration) + elif verb == "continue": + env["GEN_RUN_MODE"] = "live" + env["GEN_STATE_RESET"] = "false" + env["GEN_HISTORY_DURATION"] = selected_duration + env["GEN_MODEL_T_END"] = _model_t_end(env["GEN_MODEL_T0"], selected_duration) + elif verb == "next-day": + current_t_end = overrides.get("GEN_MODEL_T_END") + if not current_t_end: + raise ValueError("для next-day требуется GEN_MODEL_T_END") + env["GEN_RUN_MODE"] = "next-day" + env["GEN_STATE_RESET"] = "false" + env["GEN_MODEL_T_END"] = current_t_end + else: + env["GEN_RUN_MODE"] = "live" + env["GEN_STATE_RESET"] = "true" + + return env + + +def shell_assignments(env: dict[str, str]) -> str: + """Печатает env в виде, пригодном для eval в Bash.""" + return "\n".join( + f"export {key}={shlex.quote(value)}" + for key, value in sorted(env.items()) + ) + + +def _model_t_end(model_t0: str, duration: str) -> str: + t0 = datetime.fromisoformat(model_t0.replace("Z", "+00:00")) + if t0.tzinfo is None: + raise ValueError("GEN_MODEL_T0 must include timezone") + return (t0.astimezone(timezone.utc) + parse_duration(duration)).isoformat() + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Глаголы запуска генератора") + parser.add_argument( + "verb", + choices=("backfill", "continue", "next-day", "reset"), + ) + parser.add_argument( + "--profile", + default=os.getenv("PROFILE") or os.getenv("GEN_LAUNCH_PROFILE") or "ci", + choices=sorted(PROFILES), + ) + parser.add_argument("--duration", default=os.getenv("GEN_HISTORY_DURATION")) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """CLI для Bash-скриптов запуска.""" + args = _parse_args(sys.argv[1:] if argv is None else argv) + env = build_launch_env( + args.verb, + profile_name=args.profile, + duration=args.duration, + overrides=os.environ, + ) + print(shell_assignments(env)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/generator/src/clickstream_generator/metrics.py b/generator/src/clickstream_generator/metrics.py new file mode 100644 index 0000000..cb9b6fb --- /dev/null +++ b/generator/src/clickstream_generator/metrics.py @@ -0,0 +1,23 @@ +"""Prometheus-метрики генератора.""" + +from prometheus_client import Counter, Gauge, Histogram + + +METRICS_EVENTS_TOTAL = Counter( + "generator_events_total", + "Total number of events sent to Kafka", + ["topic"], +) +METRICS_ERRORS_TOTAL = Counter( + "generator_publish_errors_total", + "Total number of publish errors", + ["topic"], +) +METRICS_TICK_DURATION = Histogram( + "generator_tick_duration_seconds", + "Duration of generator tick in seconds", +) +METRICS_LAST_SUCCESS = Gauge( + "generator_last_success_timestamp", + "Unix timestamp of last successful tick", +) diff --git a/generator/src/clickstream_generator/runtime.py b/generator/src/clickstream_generator/runtime.py new file mode 100644 index 0000000..9d2ce28 --- /dev/null +++ b/generator/src/clickstream_generator/runtime.py @@ -0,0 +1,522 @@ +"""Тиковый слой генератора с активными визитами между вызовами.""" + +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from weakref import WeakKeyDictionary + +from clickstream_generator.generation import EXPECTED_VISIT_EVENTS, EventGenerator +from clickstream_generator.state import GeneratorState + + +TOPICS = ("browser_events", "location_events", "device_events", "geo_events") +RESTART_VISIT_GRACE = timedelta(minutes=30) + + +@dataclass +class UserProfile: + """Постоянный профиль пользователя между визитами.""" + + user_domain_id: str + seed_click_id: str + device: dict + geo: dict + active_click_id: str | None = None + last_finished_at: datetime | None = None + + @property + def is_active(self) -> bool: + return self.active_click_id is not None + + +@dataclass +class ActiveVisit: + """Запланированный визит, который выпускается по тикам.""" + + batch: dict[str, list[dict]] + timestamps: list[datetime] + base_click_id: str + user: UserProfile | None = None + next_index: int = 0 + + @property + def is_finished(self) -> bool: + return self.next_index >= len(self.timestamps) + + +class UserPopulation: + """Ограниченная популяция пользователей для тикового потока.""" + + def __init__(self, generator: EventGenerator): + self.generator = generator + self.users: list[UserProfile] = [ + self._create_user() + for _ in range(generator.config.population_max) + ] + + def choose_for_visit(self, tick_time: datetime) -> UserProfile | None: + """Выбирает пользователя без активного визита и кулдауна.""" + available = [ + user for user in self.users + if self._is_available(user, tick_time) + ] + if not available: + return self._rotate_new_user() + if self.generator.rng.random() < self.generator.config.p_new_user: + return self._rotate_new_user() or self.generator.rng.choice(available) + return self.generator.rng.choice(available) + + def start_visit(self, user: UserProfile, click_id: str) -> None: + user.active_click_id = click_id + + def finish_visit(self, user: UserProfile | None, finished_at: datetime) -> None: + if user is None: + return + user.active_click_id = None + user.last_finished_at = finished_at + + def _create_user(self) -> UserProfile: + seed_click_id = self.generator.rng.choice(self._profile_seed_click_ids()) + device = { + **self.generator.dictionary.device_by_click_id[seed_click_id], + "user_domain_id": self.generator._new_uuid(), + } + geo = self.generator.dictionary.geo_by_click_id[seed_click_id] + return UserProfile( + user_domain_id=device["user_domain_id"], + seed_click_id=seed_click_id, + device=device, + geo=geo, + ) + + def _rotate_new_user(self) -> UserProfile | None: + inactive_users = [user for user in self.users if not user.is_active] + if not inactive_users: + return None + + new_user = self._create_user() + victim = min( + inactive_users, + key=lambda user: user.last_finished_at or datetime.min, + ) + self.users[self.users.index(victim)] = new_user + return new_user + + def _is_available(self, user: UserProfile, tick_time: datetime) -> bool: + if user.is_active: + return False + if user.last_finished_at is None: + return True + cooldown = timedelta(minutes=self.generator.config.min_return_minutes) + return tick_time - user.last_finished_at >= cooldown + + def _profile_seed_click_ids(self) -> list[str]: + return [ + click_id + for click_id in self.generator.dictionary.device_by_click_id + if click_id in self.generator.dictionary.geo_by_click_id + ] + + +def _empty_batch() -> dict[str, list[dict]]: + return {topic: [] for topic in TOPICS} + + +def _parse_timestamp(value: str) -> datetime: + return datetime.fromisoformat(value.replace(" ", "T")) + + +def _datetime_to_state(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _timestamp_to_state_offset(started_at: datetime, timestamp: datetime) -> int: + delta = timestamp - started_at + return ( + delta.days * 86_400_000_000 + + delta.seconds * 1_000_000 + + delta.microseconds + ) + + +def _format_event_timestamp(timestamp: datetime) -> str: + return timestamp.strftime("%Y-%m-%d %H:%M:%S.%f") + + +def _stable_event_id(click_id: str, event_index: int) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{click_id}:{event_index}")) + + +def _normalize_tick_time(tick_started_at: datetime | None) -> datetime: + tick_time = tick_started_at or datetime.now(timezone.utc) + if tick_time.tzinfo is not None: + return tick_time.astimezone(timezone.utc).replace(tzinfo=None) + return tick_time + + +class TickStreamGenerator: + """Раскладывает визиты по тикам и хранит активные визиты.""" + + def __init__(self, generator: EventGenerator): + self.generator = generator + self.active_visits: list[ActiveVisit] = [] + self.population = UserPopulation(generator) + self._pending_visit_births = 0.0 + + @property + def population_size(self) -> int: + return len(self.population.users) + + @property + def population_user_ids(self) -> set[str]: + return {user.user_domain_id for user in self.population.users} + + @property + def active_visit_count(self) -> int: + return len(self.active_visits) + + def to_state( + self, + tick: int, + rng_state: tuple, + last_batch_id: str, + last_timestamp: datetime, + model_timestamp: datetime | None = None, + wall_timestamp: datetime | None = None, + model_time_speed: float = 1.0, + model_timezone: str = "UTC", + model_t0: datetime | None = None, + gen_seed: int | None = None, + ) -> GeneratorState: + """Возвращает JSON-сериализуемый снимок тикового слоя.""" + return GeneratorState( + tick=tick, + rng_state=rng_state, + last_batch_id=last_batch_id, + last_timestamp=last_timestamp, + model_timestamp=model_timestamp, + wall_timestamp=wall_timestamp, + model_time_speed=model_time_speed, + model_timezone=model_timezone, + model_t0=model_t0, + gen_seed=gen_seed, + population=[ + { + "user_domain_id": user.user_domain_id, + "seed_click_id": user.seed_click_id, + "active_click_id": user.active_click_id, + "last_finished_at": _datetime_to_state(user.last_finished_at), + } + for user in self.population.users + ], + active_visits=[ + self._visit_to_state(visit) + for visit in self.active_visits + ], + pending_visit_births=self._pending_visit_births, + ) + + def _visit_to_state(self, visit: ActiveVisit) -> dict: + started_at = visit.timestamps[0] + browser_events = visit.batch["browser_events"] + location_events = visit.batch["location_events"] + return { + "user_domain_id": visit.user.user_domain_id if visit.user else None, + "click_id": browser_events[0]["click_id"], + "base_click_id": visit.base_click_id, + "next_index": visit.next_index, + "started_at": started_at.isoformat(), + "offsets_us": [ + _timestamp_to_state_offset(started_at, timestamp) + for timestamp in visit.timestamps + ], + "page_url_paths": [ + event["page_url_path"] + for event in location_events + ], + } + + def restore_state( + self, + state: GeneratorState, + resume_model_at: datetime | None = None, + restarted_at: datetime | None = None, + ) -> None: + """Восстанавливает популяцию и активные визиты из state.""" + users = [ + self._user_from_state(item) + for item in state.population + ] + users_by_id = {user.user_domain_id: user for user in users} + + self.population.users = users + self.active_visits = [] + model_resume_time = ( + _normalize_tick_time(resume_model_at or restarted_at) + if resume_model_at is not None or restarted_at is not None + else None + ) + for item in state.active_visits: + visit = self._visit_from_state(item, users_by_id) + if self._is_overdue_after_restart(visit, model_resume_time): + self.population.finish_visit( + visit.user, + self._last_released_at(visit, state.model_timestamp), + ) + continue + self.active_visits.append(visit) + self._pending_visit_births = state.pending_visit_births + + def _user_from_state(self, item: dict) -> UserProfile: + seed_click_id = item["seed_click_id"] + if seed_click_id not in self.generator.dictionary.device_by_click_id: + raise ValueError(f"Unknown user seed_click_id: {seed_click_id}") + if seed_click_id not in self.generator.dictionary.geo_by_click_id: + raise ValueError(f"Unknown user geo seed_click_id: {seed_click_id}") + + user_domain_id = item["user_domain_id"] + device = { + **self.generator.dictionary.device_by_click_id[seed_click_id], + "user_domain_id": user_domain_id, + } + geo = self.generator.dictionary.geo_by_click_id[seed_click_id] + return UserProfile( + user_domain_id=user_domain_id, + seed_click_id=seed_click_id, + device=device, + geo=geo, + active_click_id=item.get("active_click_id"), + last_finished_at=( + _parse_timestamp(item["last_finished_at"]) + if item.get("last_finished_at") + else None + ), + ) + + def _visit_from_state( + self, + item: dict, + users_by_id: dict[str, UserProfile], + ) -> ActiveVisit: + user = users_by_id.get(item["user_domain_id"]) + if user is None: + raise ValueError(f"Unknown active visit user: {item['user_domain_id']}") + + started_at = _parse_timestamp(item["started_at"]) + timestamps = [ + started_at + timedelta(microseconds=offset_us) + for offset_us in item["offsets_us"] + ] + batch = self._compact_visit_batch( + click_id=item["click_id"], + base_click_id=item["base_click_id"], + user=user, + timestamps=timestamps, + page_url_paths=item["page_url_paths"], + ) + return ActiveVisit( + batch=batch, + timestamps=timestamps, + base_click_id=item["base_click_id"], + user=user, + next_index=item["next_index"], + ) + + def _compact_visit_batch( + self, + click_id: str, + base_click_id: str, + user: UserProfile, + timestamps: list[datetime], + page_url_paths: list[str], + ) -> dict[str, list[dict]]: + batch = _empty_batch() + if base_click_id not in self.generator.dictionary.browser_by_click_id: + raise ValueError(f"Unknown fixture base_click_id: {base_click_id}") + browser_templates = self.generator.dictionary.browser_by_click_id[base_click_id] + if not browser_templates: + raise ValueError(f"Fixture base_click_id has no browser events: {base_click_id}") + + for event_index, (timestamp, page_url_path) in enumerate( + zip(timestamps, page_url_paths) + ): + browser_template = browser_templates[event_index % len(browser_templates)] + source_event_id = browser_template["event_id"] + if source_event_id not in self.generator.dictionary.location_by_event_id: + raise ValueError(f"Unknown fixture location event_id: {source_event_id}") + location_template = self.generator.dictionary.location_by_event_id[source_event_id] + event_id = _stable_event_id(click_id, event_index) + batch["browser_events"].append( + { + **browser_template, + "event_id": event_id, + "click_id": click_id, + "event_timestamp": _format_event_timestamp(timestamp), + } + ) + batch["location_events"].append( + { + **location_template, + "event_id": event_id, + "page_url": f"http://www.dummywebsite.com{page_url_path}", + "page_url_path": page_url_path, + } + ) + batch["device_events"].append({**user.device, "click_id": click_id}) + batch["geo_events"].append({**user.geo, "click_id": click_id}) + + return batch + + def _last_released_at( + self, + visit: ActiveVisit, + fallback: datetime, + ) -> datetime: + if visit.next_index > 0: + return visit.timestamps[visit.next_index - 1] + if fallback.tzinfo is not None: + return fallback.astimezone(timezone.utc).replace(tzinfo=None) + return fallback + + def _is_overdue_after_restart( + self, + visit: ActiveVisit, + restarted_time: datetime | None, + ) -> bool: + if restarted_time is None or visit.is_finished: + return False + next_timestamp = visit.timestamps[visit.next_index] + return restarted_time - next_timestamp > RESTART_VISIT_GRACE + + def generate_tick( + self, + event_budget: int, + tick_started_at: datetime | None = None, + ) -> dict[str, list[dict]]: + """Возвращает события, созревшие к текущему тику.""" + tick_time = _normalize_tick_time(tick_started_at) + tick_batch = _empty_batch() + + self._release_due_events(tick_time, tick_batch) + self._drop_finished_visits() + self._birth_visits(event_budget, tick_time) + self._release_due_events(tick_time, tick_batch) + self._drop_finished_visits() + + return tick_batch + + def drain_until( + self, + cutoff_at: datetime, + include_boundary: bool = False, + ) -> dict[str, list[dict]]: + """Выпускает созревшие события без рождения новых визитов.""" + cutoff_time = _normalize_tick_time(cutoff_at) + tick_batch = _empty_batch() + + self._release_due_events( + cutoff_time, + tick_batch, + include_boundary=include_boundary, + ) + self._drop_finished_visits() + + return tick_batch + + def _birth_visits(self, event_budget: int, tick_time: datetime) -> None: + if len(self.active_visits) >= self.generator.config.max_active_sessions: + self._pending_visit_births = 0.0 + return + + self._pending_visit_births += max(0, event_budget) / EXPECTED_VISIT_EVENTS + + while ( + self._pending_visit_births >= 1.0 + and len(self.active_visits) < self.generator.config.max_active_sessions + ): + user = self.population.choose_for_visit(tick_time) + if user is None: + self._pending_visit_births = 0.0 + return + + visit_batch, base_click_id = self.generator.generate_visit_batch( + self.generator.config.max_session_events, + planned_start_at=tick_time, + user_profile={"device": user.device, "geo": user.geo}, + ) + timestamps = [ + _parse_timestamp(event["event_timestamp"]) + for event in visit_batch["browser_events"] + ] + if not timestamps: + break + if base_click_id is None: + raise ValueError("Generated active visit is missing fixture base_click_id") + + click_id = visit_batch["browser_events"][0]["click_id"] + self.population.start_visit(user, click_id) + self.active_visits.append( + ActiveVisit( + batch=visit_batch, + timestamps=timestamps, + base_click_id=base_click_id, + user=user, + ) + ) + self._pending_visit_births -= 1.0 + + if len(self.active_visits) >= self.generator.config.max_active_sessions: + self._pending_visit_births = 0.0 + + def _release_due_events( + self, + tick_time: datetime, + tick_batch: dict[str, list[dict]], + include_boundary: bool = True, + ) -> None: + for visit in self.active_visits: + while not visit.is_finished: + next_timestamp = visit.timestamps[visit.next_index] + if include_boundary: + is_due = next_timestamp <= tick_time + else: + is_due = next_timestamp < tick_time + if not is_due: + break + event_index = visit.next_index + for topic in TOPICS: + tick_batch[topic].append(visit.batch[topic][event_index]) + visit.next_index += 1 + + def _drop_finished_visits(self) -> None: + for visit in self.active_visits: + if visit.is_finished: + self.population.finish_visit(visit.user, visit.timestamps[-1]) + + self.active_visits = [ + visit + for visit in self.active_visits + if not visit.is_finished + ] + + +_STREAMS: WeakKeyDictionary[EventGenerator, TickStreamGenerator] = WeakKeyDictionary() + + +def generate_tick_batch( + generator: EventGenerator, + event_budget: int, + tick_started_at: datetime | None = None, +) -> dict[str, list[dict]]: + """Совместимый фасад тикового генератора. + + `event_budget` здесь трактуется как бюджет событий за тик. Если потолок + активных визитов достигнут, новые рождения пропускаются и бюджет не + переносится на следующие тики. + """ + stream = _STREAMS.get(generator) + if stream is None: + stream = TickStreamGenerator(generator) + _STREAMS[generator] = stream + + return stream.generate_tick(event_budget, tick_started_at=tick_started_at) diff --git a/generator/src/clickstream_generator/service.py b/generator/src/clickstream_generator/service.py new file mode 100644 index 0000000..2755897 --- /dev/null +++ b/generator/src/clickstream_generator/service.py @@ -0,0 +1,824 @@ +"""Основной сервисный цикл генератора.""" + +import logging +import signal +import sys +import threading +import time +import uuid +from datetime import datetime, timedelta, timezone + +from prometheus_client import start_http_server + +from clickstream_generator.config import Config +from clickstream_generator.dictionary import EventDictionary +from clickstream_generator.generation import EventGenerator +from clickstream_generator.kafka_io import ( + BatchRecord, + KafkaBatchHistory, + KafkaDataTopicReader, + KafkaPublisher, + KafkaStateManager, + KafkaStartupHistoryManifest, + ensure_topics, +) +from clickstream_generator.metrics import ( + METRICS_ERRORS_TOTAL, + METRICS_LAST_SUCCESS, + METRICS_TICK_DURATION, +) +from clickstream_generator.runtime import TickStreamGenerator +from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + ManifestCounters, + build_manifest, + generation_settings_from_config, + manifest_boundaries, + write_startup_history_artifact, +) +from clickstream_generator.state import UnsupportedStateVersionError + + +logger = logging.getLogger("generator") + + +class IncompatibleStateError(ValueError): + """Читаемый state относится к другому миру генерации.""" + + +class GeneratorService: + """Основной сервис генератора.""" + + def __init__(self, config: Config): + self.config = config + self.dictionary = EventDictionary.load(config.data_dir) + self.generator = EventGenerator(self.dictionary, config) + self.stream = TickStreamGenerator(self.generator) + self.publisher: KafkaPublisher | None = None + self.history: KafkaBatchHistory | None = None + self.state_manager: KafkaStateManager | None = None + self.manifest_manager: KafkaStartupHistoryManifest | None = None + self.data_reader: KafkaDataTopicReader | None = None + self._running = False + self._stop_requested = False + self._shutdown_event = threading.Event() + self._tick = 0 + self._model_time = config.model_t0 + + def start(self): + """Запускает основной цикл.""" + if not self.config.enabled: + logger.warning("Generator is disabled (GEN_ENABLED=false)") + return + + if self.config.metrics_enabled: + logger.info(f"Starting metrics server on port {self.config.metrics_port}") + start_http_server(self.config.metrics_port) + else: + logger.info("Metrics HTTP server is disabled") + + logger.info("Starting generator service...") + logger.info( + f"Configuration: tick={self.config.tick_seconds}s, " + f"lambda_base={self.config.lambda_base_per_min}/min, " + f"jitter={self.config.jitter_pct}%, " + f"launch_profile={self.config.launch_profile}, " + f"state_enabled={self.config.state_enabled}, " + f"state_reset={self.config.state_reset}" + ) + + ensure_topics(self.config.kafka_bootstrap_servers) + self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers) + self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers) + + if self.config.run_mode == "backfill" and not self.config.state_enabled: + raise ValueError("GEN_STATE_ENABLED must be true for backfill") + if self.config.run_mode == "next-day" and not self.config.state_enabled: + raise ValueError("для next-day GEN_STATE_ENABLED должен быть true") + + if self.config.state_enabled: + self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers) + + if self.config.run_mode == "backfill": + self.manifest_manager = KafkaStartupHistoryManifest( + self.config.kafka_bootstrap_servers + ) + logger.info("Backfill mode starts from a fresh generator state") + self._run_backfill() + self.stop() + return + + if self.config.run_mode == "next-day": + self.manifest_manager = KafkaStartupHistoryManifest( + self.config.kafka_bootstrap_servers + ) + restored_state = self.state_manager.load() + manifest = self.manifest_manager.load() + if restored_state is None: + raise IncompatibleStateError( + "state генератора для next-day не найден" + ) + mismatches = self._startup_history_mismatch_fields( + restored_state, + manifest, + ) + if mismatches: + raise IncompatibleStateError( + "слепок next-day не согласован: " + ", ".join(mismatches) + ) + model_t_end = self._as_aware_utc( + datetime.fromisoformat(manifest["model_t_end"]) + ) + self.restore_from_startup_history( + restored_state, + model_t_end=model_t_end, + ) + self.data_reader = KafkaDataTopicReader( + self.config.kafka_bootstrap_servers + ) + self._run_next_day(manifest) + self.stop() + return + + if not self.config.state_reset: + try: + restored_state = self.state_manager.load() + except UnsupportedStateVersionError as exc: + logger.error( + "Readable generator state uses an old format: version %s. " + "Run make clean or set GEN_STATE_RESET=true only if you " + "intentionally start a new world.", + exc.found_version, + ) + raise IncompatibleStateError( + f"state version {exc.found_version} is not supported " + f"by this code version ({exc.expected_version}); " + "run make clean, or set GEN_STATE_RESET=true to start " + "a new world intentionally" + ) from exc + if restored_state: + try: + self.manifest_manager = KafkaStartupHistoryManifest( + self.config.kafka_bootstrap_servers + ) + manifest = self.manifest_manager.load() + if self._is_startup_history_state(restored_state, manifest): + model_t_end = self._as_aware_utc( + datetime.fromisoformat(manifest["model_t_end"]) + ) + self.restore_from_startup_history( + restored_state, + model_t_end=model_t_end, + ) + elif self._is_startup_history_marker(restored_state): + mismatches = self._startup_history_mismatch_fields( + restored_state, + manifest, + ) + raise IncompatibleStateError( + "startup-history state mismatch: " + + ", ".join(mismatches) + + "; set GEN_STATE_RESET=true to start a new " + "world intentionally" + ) + else: + self._restore_live_state( + restored_state, + wall_now_utc=datetime.now(timezone.utc), + ) + logger.info( + f"Restored state: continuing from tick {self._tick}, " + f"model_time={self._model_time.isoformat()}, " + f"last_batch_id={restored_state.last_batch_id}" + ) + except IncompatibleStateError: + logger.error( + "Readable generator state is incompatible with " + "current settings; set GEN_STATE_RESET=true to " + "start a new world intentionally." + ) + raise + except Exception as e: + logger.warning( + f"State data was invalid, starting fresh: {e}" + ) + self.generator = EventGenerator(self.dictionary, self.config) + self.stream = TickStreamGenerator(self.generator) + self._tick = 0 + else: + logger.info("State reset requested, starting fresh") + else: + logger.info("State management disabled") + + self._running = not self._stop_requested + + try: + self._main_loop() + except KeyboardInterrupt: + logger.info("Received shutdown signal") + finally: + self.stop() + + def stop(self): + """Останавливает сервис.""" + logger.info("Stopping generator service...") + self._running = False + self._shutdown_event.set() + if self.publisher: + self.publisher.close() + if self.history: + self.history.close() + if self.state_manager: + self.state_manager.close() + if self.manifest_manager: + self.manifest_manager.close() + + def request_stop(self, signum, _frame) -> None: + """Просит завершить сервис после текущего batch.""" + logger.info("Received shutdown signal %s", signum) + self._stop_requested = True + self._running = False + self._shutdown_event.set() + + def restore_from_startup_history( + self, + state, + model_t_end: datetime, + ) -> None: + """Восстанавливает слепок стартовой истории ровно от T_end.""" + self._restore_state_snapshot(state, resume_model_at=model_t_end) + + def _restore_live_state(self, state, wall_now_utc: datetime) -> None: + """Восстанавливает live-state с учётом прошедшего настенного времени.""" + self._validate_live_state_config(state) + resume_model_at = self._calculate_live_resume_model_at( + state, + wall_now_utc=wall_now_utc, + ) + self._restore_state_snapshot(state, resume_model_at=resume_model_at) + + def _restore_state_snapshot(self, state, resume_model_at: datetime) -> None: + """Применяет state к генератору и тиковому слою.""" + resume_model_at = self._as_aware_utc(resume_model_at) + self.generator.rng.setstate(state.rng_state) + self.stream.restore_state(state, resume_model_at=resume_model_at) + self._tick = state.tick + self._model_time = resume_model_at + + def _calculate_live_resume_model_at( + self, + state, + wall_now_utc: datetime, + ) -> datetime: + """Считает модельную точку live-восстановления по state.""" + wall_now_utc = self._as_aware_utc(wall_now_utc) + wall_saved_at = self._as_aware_utc(state.wall_timestamp) + idle_seconds = max(0.0, (wall_now_utc - wall_saved_at).total_seconds()) + return self._as_aware_utc(state.model_timestamp) + timedelta( + seconds=idle_seconds * state.model_time_speed, + ) + + def _validate_live_state_config(self, state) -> None: + """Проверяет, что state относится к текущей конфигурации live-запуска.""" + mismatches = [] + if state.gen_seed != self.config.seed: + mismatches.append("GEN_SEED") + if self._as_aware_utc(state.model_t0) != self.config.model_t0: + mismatches.append("GEN_MODEL_T0") + if state.model_timezone != self.config.model_timezone: + mismatches.append("GEN_MODEL_TIMEZONE") + if abs(state.model_time_speed - self.config.model_time_speed) > 1e-9: + mismatches.append("GEN_MODEL_TIME_SPEED") + + if mismatches: + raise IncompatibleStateError( + "state config mismatch: " + + ", ".join(mismatches) + + "; set GEN_STATE_RESET=true to start a new world intentionally" + ) + + def _is_startup_history_state(self, state, manifest: dict | None) -> bool: + """Проверяет, что state совпадает со слепком стартовой истории.""" + return not self._startup_history_mismatch_fields(state, manifest) + + def _startup_history_mismatch_fields(self, state, manifest: dict | None) -> list[str]: + """Возвращает поля, по которым startup-history state не совпал.""" + mismatches = [] + if not manifest or manifest.get("run_mode") != "backfill": + return ["generator_startup_history_manifest"] + + expected_state = manifest.get("state") or {} + if manifest.get("state_version") != state.version: + mismatches.append("state_version") + if expected_state.get("last_batch_id") != state.last_batch_id: + mismatches.append("state.last_batch_id") + + try: + model_t_end = self._as_aware_utc( + datetime.fromisoformat(manifest["model_t_end"]) + ) + model_t0 = self._as_aware_utc( + datetime.fromisoformat(manifest["model_t0"]) + ) + except (KeyError, TypeError, ValueError): + return ["manifest.model_t0", "manifest.model_t_end"] + + if self._as_aware_utc(state.model_timestamp) != model_t_end: + mismatches.append("GEN_MODEL_T_END") + if self._as_aware_utc(state.model_t0) != model_t0: + mismatches.append("GEN_MODEL_T0") + if state.gen_seed != manifest.get("gen_seed"): + mismatches.append("GEN_SEED") + if state.model_timezone != manifest.get("model_timezone"): + mismatches.append("GEN_MODEL_TIMEZONE") + if ( + self.config.model_t_end is not None + and self.config.model_t_end != model_t_end + ): + mismatches.append("GEN_MODEL_T_END") + if model_t0 != self.config.model_t0: + mismatches.append("GEN_MODEL_T0") + if manifest.get("gen_seed") != self.config.seed: + mismatches.append("GEN_SEED") + if manifest.get("model_timezone") != self.config.model_timezone: + mismatches.append("GEN_MODEL_TIMEZONE") + + settings = manifest.get("generation_settings") or {} + if abs(state.model_time_speed - settings.get("model_time_speed", -1)) > 1e-9: + mismatches.append("GEN_MODEL_TIME_SPEED") + if self._generation_settings() != settings: + mismatches.append("generation_settings") + return list(dict.fromkeys(mismatches)) + + def _is_startup_history_marker(self, state) -> bool: + """Отличает state стартовой истории от обычного live-state.""" + return str(state.last_batch_id).startswith("startup-history-") + + @staticmethod + def _as_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def _save_state(self, batch_id: str) -> None: + """Сохраняет текущее состояние генератора.""" + if not self.state_manager or not self.config.state_enabled: + return + + try: + wall_now = datetime.now(timezone.utc) + state = self.stream.to_state( + tick=self._tick, + rng_state=self.generator.rng.getstate(), + last_batch_id=batch_id, + last_timestamp=self._model_time, + model_timestamp=self._model_time, + wall_timestamp=wall_now, + model_time_speed=self.config.model_time_speed, + model_timezone=self.config.model_timezone, + model_t0=self.config.model_t0, + gen_seed=self.config.seed, + ) + self.state_manager.save(state) + self.state_manager.flush() + logger.debug( + f"Saved state: tick={self._tick}, " + f"model_time={self._model_time.isoformat()}, batch_id={batch_id}" + ) + except Exception as e: + logger.warning(f"Failed to save state: {e}") + METRICS_ERRORS_TOTAL.labels(topic="state").inc() + + def _run_backfill(self) -> None: + """Проматывает стартовую историю без сна до GEN_MODEL_T_END.""" + if self.config.model_t_end is None: + raise ValueError("GEN_MODEL_T_END is required for backfill") + if not self.publisher: + raise RuntimeError("publisher is not initialized") + if not self.history: + raise RuntimeError("history is not initialized") + + logger.info( + "Running backfill from %s to %s", + self.config.model_t0.isoformat(), + self.config.model_t_end.isoformat(), + ) + artifact_builder = StartupHistoryArtifactBuilder() + + while self._model_time < self.config.model_t_end: + self._tick += 1 + batch_id = f"backfill-{self._tick:08d}" + model_time = self._model_time + started_at = datetime.now(timezone.utc) + + events_count = self.generator._calculate_events_count(now=model_time) + batch = self.stream.generate_tick( + events_count, + tick_started_at=model_time, + ) + total_sent, sent_counts, status = self._publish_batch(batch) + self._raise_on_backfill_publish_error(batch_id, status, sent_counts) + artifact_builder.add_batch(batch) + self._write_batch_history( + batch_id=batch_id, + started_at=started_at, + sent_counts=sent_counts, + total_sent=total_sent, + status=status, + error_message=None if status == "success" else "Backfill publish error", + ) + self._advance_model_time() + + final_batch = self.stream.drain_until( + self.config.model_t_end, + include_boundary=False, + ) + final_sent = 0 + final_status = "success" + if any(final_batch.values()): + batch_id = f"backfill-{self._tick + 1:08d}-final" + started_at = datetime.now(timezone.utc) + final_sent, sent_counts, final_status = self._publish_batch(final_batch) + self._raise_on_backfill_publish_error( + batch_id, + final_status, + sent_counts, + ) + artifact_builder.add_batch(final_batch) + self._write_batch_history( + batch_id=batch_id, + started_at=started_at, + sent_counts=sent_counts, + total_sent=final_sent, + status=final_status, + error_message=None if final_status == "success" else "Backfill publish error", + ) + + if self.publisher: + self.publisher.flush() + + self._model_time = self.config.model_t_end + state_batch_id = self._startup_state_batch_id(artifact_builder.counters) + state = self.stream.to_state( + tick=self._tick, + rng_state=self.generator.rng.getstate(), + last_batch_id=state_batch_id, + last_timestamp=self.config.model_t_end, + model_timestamp=self.config.model_t_end, + wall_timestamp=datetime.now(timezone.utc), + model_time_speed=self.config.model_time_speed, + model_timezone=self.config.model_timezone, + model_t0=self.config.model_t0, + gen_seed=self.config.seed, + ) + + manifest = build_manifest( + config=self.config, + counters=artifact_builder.counters, + state=state, + ) + if self.manifest_manager: + self.manifest_manager.save(manifest) + self.manifest_manager.flush() + + if self.state_manager and self.config.state_enabled: + self.state_manager.save(state) + self.state_manager.flush() + + if self.config.startup_history_artifact is not None: + artifact = artifact_builder.to_artifact(manifest=manifest, state=state) + write_startup_history_artifact( + self.config.startup_history_artifact, + artifact, + ) + logger.info( + "Startup history artifact written: %s", + self.config.startup_history_artifact, + ) + + logger.info( + "Backfill completed: events=%s, visits=%s, users=%s, final_sent=%s", + manifest["totals"]["events"], + manifest["totals"]["visits"], + manifest["totals"]["users"], + final_sent, + ) + + def _raise_on_backfill_publish_error( + self, + batch_id: str, + status: str, + sent_counts: dict[str, dict[str, int]], + ) -> None: + """Останавливает backfill до записи state/manifest при ошибке Kafka.""" + if status == "success": + return + + raise RuntimeError( + f"Backfill publish failed for batch {batch_id}: " + f"status={status}, sent_counts={sent_counts}" + ) + + def _run_next_day(self, manifest: dict) -> None: + """Доливает ровно 24 модельных часа от границы manifest.""" + if not self.publisher: + raise RuntimeError("publisher не инициализирован") + if not self.history: + raise RuntimeError("история batch не инициализирована") + if not self.state_manager or not self.manifest_manager: + raise RuntimeError("менеджеры state и manifest не инициализированы") + + current_t_end = self._as_aware_utc( + datetime.fromisoformat(manifest["model_t_end"]) + ) + if self._model_time != current_t_end: + raise IncompatibleStateError( + "восстановленное время next-day не совпадает с T_end manifest" + ) + target_t_end = current_t_end + timedelta(hours=24) + logger.info( + "Запуск next-day от %s до %s", + current_t_end.isoformat(), + target_t_end.isoformat(), + ) + + while self._model_time < target_t_end: + self._tick += 1 + batch_id = f"next-day-{self._tick:08d}" + model_time = self._model_time + started_at = datetime.now(timezone.utc) + events_count = self.generator._calculate_events_count(now=model_time) + batch = self.stream.generate_tick( + events_count, + tick_started_at=model_time, + ) + total_sent, sent_counts, status = self._publish_batch(batch) + self._raise_on_next_day_publish_error(batch_id, status, sent_counts) + self._write_batch_history( + batch_id=batch_id, + started_at=started_at, + sent_counts=sent_counts, + total_sent=total_sent, + status=status, + error_message=None, + ) + self._advance_model_time() + + final_batch = self.stream.drain_until( + target_t_end, + include_boundary=False, + ) + if any(final_batch.values()): + batch_id = f"next-day-{self._tick + 1:08d}-final" + started_at = datetime.now(timezone.utc) + total_sent, sent_counts, status = self._publish_batch(final_batch) + self._raise_on_next_day_publish_error(batch_id, status, sent_counts) + self._write_batch_history( + batch_id=batch_id, + started_at=started_at, + sent_counts=sent_counts, + total_sent=total_sent, + status=status, + error_message=None, + ) + + self.publisher.flush() + self._model_time = target_t_end + reader = self.data_reader or KafkaDataTopicReader( + self.config.kafka_bootstrap_servers + ) + counters = ManifestCounters() + counters.add_batch(reader.load()) + state_batch_id = self._startup_state_batch_id(counters) + state = self.stream.to_state( + tick=self._tick, + rng_state=self.generator.rng.getstate(), + last_batch_id=state_batch_id, + last_timestamp=target_t_end, + model_timestamp=target_t_end, + wall_timestamp=datetime.now(timezone.utc), + model_time_speed=self.config.model_time_speed, + model_timezone=self.config.model_timezone, + model_t0=self.config.model_t0, + gen_seed=self.config.seed, + ) + boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()] + updated_manifest = build_manifest( + self.config, + counters, + state, + model_t_end=target_t_end, + boundaries=boundaries, + ) + + self.state_manager.save(state) + self.state_manager.flush() + self.manifest_manager.save(updated_manifest) + self.manifest_manager.flush() + + def _raise_on_next_day_publish_error( + self, + batch_id: str, + status: str, + sent_counts: dict[str, dict[str, int]], + ) -> None: + """Останавливает next-day до записи state и manifest при ошибке Kafka.""" + if status == "success": + return + raise RuntimeError( + f"Публикация next-day не удалась для batch {batch_id}: " + f"status={status}, sent_counts={sent_counts}" + ) + + def _publish_batch( + self, + batch: dict[str, list[dict]], + ) -> tuple[int, dict[str, dict[str, int]], str]: + """Публикует batch и возвращает счётчики отправки.""" + total_sent = 0 + total_errors = 0 + sent_counts = {} + + for topic, events in batch.items(): + if events: + sent, errors = self.publisher.publish(topic, events) + sent_counts[topic] = {"sent": sent, "errors": errors} + total_sent += sent + total_errors += errors + + if total_errors == 0: + status = "success" + elif total_sent > 0: + status = "partial" + else: + status = "error" + + return total_sent, sent_counts, status + + def _write_batch_history( + self, + batch_id: str, + started_at: datetime, + sent_counts: dict[str, dict[str, int]], + total_sent: int, + status: str, + error_message: str | None, + ) -> None: + """Пишет служебную историю batch.""" + if not self.history: + return + try: + record = BatchRecord( + batch_id=batch_id, + started_at=started_at, + finished_at=datetime.now(timezone.utc), + sent_total=total_sent, + sent_browser=sent_counts.get("browser_events", {}).get("sent", 0), + sent_location=sent_counts.get("location_events", {}).get("sent", 0), + sent_device=sent_counts.get("device_events", {}).get("sent", 0), + sent_geo=sent_counts.get("geo_events", {}).get("sent", 0), + status=status, + error_message=error_message, + ) + self.history.add(record) + self.history.flush() + except Exception as hist_err: + logger.warning(f"Failed to write batch history: {hist_err}") + METRICS_ERRORS_TOTAL.labels(topic="history").inc() + + def _startup_state_batch_id(self, counters) -> str: + digest = counters.topic_stats["browser_events"].checksum[:12] + return f"startup-history-{digest}" + + def _build_startup_history_manifest(self, counters, state) -> dict: + return build_manifest(config=self.config, counters=counters, state=state) + + def _generation_settings(self) -> dict: + return generation_settings_from_config(self.config) + + def _main_loop(self): + """Основной цикл тиков.""" + while self._running: + self._tick += 1 + tick_start = time.time() + batch_id = str(uuid.uuid4())[:8] + model_time = self._model_time + + with METRICS_TICK_DURATION.time(): + logger.debug(f"=== Tick {self._tick} (batch_id={batch_id}) ===") + + try: + events_count = self.generator._calculate_events_count( + now=model_time, + ) + logger.debug(f"Generating with event budget ~{events_count}") + + gen_start = time.time() + batch = self.stream.generate_tick( + events_count, + tick_started_at=model_time, + ) + gen_duration = time.time() - gen_start + + pub_start = time.time() + total_sent = 0 + total_errors = 0 + + sent_counts = {} + for topic, events in batch.items(): + if events: + sent, errors = self.publisher.publish(topic, events) + sent_counts[topic] = {"sent": sent, "errors": errors} + total_sent += sent + total_errors += errors + + if total_errors == 0: + status = "success" + elif total_sent > 0: + status = "partial" + else: + status = "error" + + if status in ("success", "partial"): + METRICS_LAST_SUCCESS.set_to_current_time() + self._advance_model_time() + self._save_state(batch_id) + + self.publisher.flush() + pub_duration = time.time() - pub_start + + try: + batch_record = BatchRecord( + batch_id=batch_id, + started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc), + finished_at=datetime.now(timezone.utc), + sent_total=total_sent, + sent_browser=sent_counts.get("browser_events", {}).get("sent", 0), + sent_location=sent_counts.get("location_events", {}).get("sent", 0), + sent_device=sent_counts.get("device_events", {}).get("sent", 0), + sent_geo=sent_counts.get("geo_events", {}).get("sent", 0), + status=status, + error_message=None if status == "success" else f"Errors: {total_errors}", + ) + self.history.add(batch_record) + self.history.flush() + except Exception as hist_err: + logger.warning(f"Failed to write batch history: {hist_err}") + METRICS_ERRORS_TOTAL.labels(topic="history").inc() + + tick_duration = time.time() - tick_start + logger.debug( + f"Batch {batch_id} completed: " + f"sent={total_sent}, errors={total_errors}, " + f"gen_time={gen_duration:.3f}s, pub_time={pub_duration:.3f}s, " + f"total_time={tick_duration:.3f}s" + ) + + for topic, counts in sent_counts.items(): + if counts["sent"] > 0: + logger.debug(f" {topic}: {counts['sent']} sent") + + except Exception as e: + logger.exception(f"Error in tick {self._tick}: {e}") + try: + self.history.add( + BatchRecord( + batch_id=batch_id, + started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc), + finished_at=datetime.now(timezone.utc), + sent_total=0, + sent_browser=0, + sent_location=0, + sent_device=0, + sent_geo=0, + status="error", + error_message=str(e), + ) + ) + self.history.flush() + except Exception as hist_err: + logger.warning(f"Failed to write error to history: {hist_err}") + + elapsed = time.time() - tick_start + sleep_time = max(0, self.config.tick_seconds - elapsed) + if sleep_time > 0: + logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick") + self._shutdown_event.wait(sleep_time) + + def _advance_model_time(self) -> None: + """Сдвигает модельное время после успешного live-тика.""" + self._model_time = self._model_time + timedelta( + seconds=self.config.tick_seconds * self.config.model_time_speed, + ) + + +def main(): + """Точка входа сервиса.""" + try: + config = Config() + service = GeneratorService(config) + signal.signal(signal.SIGTERM, service.request_stop) + service.start() + except Exception as e: + logger.exception(f"Fatal error: {e}") + sys.exit(1) diff --git a/generator/src/clickstream_generator/stand_clean.py b/generator/src/clickstream_generator/stand_clean.py new file mode 100644 index 0000000..ea5d6e9 --- /dev/null +++ b/generator/src/clickstream_generator/stand_clean.py @@ -0,0 +1,168 @@ +"""Общая предпроверка чистого стенда перед новым миром.""" + +from __future__ import annotations + +import argparse +import sys +import urllib.error +import urllib.request +from collections.abc import Iterable + +from clickstream_generator.config import Config +from clickstream_generator.kafka_io import ( + KafkaStateManager, + KafkaStartupHistoryManifest, +) +from clickstream_generator.service import GeneratorService, IncompatibleStateError +from clickstream_generator.state import UnsupportedStateVersionError +from clickstream_generator.startup_history_artifact import KafkaTopicInspector + + +STG_EMPTY_SQL = """ +SELECT + (SELECT count() FROM stg.browser_raw) AS browser_raw, + (SELECT count() FROM stg.location_raw) AS location_raw, + (SELECT count() FROM stg.device_raw) AS device_raw, + (SELECT count() FROM stg.geo_raw) AS geo_raw +""" + +STG_TABLE_NAMES = ( + "stg.browser_raw", + "stg.location_raw", + "stg.device_raw", + "stg.geo_raw", +) + + +def assert_kafka_data_topics_empty(kafka_bootstrap_servers: str) -> None: + """Падает, если в Kafka data-топиках уже есть сообщения.""" + try: + KafkaTopicInspector(kafka_bootstrap_servers).assert_data_topics_empty() + except RuntimeError as exc: + raise RuntimeError( + "Стенд не чистый: в Kafka data-топиках уже есть сообщения. " + "Выполните make clean и повторите операцию." + ) from exc + + +def assert_stg_counts_empty(counts: Iterable[int]) -> None: + """Падает, если в STG уже есть строки.""" + dirty = [ + f"{name}={int(count)}" + for name, count in zip(STG_TABLE_NAMES, counts) + if int(count) > 0 + ] + if dirty: + raise RuntimeError( + "Стенд не чистый: в STG уже есть строки (" + + ", ".join(dirty) + + "). Выполните make clean и повторите операцию." + ) + + +def parse_stg_counts(value: str) -> tuple[int, int, int, int]: + """Разбирает TSV-строку ClickHouse с четырьмя счётчиками STG.""" + parts = value.strip().split() + if len(parts) != len(STG_TABLE_NAMES): + raise RuntimeError( + "Не удалось проверить чистоту STG: ClickHouse вернул " + f"{len(parts)} значений вместо {len(STG_TABLE_NAMES)}." + ) + return tuple(int(part) for part in parts) # type: ignore[return-value] + + +def assert_stand_clean(kafka_bootstrap_servers: str, stg_counts: Iterable[int]) -> None: + """Проверяет Kafka data-топики и STG перед backfill/import.""" + assert_kafka_data_topics_empty(kafka_bootstrap_servers) + assert_stg_counts_empty(stg_counts) + + +def assert_live_generator_not_running(live_metrics_url: str) -> None: + """Падает, если live-генератор отвечает на metrics-порту.""" + try: + urllib.request.urlopen(live_metrics_url, timeout=2).close() + except (urllib.error.URLError, TimeoutError, OSError): + return + raise RuntimeError( + "Live-генератор уже запущен. Остановите его через make generator-down " + "или make clean перед новым backfill/import." + ) + + +def assert_continue_has_state_or_clean_stand( + kafka_bootstrap_servers: str, + stg_counts: Iterable[int], + config: Config, +) -> None: + """Разрешает continue только от совместимого state или чистого стенда.""" + state_manager = KafkaStateManager(kafka_bootstrap_servers) + try: + state = state_manager.load() + except UnsupportedStateVersionError as exc: + raise RuntimeError( + f"State записан старой версией {exc.found_version}. " + "Выполните make clean или явно начните новый мир через make generator-reset." + ) from exc + finally: + state_manager.close() + + if state is None: + assert_stand_clean(kafka_bootstrap_servers, stg_counts) + return + + service = GeneratorService(config) + manifest_manager = KafkaStartupHistoryManifest(kafka_bootstrap_servers) + try: + manifest = manifest_manager.load() + finally: + manifest_manager.close() + + if service._is_startup_history_state(state, manifest): + return + if service._is_startup_history_marker(state): + mismatches = service._startup_history_mismatch_fields(state, manifest) + raise RuntimeError( + "State стартовой истории несовместим с текущими настройками: " + + ", ".join(mismatches) + + ". Выполните make clean или используйте тот же профиль." + ) + try: + service._validate_live_state_config(state) + except IncompatibleStateError as exc: + raise RuntimeError(str(exc)) from exc + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Проверить, что стенд чист перед новым миром генератора." + ) + parser.add_argument("--mode", choices=("clean", "continue", "live"), default="clean") + parser.add_argument("--kafka-bootstrap-servers") + parser.add_argument("--stg-counts") + parser.add_argument("--live-metrics-url") + args = parser.parse_args(argv) + + try: + if args.live_metrics_url: + assert_live_generator_not_running(args.live_metrics_url) + if args.mode == "live": + return 0 + if not args.kafka_bootstrap_servers or not args.stg_counts: + raise RuntimeError("Нужны --kafka-bootstrap-servers и --stg-counts.") + stg_counts = parse_stg_counts(args.stg_counts) + if args.mode == "continue": + assert_continue_has_state_or_clean_stand( + args.kafka_bootstrap_servers, + stg_counts, + Config(), + ) + else: + assert_stand_clean(args.kafka_bootstrap_servers, stg_counts) + except RuntimeError as exc: + print(f"Ошибка: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/generator/src/clickstream_generator/startup_history_artifact.py b/generator/src/clickstream_generator/startup_history_artifact.py new file mode 100644 index 0000000..e77f9e6 --- /dev/null +++ b/generator/src/clickstream_generator/startup_history_artifact.py @@ -0,0 +1,755 @@ +"""Портативный артефакт стартовой истории.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from clickstream_generator.config import Config +from clickstream_generator.kafka_io import ( + KafkaStateManager, + KafkaStartupHistoryManifest, + ensure_topics, + kafka_event_value_json, + _kafka_importer, +) +from clickstream_generator.state import GeneratorState + + +logger = logging.getLogger("generator") + +ARTIFACT_KIND = "clickstream_generator_startup_history" +ARTIFACT_VERSION = "1.0" +TOPICS = ("browser_events", "location_events", "device_events", "geo_events") +IMPORT_TOPICS = ( + "browser_events", + "location_events", + "device_events", + "geo_events", + KafkaStateManager.STATE_TOPIC, + KafkaStartupHistoryManifest.MANIFEST_TOPIC, +) + + +class _TopicManifestStats: + """Накопительные счётчики одного Kafka-топика для manifest.""" + + def __init__(self): + self.rows = 0 + self.min_event_timestamp: str | None = None + self.max_event_timestamp: str | None = None + self._checksum = hashlib.sha256() + + @property + def checksum(self) -> str: + return self._checksum.hexdigest() + + def add(self, event: dict, event_timestamp: str | None = None) -> None: + self.rows += 1 + self._checksum.update( + json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8") + ) + timestamp = ( + event_timestamp + if event_timestamp is not None + else event.get("event_timestamp") + ) + self.add_timestamp(timestamp) + + def add_timestamp(self, timestamp: str | None) -> None: + if timestamp is None: + return + if self.min_event_timestamp is None or timestamp < self.min_event_timestamp: + self.min_event_timestamp = timestamp + if self.max_event_timestamp is None or timestamp > self.max_event_timestamp: + self.max_event_timestamp = timestamp + + def to_dict(self) -> dict: + return { + "rows": self.rows, + "min_event_timestamp": self.min_event_timestamp, + "max_event_timestamp": self.max_event_timestamp, + "checksum_sha256": self.checksum, + } + + +class ManifestCounters: + """Счётчики стартовой истории для manifest.""" + + def __init__(self): + self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS} + self.click_ids: set[str] = set() + self.user_ids: set[str] = set() + + def add_batch(self, batch: dict[str, list[dict]]) -> None: + browser_events = batch.get("browser_events", []) + event_timestamps = { + event["event_id"]: event.get("event_timestamp") + for event in browser_events + if event.get("event_id") and event.get("event_timestamp") + } + click_timestamps: dict[str, list[str]] = {} + for event in browser_events: + click_id = event.get("click_id") + timestamp = event.get("event_timestamp") + if click_id and timestamp: + click_timestamps.setdefault(click_id, []).append(timestamp) + + for topic, events in batch.items(): + if topic not in self.topic_stats: + raise ValueError(f"unknown startup history topic: {topic}") + stats = self.topic_stats[topic] + for event in events: + event_timestamp = event.get("event_timestamp") + if event_timestamp is None and topic == "location_events": + event_timestamp = event_timestamps.get(event.get("event_id")) + if event_timestamp is None and topic in ("device_events", "geo_events"): + timestamps = click_timestamps.get(event.get("click_id")) + if timestamps: + event_timestamp = min(timestamps) + stats.add_timestamp(max(timestamps)) + stats.add(event, event_timestamp=event_timestamp) + click_id = event.get("click_id") + if topic == "browser_events" and click_id: + self.click_ids.add(click_id) + user_id = event.get("user_domain_id") + if topic == "device_events" and user_id: + self.user_ids.add(user_id) + + def to_manifest_topics(self) -> dict: + return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()} + + def to_manifest_totals(self) -> dict: + browser_stats = self.topic_stats["browser_events"] + return { + "events": browser_stats.rows, + "visits": len(self.click_ids), + "users": len(self.user_ids), + "min_event_timestamp": browser_stats.min_event_timestamp, + "max_event_timestamp": browser_stats.max_event_timestamp, + } + + +class StartupHistoryArtifactBuilder: + """Собирает сообщения топиков для портативного файла.""" + + def __init__(self): + self.topics = {topic: [] for topic in TOPICS} + self.raw_topics = {topic: [] for topic in TOPICS} + self.counters = ManifestCounters() + + def add_batch(self, batch: dict[str, list[dict]]) -> None: + self.counters.add_batch(batch) + for topic in TOPICS: + for event in batch.get(topic, []): + self.topics[topic].append(deepcopy(event)) + self.raw_topics[topic].append( + { + "key": _event_key(event), + "value_json": _event_value_json(event), + } + ) + + def to_artifact(self, manifest: dict, state: GeneratorState) -> dict: + return { + "artifact_version": ARTIFACT_VERSION, + "kind": ARTIFACT_KIND, + "created_at": datetime.now(timezone.utc).isoformat(), + "manifest": deepcopy(manifest), + "state": state.to_dict(), + "topics": deepcopy(self.topics), + "raw_topics": deepcopy(self.raw_topics), + } + + +class KafkaRawPublisher: + """Публикует сохранённые Kafka value bytes без повторной JSON-сериализации.""" + + def __init__(self, bootstrap_servers: str): + self.bootstrap_servers = bootstrap_servers + KafkaProducerCls, _ = _kafka_importer()() + self.producer = KafkaProducerCls( + bootstrap_servers=self.bootstrap_servers, + key_serializer=lambda k: k.encode("utf-8") if k else None, + retries=3, + retry_backoff_ms=1000, + ) + + def publish_records(self, topic: str, records: list[dict]) -> tuple[int, int]: + sent = 0 + errors = 0 + futures = [] + for record in records: + future = self.producer.send( + topic, + key=record.get("key"), + value=record["value_json"].encode("utf-8"), + ) + futures.append(future) + for future in futures: + try: + future.get(timeout=10) + sent += 1 + except Exception: + errors += 1 + return sent, errors + + def flush(self) -> None: + self.producer.flush() + + def close(self) -> None: + self.producer.close() + + +class KafkaTopicInspector: + """Проверяет Kafka-топики перед clean-stand импортом.""" + + def __init__(self, bootstrap_servers: str): + self.bootstrap_servers = bootstrap_servers + + def assert_data_topics_empty(self) -> None: + """Падает, если в data-топиках уже есть сообщения.""" + from kafka import KafkaConsumer, TopicPartition + + consumer = KafkaConsumer( + bootstrap_servers=self.bootstrap_servers, + enable_auto_commit=False, + consumer_timeout_ms=1000, + ) + try: + dirty_topics = [] + for topic in TOPICS: + partitions = consumer.partitions_for_topic(topic) + if not partitions: + continue + topic_partitions = [ + TopicPartition(topic, partition) + for partition in partitions + ] + end_offsets = consumer.end_offsets(topic_partitions) + if any(offset > 0 for offset in end_offsets.values()): + dirty_topics.append(topic) + if dirty_topics: + raise RuntimeError( + "Kafka data topics are not empty; clean the stand before " + "startup history import: " + ", ".join(dirty_topics) + ) + finally: + consumer.close() + + def snapshot_import_topics(self) -> dict: + """Снимок для отката clean-stand импорта.""" + return {"topics": list(IMPORT_TOPICS)} + + def rollback_import_topics(self, snapshot: dict) -> None: + """Удаляет топики, которые могли получить частичный импорт.""" + from kafka import KafkaAdminClient + from kafka.errors import UnknownTopicOrPartitionError + + admin = KafkaAdminClient(bootstrap_servers=self.bootstrap_servers) + try: + existing_topics = set(admin.list_topics()) + topics = [ + topic + for topic in snapshot.get("topics", IMPORT_TOPICS) + if topic in existing_topics + ] + if not topics: + return + try: + admin.delete_topics(topics) + except UnknownTopicOrPartitionError: + return + finally: + admin.close() + + +def generation_settings_from_config(config: Config) -> dict: + """Возвращает настройки, влияющие на поток генерации.""" + return { + "tick_seconds": config.tick_seconds, + "lambda_base_per_min": config.lambda_base_per_min, + "jitter_pct": config.jitter_pct, + "min_events_per_tick": config.min_events_per_tick, + "max_events_per_tick": config.max_events_per_tick, + "max_session_events": config.max_session_events, + "max_active_sessions": config.max_active_sessions, + "population_max": config.population_max, + "p_new_user": config.p_new_user, + "min_return_minutes": config.min_return_minutes, + "model_time_speed": config.model_time_speed, + } + + +def build_manifest( + config: Config, + counters: ManifestCounters, + state: GeneratorState, + *, + model_t_end: datetime | None = None, + boundaries: list[str] | None = None, +) -> dict: + """Строит manifest стартовой истории.""" + selected_t_end = model_t_end or config.model_t_end + if selected_t_end is None: + raise ValueError("GEN_MODEL_T_END is required for startup history manifest") + selected_boundaries = ( + boundaries + if boundaries is not None + else [config.model_t0.isoformat(), selected_t_end.isoformat()] + ) + manifest = { + "manifest_version": "1.0", + "generated_at": datetime.now(timezone.utc).isoformat(), + "gen_seed": config.seed, + "model_t0": config.model_t0.isoformat(), + "model_t_end": selected_t_end.isoformat(), + "boundaries": list(selected_boundaries), + "model_timezone": config.model_timezone, + "run_mode": "backfill", + "launch_profile": config.launch_profile, + "generation_settings": generation_settings_from_config(config), + "state_version": state.version, + "state": { + "topic": KafkaStateManager.STATE_TOPIC, + "key": KafkaStateManager.STATE_KEY, + "last_batch_id": state.last_batch_id, + "model_timestamp": state.model_timestamp.isoformat(), + }, + "topics": counters.to_manifest_topics(), + "totals": counters.to_manifest_totals(), + } + manifest_boundaries(manifest) + return manifest + + +def manifest_boundaries(manifest: dict) -> list[str]: + """Возвращает проверенную цепочку границ, включая старый формат.""" + model_t0 = _parse_manifest_timestamp(manifest.get("model_t0")) + model_t_end = _parse_manifest_timestamp(manifest.get("model_t_end")) + raw_boundaries = manifest.get("boundaries") + if raw_boundaries is None: + return [manifest["model_t0"], manifest["model_t_end"]] + if not isinstance(raw_boundaries, list) or len(raw_boundaries) < 2: + raise ValueError("manifest boundaries должен содержать T0 и T_end") + + parsed = [_parse_manifest_timestamp(value) for value in raw_boundaries] + if parsed[0] != model_t0: + raise ValueError("manifest boundaries не начинается с model_t0") + if parsed[-1] != model_t_end: + raise ValueError("manifest boundaries не заканчивается на model_t_end") + if any(left >= right for left, right in zip(parsed, parsed[1:])): + raise ValueError("manifest boundaries должен строго возрастать") + return list(raw_boundaries) + + +def write_startup_history_artifact(path: str | Path, artifact: dict) -> None: + """Пишет артефакт в JSON-файл.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(artifact, ensure_ascii=True, indent=2), + encoding="utf-8", + ) + + +def load_startup_history_artifact(path: str | Path) -> dict: + """Читает артефакт из JSON-файла.""" + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def validate_startup_history_artifact( + artifact: dict, + expected_config: Config | None = None, +) -> tuple[GeneratorState, dict, dict[str, list[dict]], dict[str, list[dict]]]: + """Проверяет связку manifest, state и сообщений топиков.""" + if not isinstance(artifact, dict): + raise ValueError("artifact must be an object") + if artifact.get("kind") != ARTIFACT_KIND: + raise ValueError("artifact kind mismatch") + if artifact.get("artifact_version") != ARTIFACT_VERSION: + raise ValueError("artifact version mismatch") + + manifest = artifact.get("manifest") + state_payload = artifact.get("state") + topics = artifact.get("topics") + raw_topics = artifact.get("raw_topics") + if not isinstance(manifest, dict): + raise ValueError("artifact manifest must be an object") + if not isinstance(state_payload, dict): + raise ValueError("artifact state must be an object") + if not isinstance(topics, dict): + raise ValueError("artifact topics must be an object") + if not isinstance(raw_topics, dict): + raise ValueError("artifact raw_topics must be an object") + for topic in TOPICS: + if not isinstance(topics.get(topic), list): + raise ValueError(f"artifact topic {topic} must be a list") + if not isinstance(raw_topics.get(topic), list): + raise ValueError(f"artifact raw topic {topic} must be a list") + + state = GeneratorState.from_dict(state_payload) + manifest_boundaries(manifest) + _validate_manifest_state(manifest, state) + _validate_manifest_topics(manifest, topics) + _validate_raw_topics(topics, raw_topics) + if expected_config is not None: + _validate_manifest_config(manifest, expected_config) + return ( + state, + manifest, + {topic: topics[topic] for topic in TOPICS}, + {topic: raw_topics[topic] for topic in TOPICS}, + ) + + +def import_startup_history_artifact( + artifact: dict, + *, + publisher, + state_manager, + manifest_manager, + expected_config: Config | None = None, + topic_inspector=None, +) -> dict: + """Воспроизводит артефакт в Kafka и compact-топиках генератора.""" + state, manifest, topics, raw_topics = validate_startup_history_artifact( + artifact, + expected_config=expected_config, + ) + if topic_inspector is not None: + topic_inspector.assert_data_topics_empty() + snapshot = ( + topic_inspector.snapshot_import_topics() + if hasattr(topic_inspector, "snapshot_import_topics") + else None + ) + else: + snapshot = None + + if not hasattr(publisher, "publish_records"): + raise TypeError( + "startup history import requires publisher.publish_records " + "for raw Kafka values" + ) + + try: + sent_total = 0 + sent_by_topic = {} + + for topic in TOPICS: + raw_records = raw_topics[topic] + if not raw_records: + sent_by_topic[topic] = 0 + continue + sent, errors = publisher.publish_records(topic, raw_records) + if errors: + raise RuntimeError( + f"Startup history import failed for {topic}: sent={sent}, errors={errors}" + ) + sent_total += sent + sent_by_topic[topic] = sent + + publisher.flush() + manifest_manager.save(manifest) + manifest_manager.flush() + state_manager.save(state) + state_manager.flush() + except Exception: + if ( + topic_inspector is not None + and snapshot is not None + and hasattr(topic_inspector, "rollback_import_topics") + ): + topic_inspector.rollback_import_topics(snapshot) + raise + + return {"events": sent_total, "topics": sent_by_topic} + + +def compare_clickhouse_stats_to_manifest( + manifest: dict, + stats: dict[str, str], +) -> list[str]: + """Возвращает поля ClickHouse, которые не совпали с manifest.""" + totals = manifest.get("totals") or {} + expected = { + "events": str(totals.get("events")), + "visits": str(totals.get("visits")), + "users": str(totals.get("users")), + "min_event_timestamp": str(totals.get("min_event_timestamp")), + "max_event_timestamp": str(totals.get("max_event_timestamp")), + } + return [ + field + for field, expected_value in expected.items() + if str(stats.get(field)) != expected_value + ] + + +def _validate_manifest_state(manifest: dict, state: GeneratorState) -> None: + mismatches = [] + expected_state = manifest.get("state") or {} + if manifest.get("run_mode") != "backfill": + mismatches.append("run_mode") + if manifest.get("state_version") != state.version: + mismatches.append("state_version") + if expected_state.get("last_batch_id") != state.last_batch_id: + mismatches.append("state.last_batch_id") + if expected_state.get("model_timestamp") != state.model_timestamp.isoformat(): + mismatches.append("state.model_timestamp") + try: + model_t0 = _parse_manifest_timestamp(manifest["model_t0"]) + model_t_end = _parse_manifest_timestamp(manifest["model_t_end"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"manifest time fields are invalid: {exc}") from exc + if state.model_t0 != model_t0: + mismatches.append("GEN_MODEL_T0") + if state.model_timestamp != model_t_end: + mismatches.append("GEN_MODEL_T_END") + if state.gen_seed != manifest.get("gen_seed"): + mismatches.append("GEN_SEED") + if state.model_timezone != manifest.get("model_timezone"): + mismatches.append("GEN_MODEL_TIMEZONE") + settings = manifest.get("generation_settings") or {} + if abs(state.model_time_speed - settings.get("model_time_speed", -1)) > 1e-9: + mismatches.append("GEN_MODEL_TIME_SPEED") + if mismatches: + raise ValueError("startup history artifact mismatch: " + ", ".join(mismatches)) + + +def validate_manifest_state(manifest: dict, state: GeneratorState) -> None: + """Проверяет согласованность manifest и state без данных артефакта.""" + _validate_manifest_state(manifest, state) + + +def _validate_manifest_topics(manifest: dict, topics: dict[str, list[dict]]) -> None: + counters = ManifestCounters() + counters.add_batch({topic: topics[topic] for topic in TOPICS}) + expected_topics = manifest.get("topics") or {} + expected_totals = manifest.get("totals") or {} + if counters.to_manifest_topics() != expected_topics: + raise ValueError("startup history artifact mismatch: topic checksums") + if counters.to_manifest_totals() != expected_totals: + raise ValueError("startup history artifact mismatch: totals") + + +def _validate_raw_topics( + topics: dict[str, list[dict]], + raw_topics: dict[str, list[dict]], +) -> None: + for topic in TOPICS: + if len(raw_topics[topic]) != len(topics[topic]): + raise ValueError(f"artifact raw topic {topic} row count mismatch") + for event, record in zip(topics[topic], raw_topics[topic]): + if record.get("key") != _event_key(event): + raise ValueError(f"artifact raw topic {topic} key mismatch") + if _raw_value_to_event(record.get("value_json"), topic) != event: + raise ValueError(f"artifact raw topic {topic} value mismatch") + + +def _event_key(event: dict) -> str | None: + return event.get("event_id") or event.get("click_id") + + +def _event_value_json(event: dict) -> str: + return kafka_event_value_json(event) + + +def _raw_value_to_event(value: Any, topic: str) -> dict: + if not isinstance(value, str): + raise ValueError(f"artifact raw topic {topic} value must be a string") + try: + event = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError(f"artifact raw topic {topic} value must be valid JSON") from exc + if not isinstance(event, dict): + raise ValueError(f"artifact raw topic {topic} value must be a JSON object") + return event + + +def _validate_manifest_config(manifest: dict, config: Config) -> None: + mismatches = [] + if manifest.get("gen_seed") != config.seed: + mismatches.append("GEN_SEED") + if _parse_manifest_timestamp(manifest.get("model_t0")) != config.model_t0: + mismatches.append("GEN_MODEL_T0") + if config.model_t_end is None: + mismatches.append("GEN_MODEL_T_END") + elif _parse_manifest_timestamp(manifest.get("model_t_end")) != config.model_t_end: + mismatches.append("GEN_MODEL_T_END") + if manifest.get("model_timezone") != config.model_timezone: + mismatches.append("GEN_MODEL_TIMEZONE") + if manifest.get("generation_settings") != generation_settings_from_config(config): + mismatches.append("generation_settings") + if mismatches: + raise ValueError( + "startup history artifact is incompatible with current settings: " + + ", ".join(mismatches) + ) + + +def _parse_manifest_timestamp(value: Any) -> datetime: + if not isinstance(value, str): + raise ValueError("timestamp must be a string") + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + raise ValueError("timestamp must include timezone") + return timestamp.astimezone(timezone.utc) + + +def _run_import(args: argparse.Namespace) -> int: + config = Config() + artifact = load_startup_history_artifact(args.artifact) + 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: + result = 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() + logger.info( + "Startup history artifact imported: events=%s, topics=%s", + result["events"], + result["topics"], + ) + return 0 + + +def _run_validate(args: argparse.Namespace) -> int: + config = Config() + artifact = load_startup_history_artifact(args.artifact) + validate_startup_history_artifact(artifact, expected_config=config) + logger.info("Startup history artifact is valid: %s", args.artifact) + return 0 + + +def _run_manifest_summary(args: argparse.Namespace) -> int: + artifact = load_startup_history_artifact(args.artifact) + _, manifest, _, _ = validate_startup_history_artifact(artifact) + totals = manifest["totals"] + print( + "\t".join( + [ + str(totals["events"]), + str(totals["visits"]), + str(totals["users"]), + str(totals["min_event_timestamp"]), + str(totals["max_event_timestamp"]), + str(manifest["model_t0"]), + str(manifest["model_t_end"]), + ] + ) + ) + return 0 + + +def _print_manifest_summary(manifest: dict) -> None: + totals = manifest["totals"] + print( + "\t".join( + [ + str(totals["events"]), + str(totals["visits"]), + str(totals["users"]), + str(totals["min_event_timestamp"]), + str(totals["max_event_timestamp"]), + str(manifest["model_t0"]), + str(manifest["model_t_end"]), + str(manifest.get("launch_profile", "")), + ] + ) + ) + + +def _run_kafka_manifest_summary(args: argparse.Namespace) -> int: + config = Config() + manifest_manager = KafkaStartupHistoryManifest(config.kafka_bootstrap_servers) + try: + manifest = manifest_manager.load() + finally: + manifest_manager.close() + + if not manifest: + raise RuntimeError("startup history manifest not found in Kafka") + + _print_manifest_summary(manifest) + return 0 + + +def _run_kafka_boundaries(args: argparse.Namespace) -> int: + config = Config() + manifest_manager = KafkaStartupHistoryManifest(config.kafka_bootstrap_servers) + try: + manifest = manifest_manager.load() + finally: + manifest_manager.close() + if not manifest: + raise RuntimeError("manifest стартовой истории не найден в Kafka") + for boundary in manifest_boundaries(manifest): + print(boundary) + return 0 + + +def _run_kafka_topic_rows(args: argparse.Namespace) -> int: + config = Config() + manifest_manager = KafkaStartupHistoryManifest(config.kafka_bootstrap_servers) + try: + manifest = manifest_manager.load() + finally: + manifest_manager.close() + if not manifest: + raise RuntimeError("manifest стартовой истории не найден в Kafka") + topics = manifest.get("topics") or {} + print("\t".join(str(topics[topic]["rows"]) for topic in TOPICS)) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Startup history artifact tools") + subparsers = parser.add_subparsers(dest="command", required=True) + + validate_parser = subparsers.add_parser("validate") + validate_parser.add_argument("--artifact", required=True) + validate_parser.set_defaults(func=_run_validate) + + import_parser = subparsers.add_parser("import") + import_parser.add_argument("--artifact", required=True) + import_parser.set_defaults(func=_run_import) + + summary_parser = subparsers.add_parser("manifest-summary") + summary_parser.add_argument("--artifact", required=True) + summary_parser.set_defaults(func=_run_manifest_summary) + + kafka_summary_parser = subparsers.add_parser("kafka-manifest-summary") + kafka_summary_parser.set_defaults(func=_run_kafka_manifest_summary) + + kafka_boundaries_parser = subparsers.add_parser("kafka-boundaries") + kafka_boundaries_parser.set_defaults(func=_run_kafka_boundaries) + + kafka_topic_rows_parser = subparsers.add_parser("kafka-topic-rows") + kafka_topic_rows_parser.set_defaults(func=_run_kafka_topic_rows) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/generator/src/clickstream_generator/state.py b/generator/src/clickstream_generator/state.py new file mode 100644 index 0000000..4d70463 --- /dev/null +++ b/generator/src/clickstream_generator/state.py @@ -0,0 +1,308 @@ +"""Сериализуемое состояние генератора.""" + +import logging +import random +from dataclasses import dataclass, field +from datetime import datetime, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +logger = logging.getLogger("generator") + + +STATE_VERSION = "3.0" + + +class UnsupportedStateVersionError(ValueError): + """State записан в старом формате и требует явного решения оператора.""" + + def __init__(self, found_version: str, expected_version: str = STATE_VERSION): + self.found_version = found_version + self.expected_version = expected_version + super().__init__( + f"unsupported state version {found_version}; " + f"current version is {expected_version}" + ) + + +def _nested_list_to_tuple(obj): + """Рекурсивно преобразует list в tuple для восстановления RNG state.""" + if isinstance(obj, list): + return tuple(_nested_list_to_tuple(x) for x in obj) + return obj + + +def _require_keys(item: dict, keys: tuple[str, ...], label: str) -> None: + missing = [key for key in keys if key not in item] + if missing: + raise ValueError(f"{label} missing fields: {', '.join(missing)}") + + +def _parse_aware_utc(value: str, label: str) -> datetime: + if not isinstance(value, str) or not value: + raise ValueError(f"{label} must be a non-empty string") + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + if timestamp.tzinfo is None: + raise ValueError(f"{label} must include timezone") + return timestamp.astimezone(timezone.utc) + + +def _validate_resume_fields(data: dict) -> None: + _require_keys( + data, + ( + "model_timestamp", + "wall_timestamp", + "model_time_speed", + "model_timezone", + "model_t0", + "gen_seed", + ), + "state", + ) + _parse_aware_utc(data["model_timestamp"], "model_timestamp") + _parse_aware_utc(data["wall_timestamp"], "wall_timestamp") + _parse_aware_utc(data["model_t0"], "model_t0") + + model_time_speed = data["model_time_speed"] + if ( + isinstance(model_time_speed, bool) + or not isinstance(model_time_speed, int | float) + or model_time_speed <= 0 + ): + raise ValueError("model_time_speed must be a positive number") + + model_timezone = data["model_timezone"] + if not isinstance(model_timezone, str) or not model_timezone: + raise ValueError("model_timezone must be a string") + try: + ZoneInfo(model_timezone) + except ZoneInfoNotFoundError as e: + raise ValueError(f"unknown model_timezone: {model_timezone}") from e + + gen_seed = data["gen_seed"] + if gen_seed is not None: + if isinstance(gen_seed, bool) or not isinstance(gen_seed, int): + raise ValueError("gen_seed must be an integer or null") + + +def _validate_v3_payload(data: dict) -> None: + _validate_resume_fields(data) + population = data.get("population") + active_visits = data.get("active_visits") + pending_visit_births = data.get("pending_visit_births", 0.0) + + if not isinstance(population, list): + raise ValueError("population must be a list") + if not population: + raise ValueError("population must be non-empty") + if not isinstance(active_visits, list): + raise ValueError("active_visits must be a list") + if not isinstance(pending_visit_births, int | float): + raise ValueError("pending_visit_births must be a number") + if not 0 <= pending_visit_births < 1_000_000: + raise ValueError("pending_visit_births is out of range") + + users_by_id = {} + for index, user in enumerate(population): + if not isinstance(user, dict): + raise ValueError(f"population[{index}] must be an object") + _require_keys(user, ("user_domain_id", "seed_click_id"), f"population[{index}]") + user_domain_id = user["user_domain_id"] + seed_click_id = user["seed_click_id"] + active_click_id = user.get("active_click_id") + last_finished_at = user.get("last_finished_at") + if not isinstance(user_domain_id, str) or not user_domain_id: + raise ValueError(f"population[{index}].user_domain_id must be a string") + if not isinstance(seed_click_id, str) or not seed_click_id: + raise ValueError(f"population[{index}].seed_click_id must be a string") + if active_click_id is not None and not isinstance(active_click_id, str): + raise ValueError(f"population[{index}].active_click_id must be a string or null") + if last_finished_at is not None: + if not isinstance(last_finished_at, str): + raise ValueError(f"population[{index}].last_finished_at must be a string or null") + datetime.fromisoformat(last_finished_at) + if user_domain_id in users_by_id: + raise ValueError(f"duplicate population user_domain_id: {user_domain_id}") + users_by_id[user_domain_id] = user + + active_visit_pairs = set() + for index, visit in enumerate(active_visits): + if not isinstance(visit, dict): + raise ValueError(f"active_visits[{index}] must be an object") + _require_keys( + visit, + ( + "user_domain_id", + "click_id", + "base_click_id", + "next_index", + "started_at", + "offsets_us", + "page_url_paths", + ), + f"active_visits[{index}]", + ) + user_domain_id = visit["user_domain_id"] + click_id = visit["click_id"] + base_click_id = visit["base_click_id"] + offsets = visit["offsets_us"] + page_url_paths = visit["page_url_paths"] + next_index = visit["next_index"] + if not isinstance(user_domain_id, str) or user_domain_id not in users_by_id: + raise ValueError(f"active_visits[{index}].user_domain_id is unknown") + if not isinstance(click_id, str) or not click_id: + raise ValueError(f"active_visits[{index}].click_id must be a string") + if not isinstance(base_click_id, str) or not base_click_id: + raise ValueError(f"active_visits[{index}].base_click_id must be a string") + if not isinstance(offsets, list) or not offsets: + raise ValueError(f"active_visits[{index}].offsets_us must be a non-empty list") + if not all(isinstance(offset, int) and offset >= 0 for offset in offsets): + raise ValueError(f"active_visits[{index}].offsets_us must contain non-negative integers") + if offsets != sorted(offsets): + raise ValueError(f"active_visits[{index}].offsets_us must be sorted") + if not isinstance(page_url_paths, list) or len(page_url_paths) != len(offsets): + raise ValueError( + f"active_visits[{index}].page_url_paths must match offsets_us length" + ) + if not all(isinstance(path, str) and path.startswith("/") for path in page_url_paths): + raise ValueError(f"active_visits[{index}].page_url_paths must contain paths") + if not isinstance(next_index, int) or not 0 <= next_index <= len(offsets): + raise ValueError(f"active_visits[{index}].next_index is out of range") + datetime.fromisoformat(visit["started_at"]) + + user_active_click_id = users_by_id[user_domain_id].get("active_click_id") + if user_active_click_id != click_id: + raise ValueError( + f"population active_click_id conflicts with active_visits[{index}]" + ) + active_visit_pairs.add((user_domain_id, click_id)) + + for user_domain_id, user in users_by_id.items(): + active_click_id = user.get("active_click_id") + if active_click_id and (user_domain_id, active_click_id) not in active_visit_pairs: + raise ValueError( + f"population user {user_domain_id} has active_click_id without active visit" + ) + + +@dataclass +class GeneratorState: + """Состояние генератора для восстановления после рестарта.""" + + tick: int + rng_state: tuple + last_batch_id: str + last_timestamp: datetime + version: str = STATE_VERSION + model_timestamp: datetime | None = None + wall_timestamp: datetime | None = None + model_time_speed: float = 1.0 + model_timezone: str = "UTC" + model_t0: datetime | None = None + gen_seed: int | None = None + population: list[dict] = field(default_factory=list) + active_visits: list[dict] = field(default_factory=list) + pending_visit_births: float = 0.0 + + def __post_init__(self) -> None: + if self.model_timestamp is None: + self.model_timestamp = self._as_aware_utc(self.last_timestamp) + if self.wall_timestamp is None: + self.wall_timestamp = self._as_aware_utc(self.last_timestamp) + if self.model_t0 is None: + self.model_t0 = self.model_timestamp + + @staticmethod + def _as_aware_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def to_dict(self) -> dict: + """Конвертирует в словарь для JSON-сериализации.""" + return { + "tick": self.tick, + "rng_state": self.rng_state, + "last_batch_id": self.last_batch_id, + "last_timestamp": self.last_timestamp.isoformat(), + "model_timestamp": self.model_timestamp.isoformat(), + "wall_timestamp": self.wall_timestamp.isoformat(), + "model_time_speed": self.model_time_speed, + "model_timezone": self.model_timezone, + "model_t0": self.model_t0.isoformat(), + "gen_seed": self.gen_seed, + "version": self.version, + "population": self.population, + "active_visits": self.active_visits, + "pending_visit_births": self.pending_visit_births, + } + + @classmethod + def from_dict(cls, data: dict) -> "GeneratorState": + """Создаёт состояние из словаря.""" + try: + if not isinstance(data, dict): + raise ValueError("state must be an object") + version = str(data.get("version", "1.0")) + if version != STATE_VERSION: + raise UnsupportedStateVersionError(version) + _validate_v3_payload(data) + model_timestamp = _parse_aware_utc( + data["model_timestamp"], + "model_timestamp", + ) + wall_timestamp = _parse_aware_utc(data["wall_timestamp"], "wall_timestamp") + model_t0 = _parse_aware_utc(data["model_t0"], "model_t0") + + rng_state_raw = data.get("rng_state") + if not rng_state_raw: + logger.warning("State missing rng_state field") + raise ValueError("rng_state is missing") + + rng_state = _nested_list_to_tuple(rng_state_raw) + + if not isinstance(rng_state, tuple): + logger.warning(f"rng_state is not tuple: {type(rng_state)}") + raise ValueError("rng_state must be tuple") + + if len(rng_state) < 2: + logger.warning(f"rng_state has insufficient length: {len(rng_state)}") + raise ValueError("rng_state has insufficient length") + + test_rng = random.Random() + test_rng.setstate(rng_state) + + return cls( + tick=data.get("tick", 0), + rng_state=rng_state, + last_batch_id=data.get("last_batch_id", ""), + last_timestamp=datetime.fromisoformat( + data.get("last_timestamp", data["model_timestamp"]) + ), + version=version, + model_timestamp=model_timestamp, + wall_timestamp=wall_timestamp, + model_time_speed=float(data["model_time_speed"]), + model_timezone=data["model_timezone"], + model_t0=model_t0, + gen_seed=data["gen_seed"], + population=data.get("population", []), + active_visits=data.get("active_visits", []), + pending_visit_births=data.get("pending_visit_births", 0.0), + ) + except UnsupportedStateVersionError: + raise + except Exception as e: + logger.warning(f"Invalid state format, will start fresh: {e}") + raise ValueError(f"Invalid state: {e}") + + @classmethod + def from_dict_safe(cls, data: dict) -> "GeneratorState | None": + """Безопасная загрузка state с graceful degradation.""" + try: + return cls.from_dict(data) + except UnsupportedStateVersionError: + raise + except Exception: + return None diff --git a/generator/tests/conftest.py b/generator/tests/conftest.py new file mode 100644 index 0000000..a90b071 --- /dev/null +++ b/generator/tests/conftest.py @@ -0,0 +1,62 @@ +""" +Pytest fixtures для тестирования генератора. +""" + +import sys +from pathlib import Path + +GENERATOR_DIR = Path(__file__).parent.parent + +# Добавляем фасад generator.py и src-пакет в путь +sys.path.insert(0, str(GENERATOR_DIR)) +sys.path.insert(0, str(GENERATOR_DIR / "src")) + +import pytest +from generator import Config, EventDictionary + + +@pytest.fixture +def data_dir(): + """Путь к директории с тестовыми данными.""" + return Path(__file__).parent.parent.parent / "data" + + +@pytest.fixture +def event_dictionary(data_dir): + """Загруженный словарь событий.""" + return EventDictionary.load(data_dir) + + +@pytest.fixture +def base_config(data_dir): + """Базовая конфигурация для тестов.""" + return Config( + kafka_bootstrap_servers="localhost:9092", + tick_seconds=5, + lambda_base_per_min=30, + jitter_pct=20, + min_events_per_tick=1, + max_events_per_tick=50, + data_dir=data_dir, + seed=42, + enabled=True, + metrics_port=9109, + state_enabled=True, + state_reset=False, + ) + + +@pytest.fixture +def config_no_jitter(base_config): + """Конфигурация без jitter.""" + from dataclasses import replace + return replace(base_config, jitter_pct=0) + + +@pytest.fixture +def empty_temp_dir(tmp_path): + """Временная директория с пустыми JSONL файлами.""" + for fname in ["browser_events.jsonl", "location_events.jsonl", + "device_events.jsonl", "geo_events.jsonl"]: + (tmp_path / fname).touch() + return tmp_path diff --git a/generator/tests/test_airflow_control.py b/generator/tests/test_airflow_control.py new file mode 100644 index 0000000..931f926 --- /dev/null +++ b/generator/tests/test_airflow_control.py @@ -0,0 +1,293 @@ +""" +Тесты чистой логики пульта Airflow для генератора. +""" + +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def test_build_control_env_uses_profile_duration_and_airflow_data_dir(): + """Пульт строит env для backfill из профиля и каталога Airflow.""" + from clickstream_generator.airflow_control import build_control_env + + env = build_control_env( + "backfill", + profile_name="ci", + duration="", + artifact_path="/opt/airflow/data/startup-history.json", + overrides={"GEN_LAMBDA_BASE_PER_MIN": "120", "GEN_SEED": "99"}, + ) + + assert env["GEN_RUN_MODE"] == "backfill" + assert env["GEN_HISTORY_DURATION"] == "6h" + assert env["GEN_MODEL_T_END"] == "2026-01-01T06:00:00+00:00" + assert env["GEN_DATA_DIR"] == "/opt/airflow/data" + assert env["GEN_METRICS_ENABLED"] == "false" + assert env["GEN_STARTUP_HISTORY_ARTIFACT"] == "/opt/airflow/data/startup-history.json" + assert env["GEN_LAMBDA_BASE_PER_MIN"] == "120" + assert env["GEN_SEED"] == "99" + + +def test_import_env_requires_artifact_path_and_uses_backfill_contract(): + """Import валидируется как тот же мир, что backfill.""" + from clickstream_generator.airflow_control import build_control_env + + with pytest.raises(ValueError, match="artifact_path"): + build_control_env("import", profile_name="ci", artifact_path="") + + env = build_control_env( + "import", + profile_name="daily-wave", + artifact_path="/opt/airflow/data/history.json", + ) + + assert env["GEN_RUN_MODE"] == "backfill" + assert env["GEN_STATE_RESET"] == "true" + assert env["GEN_HISTORY_DURATION"] == "2d" + + +def test_next_day_env_uses_world_settings_and_current_manifest_boundary(): + """Next-day восстанавливает настройки мира из manifest, а не из формы DAG.""" + from clickstream_generator.airflow_control import build_next_day_env + + manifest = { + "gen_seed": 42, + "model_t0": "2026-01-01T00:00:00+00:00", + "model_t_end": "2026-01-03T00:00:00+00:00", + "model_timezone": "UTC", + "launch_profile": "daily-wave", + "generation_settings": { + "tick_seconds": 1, + "lambda_base_per_min": 60, + "jitter_pct": 0, + "min_events_per_tick": 1, + "max_events_per_tick": 1000, + "max_session_events": 30, + "max_active_sessions": 200, + "population_max": 300, + "p_new_user": 0.15, + "min_return_minutes": 30, + "model_time_speed": 60, + }, + } + + env = build_next_day_env(manifest) + + assert env["GEN_RUN_MODE"] == "next-day" + assert env["GEN_STATE_RESET"] == "false" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + assert env["GEN_MODEL_TIME_SPEED"] == "60" + assert env["GEN_MAX_SESSION_EVENTS"] == "30" + assert env["GEN_DATA_DIR"] == "/opt/airflow/data" + + +def test_next_day_env_map_covers_every_generation_setting(base_config): + """Новая настройка генерации не может потеряться между manifest и env.""" + from clickstream_generator.airflow_control import NEXT_DAY_SETTING_ENV_KEYS + from clickstream_generator.startup_history_artifact import ( + generation_settings_from_config, + ) + + assert set(NEXT_DAY_SETTING_ENV_KEYS) == set( + generation_settings_from_config(base_config) + ) + + +def test_next_day_precheck_rejects_stale_expected_boundary_with_both_values(): + """Повторный запуск с прежней границей падает до записи данных.""" + from clickstream_generator.airflow_control import assert_expected_t_end + + with pytest.raises(RuntimeError) as exc_info: + assert_expected_t_end( + "2026-01-03T00:00:00+00:00", + "2026-01-04T00:00:00+00:00", + ) + + message = str(exc_info.value) + assert "2026-01-03T00:00:00+00:00" in message + assert "2026-01-04T00:00:00+00:00" in message + + +def test_next_day_precheck_requires_manifest_and_matching_state(): + """Next-day громко отвергает пустой стенд и state не на T_end.""" + from clickstream_generator.airflow_control import assert_next_day_snapshot + from test_startup_history_artifact import _state + + state = _state() + with pytest.raises(RuntimeError, match="Manifest"): + assert_next_day_snapshot(None, state) + + manifest = { + "run_mode": "backfill", + "model_t0": state.model_t0.isoformat(), + "model_t_end": "2026-01-01T02:00:00+00:00", + "gen_seed": state.gen_seed, + "model_timezone": state.model_timezone, + "state_version": state.version, + "state": { + "last_batch_id": state.last_batch_id, + "model_timestamp": "2026-01-01T02:00:00+00:00", + }, + "generation_settings": {"model_time_speed": state.model_time_speed}, + } + with pytest.raises(RuntimeError, match="state.*T_end"): + assert_next_day_snapshot(manifest, state) + + +def test_target_dag_trigger_error_covers_missing_paused_and_ready_states(): + """Проверка зависимого DAG различает три реальные ветки.""" + from clickstream_generator.airflow_control import target_dag_trigger_error + + assert "ещё не найден" in target_dag_trigger_error("etl_pipeline", None) + assert "стоит на паузе" in target_dag_trigger_error( + "etl_pipeline", + SimpleNamespace(is_paused=True), + ) + assert target_dag_trigger_error( + "etl_pipeline", + SimpleNamespace(is_paused=False), + ) is None + + +def test_assert_stand_clean_rejects_non_empty_stg_before_writes(): + """Backfill/import не стартуют на непустом STG.""" + from clickstream_generator.airflow_control import assert_stg_tables_empty + + class Hook: + def execute(self, sql): + self.sql = sql + return [(0, 2, 0, 0)] + + hook = Hook() + + with pytest.raises(RuntimeError, match="make clean"): + assert_stg_tables_empty(hook) + assert "stg.browser_raw" in hook.sql + assert "stg.location_raw" in hook.sql + + +def test_assert_stand_clean_rejects_non_empty_kafka_with_make_clean_hint(monkeypatch): + """Непустые Kafka-топики дают ту же подсказку про make clean.""" + from clickstream_generator import airflow_control, stand_clean + + class Inspector: + def __init__(self, bootstrap_servers): + self.bootstrap_servers = bootstrap_servers + + def assert_data_topics_empty(self): + raise RuntimeError("Kafka data topics are not empty: browser_events") + + class Hook: + def execute(self, sql): + return [(0, 0, 0, 0)] + + monkeypatch.setattr(stand_clean, "KafkaTopicInspector", Inspector) + + with pytest.raises(RuntimeError, match="make clean"): + airflow_control.assert_stand_clean("kafka:29092", Hook()) + + +def test_shared_stand_clean_rejects_host_stg_counts(): + """Хостовая предпроверка использует ту же STG-границу, что пульт.""" + from clickstream_generator.stand_clean import assert_stg_counts_empty + + with pytest.raises(RuntimeError, match="stg.location_raw=3.*make clean"): + assert_stg_counts_empty((0, 3, 0, 0)) + + +def test_continue_rejects_dirty_stand_without_state(base_config, monkeypatch): + """Continue без state не стартует новый мир поверх грязного STG.""" + from clickstream_generator import stand_clean + + class StateManager: + def __init__(self, bootstrap_servers): + self.bootstrap_servers = bootstrap_servers + + def load(self): + return None + + def close(self): + pass + + class Inspector: + def __init__(self, bootstrap_servers): + self.bootstrap_servers = bootstrap_servers + + def assert_data_topics_empty(self): + pass + + monkeypatch.setattr(stand_clean, "KafkaStateManager", StateManager) + monkeypatch.setattr(stand_clean, "KafkaTopicInspector", Inspector) + + with pytest.raises(RuntimeError, match="stg.browser_raw=1.*make clean"): + stand_clean.assert_continue_has_state_or_clean_stand( + "localhost:9092", + (1, 0, 0, 0), + base_config, + ) + + +def test_check_manifest_compares_clickhouse_stats(base_config): + """Check падает, когда контрольные числа ClickHouse расходятся с manifest.""" + from clickstream_generator.airflow_control import assert_clickhouse_matches_manifest + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + ) + from test_startup_history_artifact import _batch, _state + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest( + config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ), + counters=builder.counters, + state=state, + ) + + class Hook: + def execute(self, sql): + self.sql = sql + return [( + 0, + 1, + 1, + "2026-01-01 00:00:00.000000", + "2026-01-01 00:00:00.000000", + )] + + with pytest.raises(RuntimeError, match="events"): + assert_clickhouse_matches_manifest(manifest, Hook()) + + +def test_generator_metrics_server_can_be_disabled(base_config, monkeypatch): + """Airflow-задача может запускать генератор без HTTP-сервера метрик.""" + from clickstream_generator.service import GeneratorService + + config = replace(base_config, enabled=False, metrics_enabled=False) + service = GeneratorService(config) + called = False + + def start_http_server(_port): + nonlocal called + called = True + + monkeypatch.setattr( + "clickstream_generator.service.start_http_server", + start_http_server, + ) + + service.start() + + assert called is False diff --git a/generator/tests/test_config.py b/generator/tests/test_config.py new file mode 100644 index 0000000..2d67531 --- /dev/null +++ b/generator/tests/test_config.py @@ -0,0 +1,138 @@ +""" +Тесты конфигурации генератора. +""" + +from pathlib import Path +from datetime import datetime, timedelta, timezone + +import pytest +from generator import Config + + +class TestConfigValidation: + """Тесты валидации конфигурации.""" + + def test_tick_seconds_must_be_positive(self, base_config): + """tick_seconds должен быть >= 1.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_TICK_SECONDS"): + replace(base_config, tick_seconds=0) + + def test_lambda_base_must_be_positive(self, base_config): + """lambda_base_per_min должен быть >= 1.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_LAMBDA_BASE_PER_MIN"): + replace(base_config, lambda_base_per_min=0) + + def test_max_session_events_must_be_positive(self, base_config): + """max_session_events должен быть >= 1.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_MAX_SESSION_EVENTS"): + replace(base_config, max_session_events=0) + + def test_max_active_sessions_must_be_less_than_population(self, base_config): + """Потолок активных визитов должен быть меньше потолка популяции.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_MAX_ACTIVE_SESSIONS"): + replace(base_config, max_active_sessions=10, population_max=10) + + def test_new_user_probability_must_be_valid_share(self, base_config): + """Вероятность нового пользователя должна быть долей от 0 до 1.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_P_NEW_USER"): + replace(base_config, p_new_user=1.5) + + def test_min_return_minutes_must_not_be_negative(self, base_config): + """Кулдаун возврата не может быть отрицательным.""" + from dataclasses import replace + with pytest.raises(ValueError, match="GEN_MIN_RETURN_MINUTES"): + replace(base_config, min_return_minutes=-1) + + def test_data_dir_must_exist(self, base_config): + """data_dir должен существовать.""" + from dataclasses import replace + with pytest.raises(ValueError, match="does not exist"): + replace(base_config, data_dir=Path("/nonexistent/path")) + + def test_valid_config_passes(self, base_config): + """Валидная конфигурация создается без ошибок.""" + assert base_config.tick_seconds == 5 + assert base_config.lambda_base_per_min == 30 + assert base_config.jitter_pct == 20 + assert base_config.max_active_sessions < base_config.population_max + + def test_model_t0_is_normalized_to_utc(self, base_config): + """model_t0 нормализуется к UTC даже при прямом создании Config.""" + from dataclasses import replace + + config = replace( + base_config, + model_t0=datetime( + 2026, + 1, + 1, + 13, + 0, + tzinfo=timezone(timedelta(hours=3)), + ), + ) + + assert config.model_t0 == datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + + +class TestConfigDefaults: + """Тесты значений по умолчанию.""" + + def test_default_lambda_base_per_min_is_calibrated_to_demo_scale( + self, monkeypatch, data_dir + ): + """По умолчанию целевая интенсивность равна 30 событиям в минуту.""" + monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) + monkeypatch.delenv("GEN_LAMBDA_BASE_PER_MIN", raising=False) + + config = Config() + + assert config.lambda_base_per_min == 30 + + def test_default_tick_seconds_is_5(self, data_dir): + """По умолчанию tick_seconds = 5 (rev5).""" + import os + # Сохраняем текущее значение + orig_value = os.environ.get("GEN_TICK_SECONDS") + try: + if "GEN_TICK_SECONDS" in os.environ: + del os.environ["GEN_TICK_SECONDS"] + config = Config( + kafka_bootstrap_servers="localhost:9092", + tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")), + lambda_base_per_min=30, + jitter_pct=20, + min_events_per_tick=1, + max_events_per_tick=50, + data_dir=data_dir, + seed=None, + enabled=True, + metrics_port=9109, + ) + assert config.tick_seconds == 5 + finally: + if orig_value is not None: + os.environ["GEN_TICK_SECONDS"] = orig_value + + def test_launch_profile_defaults_to_ci(self, monkeypatch, data_dir): + """По умолчанию выбран быстрый профиль запуска ci.""" + monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) + monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False) + + config = Config() + + assert config.launch_profile == "ci" + + def test_launch_profile_can_be_read_from_env(self, monkeypatch, data_dir): + """Выбранный профиль запуска попадает в конфигурацию для логов.""" + monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) + monkeypatch.setenv("GEN_LAUNCH_PROFILE", "daily-wave") + + config = Config() + + assert config.launch_profile == "daily-wave" diff --git a/generator/tests/test_generation.py b/generator/tests/test_generation.py new file mode 100644 index 0000000..efd78df --- /dev/null +++ b/generator/tests/test_generation.py @@ -0,0 +1,1339 @@ +""" +Тесты генерации событий. +""" + +import json +import random +import uuid +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest +from generator import ( + EventGenerator, + EventDictionary, + EXPECTED_VISIT_EVENTS, + TickStreamGenerator, + calculate_events_count, + generate_tick_batch, + hour_factor, +) +from clickstream_generator.runtime import _timestamp_to_state_offset + + +ALLOWED_PAGE_PATHS = { + "/home", + "/product_a", + "/product_b", + "/cart", + "/payment", + "/confirmation", +} + + +def _parse_event_timestamps(batch): + return [ + datetime.fromisoformat(event["event_timestamp"].replace(" ", "T")) + for event in batch["browser_events"] + ] + + +def _without_fields(batch, excluded_fields): + return { + topic: [ + { + key: value + for key, value in event.items() + if key not in excluded_fields + } + for event in events + ] + for topic, events in batch.items() + } + + +def _without_event_ids(batch): + return _without_fields(batch, {"event_id"}) + + +def _page_path_visits(generator, visits_count: int): + return [ + [ + event["page_url_path"] + for event in generator.generate_batch(30)["location_events"] + ] + for _ in range(visits_count) + ] + + +def _run_stream( + stream, + tick_at: datetime, + ticks_count: int, + event_budget: int, + tick_seconds: int, +): + events = [] + for tick_index in range(ticks_count): + batch = stream.generate_tick( + event_budget=event_budget, + tick_started_at=tick_at + timedelta(seconds=tick_seconds * tick_index), + ) + events.extend(batch["device_events"]) + return events + + +def _run_stream_batches( + stream, + tick_at: datetime, + ticks_count: int, + event_budget: int, + tick_seconds: int, +): + batches = [] + for tick_index in range(ticks_count): + batches.append( + stream.generate_tick( + event_budget=event_budget, + tick_started_at=tick_at + timedelta(seconds=tick_seconds * tick_index), + ) + ) + return batches + + +class TestEventGeneration: + """Тесты генерации событий.""" + + def test_event_dictionary_loads(self, event_dictionary): + """Словарь событий загружается корректно.""" + assert len(event_dictionary.browser_events) == 1000 + assert len(event_dictionary.location_events) == 1000 + assert len(event_dictionary.device_events) == 1000 + assert len(event_dictionary.geo_events) == 1000 + + def test_dictionary_consistency(self, event_dictionary): + """Все записи в словаре имеют корректные связи.""" + browser_event_ids = {e["event_id"] for e in event_dictionary.browser_events} + browser_click_ids = {e["click_id"] for e in event_dictionary.browser_events} + + location_orphaned = sum( + 1 for loc in event_dictionary.location_events + if loc["event_id"] not in browser_event_ids + ) + device_orphaned = sum( + 1 for dev in event_dictionary.device_events + if dev["click_id"] not in browser_click_ids + ) + geo_orphaned = sum( + 1 for geo in event_dictionary.geo_events + if geo["click_id"] not in browser_click_ids + ) + + assert location_orphaned == 0, f"Found {location_orphaned} orphaned location records" + assert device_orphaned == 0, f"Found {device_orphaned} orphaned device records" + assert geo_orphaned == 0, f"Found {geo_orphaned} orphaned geo records" + + def test_generate_batch_structure(self, event_dictionary, base_config): + """Батч имеет правильную структуру.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(10) + + assert "browser_events" in batch + assert "location_events" in batch + assert "device_events" in batch + assert "geo_events" in batch + + def test_generate_batch_respects_requested_visit_budget(self, event_dictionary, base_config): + """Визит не превышает запрошенный бюджет событий.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(10) + + browser_count = len(batch["browser_events"]) + + assert 1 <= browser_count <= 10 + assert len(batch["location_events"]) == browser_count + assert len(batch["device_events"]) == browser_count + assert len(batch["geo_events"]) == browser_count + + def test_generate_tick_batch_uses_requested_event_budget(self, event_dictionary, base_config): + """Тиковый батч трактует входное число как событийный бюджет.""" + generator = EventGenerator(event_dictionary, base_config) + + batch = generate_tick_batch(generator, 20) + + browser_count = len(batch["browser_events"]) + + assert 1 <= browser_count <= 20 + assert len(batch["location_events"]) == browser_count + assert len(batch["device_events"]) == browser_count + assert len(batch["geo_events"]) == browser_count + assert len({event["click_id"] for event in batch["browser_events"]}) == browser_count + + def test_tick_stream_keeps_long_window_intensity_near_event_budget( + self, event_dictionary, base_config + ): + """Длинное окно держит среднюю интенсивность около событийного бюджета.""" + config = replace( + base_config, + tick_seconds=5, + jitter_pct=0, + min_events_per_tick=17, + max_events_per_tick=17, + max_active_sessions=10_000, + population_max=10_001, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + ticks_count = 12 * 60 + total_events = 0 + + for tick_index in range(ticks_count): + batch = stream.generate_tick( + event_budget=17, + tick_started_at=tick_at + timedelta(seconds=5 * tick_index), + ) + total_events += len(batch["browser_events"]) + + events_per_minute = total_events / (ticks_count * config.tick_seconds / 60) + expected_events_per_minute = 17 * 60 / config.tick_seconds + + assert events_per_minute == pytest.approx(expected_events_per_minute, rel=0.10) + + def test_tick_stream_births_visits_from_average_visit_length_budget( + self, event_dictionary, base_config + ): + """Новый визит рождается после накопления бюджета средней длины визита.""" + config = replace( + base_config, + tick_seconds=60, + max_active_sessions=100, + population_max=101, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + partial_budget_tick = stream.generate_tick( + event_budget=9, + tick_started_at=tick_at, + ) + completed_budget_tick = stream.generate_tick( + event_budget=1, + tick_started_at=tick_at + timedelta(seconds=config.tick_seconds), + ) + + assert partial_budget_tick["browser_events"] == [] + assert len(completed_budget_tick["browser_events"]) == 1 + + def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config): + """Один визит может выпускать события в нескольких последовательных тиках.""" + config = replace(base_config, tick_seconds=30 * 60, max_session_events=5) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + first_tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=first_tick_at) + second_tick = stream.generate_tick( + event_budget=0, + tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds), + ) + + first_click_id = first_tick["browser_events"][0]["click_id"] + second_click_ids = { + event["click_id"] + for event in second_tick["browser_events"] + } + second_timestamps = _parse_event_timestamps(second_tick) + + assert first_click_id in second_click_ids + assert all( + timestamp < first_tick_at + timedelta(seconds=config.tick_seconds) + for timestamp in second_timestamps + ) + + def test_tick_stream_releases_only_matured_events(self, event_dictionary, base_config): + """Тик не выпускает будущие события активного визита.""" + generator = EventGenerator(event_dictionary, base_config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at) + + assert len(first_tick["browser_events"]) == 1 + assert same_time_tick["browser_events"] == [] + assert same_time_tick["location_events"] == [] + assert same_time_tick["device_events"] == [] + assert same_time_tick["geo_events"] == [] + + def test_tick_stream_does_not_reemit_finished_visit(self, event_dictionary, base_config): + """Завершённый визит больше не выпускает события в следующих тиках.""" + config = replace(base_config, max_session_events=2) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + final_tick = stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(hours=1), + ) + later_tick = stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(hours=2), + ) + + click_id = first_tick["browser_events"][0]["click_id"] + + assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id} + assert later_tick["browser_events"] == [] + + def test_tick_stream_state_roundtrip_keeps_population_and_active_visits( + self, event_dictionary, base_config + ): + """Снимок тикового слоя восстанавливает популяцию и активные визиты.""" + generator = EventGenerator(event_dictionary, base_config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict()))) + + restored_generator = EventGenerator(event_dictionary, base_config) + restored_stream = TickStreamGenerator(restored_generator) + restored_stream.restore_state(restored_state, restarted_at=tick_at) + + assert restored_stream.population_user_ids == stream.population_user_ids + assert restored_stream.active_visit_count == stream.active_visit_count + + def test_tick_stream_state_stays_compact_at_active_session_limit( + self, event_dictionary, base_config + ): + """State не хранит полные события активных визитов.""" + config = replace( + base_config, + max_session_events=30, + max_active_sessions=200, + population_max=300, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + stream.generate_tick( + event_budget=int(EXPECTED_VISIT_EVENTS * config.max_active_sessions), + tick_started_at=tick_at, + ) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + state_bytes = len(json.dumps(state.to_dict())) + + assert stream.active_visit_count == config.max_active_sessions + assert state_bytes < 250_000 + + def test_tick_stream_restored_after_short_idle_releases_due_original_timestamps( + self, event_dictionary, base_config + ): + """После короткого простоя активный визит продолжается со старыми метками.""" + config = replace(base_config, max_session_events=5) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + visit_state = state.active_visits[0] + original_visit = stream.active_visits[0] + started_at = datetime.fromisoformat(visit_state["started_at"]) + next_index = visit_state["next_index"] + planned_timestamps = { + (started_at + timedelta(microseconds=offset_us)).strftime( + "%Y-%m-%d %H:%M:%S.%f" + ) + for offset_us in visit_state["offsets_us"] + } + + restored_generator = EventGenerator(event_dictionary, config) + restored_stream = TickStreamGenerator(restored_generator) + restored_stream.restore_state( + type(state).from_dict(json.loads(json.dumps(state.to_dict()))), + restarted_at=tick_at + timedelta(minutes=29), + ) + + resumed = restored_stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(minutes=29), + ) + resumed_timestamps = [ + event["event_timestamp"] + for event in resumed["browser_events"] + ] + + assert resumed_timestamps + assert set(resumed_timestamps).issubset(planned_timestamps) + assert {event["click_id"] for event in resumed["browser_events"]} == { + visit_state["click_id"] + } + assert [ + event["page_url_path"] + for event in resumed["location_events"] + ] == visit_state["page_url_paths"][next_index:next_index + len(resumed_timestamps)] + assert resumed["device_events"] == original_visit.batch["device_events"][ + next_index:next_index + len(resumed_timestamps) + ] + assert resumed["geo_events"] == original_visit.batch["geo_events"][ + next_index:next_index + len(resumed_timestamps) + ] + assert all( + datetime.fromisoformat(timestamp.replace(" ", "T")) <= tick_at + timedelta(minutes=29) + for timestamp in resumed_timestamps + ) + + def test_tick_stream_restored_visit_keeps_per_event_fixture( + self, event_dictionary, base_config + ): + """Восстановленный визит продолжает ту же фактуру событий.""" + config = replace(base_config, max_session_events=8) + tick_at = datetime(2026, 6, 11, 12, 0) + resume_at = tick_at + timedelta(hours=2) + + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict()))) + + restored_generator = EventGenerator(event_dictionary, config) + restored_stream = TickStreamGenerator(restored_generator) + restored_stream.restore_state(restored_state, resume_model_at=tick_at) + + uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at) + restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at) + + assert restored["browser_events"] + assert _without_event_ids(restored) == _without_event_ids(uninterrupted) + + def test_tick_stream_restored_visit_keeps_cyclic_fallback_fixture( + self, event_dictionary, base_config + ): + """Короткий донор фактуры после восстановления повторяется так же.""" + config = replace(base_config, max_session_events=30) + tick_at = datetime(2026, 6, 11, 12, 0) + resume_at = tick_at + timedelta(hours=12) + + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + stream.generate_tick(event_budget=100, tick_started_at=tick_at) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + assert any( + len(event_dictionary.browser_by_click_id[visit["base_click_id"]]) + < len(visit["offsets_us"]) + for visit in state.active_visits + ) + restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict()))) + + restored_generator = EventGenerator(event_dictionary, config) + restored_stream = TickStreamGenerator(restored_generator) + restored_stream.restore_state(restored_state, resume_model_at=tick_at) + + uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at) + restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at) + + assert restored["browser_events"] + assert _without_event_ids(restored) == _without_event_ids(uninterrupted) + + def test_state_offset_preserves_microseconds_without_float_rounding(self): + """Смещение state не теряет микросекунду на float-округлении.""" + started_at = datetime(2026, 6, 11, 12, 0, 0) + timestamp = datetime(2026, 6, 11, 12, 8, 34, 130779) + + assert _timestamp_to_state_offset(started_at, timestamp) == 514_130_779 + + def test_tick_stream_restore_rejects_unknown_fixture_donor( + self, event_dictionary, base_config + ): + """Неизвестный донор фактуры в state даёт ошибку восстановления.""" + config = replace(base_config, max_session_events=5) + tick_at = datetime(2026, 6, 11, 12, 0) + + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + state_data = json.loads(json.dumps(state.to_dict())) + state_data["active_visits"][0]["base_click_id"] = "missing-click-id" + restored_state = type(state).from_dict(state_data) + + restored_generator = EventGenerator(event_dictionary, config) + restored_stream = TickStreamGenerator(restored_generator) + + with pytest.raises(ValueError, match="Unknown fixture base_click_id"): + restored_stream.restore_state(restored_state, resume_model_at=tick_at) + + def test_tick_stream_restored_after_long_idle_closes_overdue_visit_without_replay( + self, event_dictionary, base_config + ): + """После долгого простоя просроченный визит закрывается без досылки.""" + config = replace(base_config, max_session_events=5) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + stream.generate_tick(event_budget=10, tick_started_at=tick_at) + population_ids = stream.population_user_ids + state = stream.to_state( + tick=1, + rng_state=generator.rng.getstate(), + last_batch_id="batch-1", + last_timestamp=tick_at, + ) + + restored_generator = EventGenerator(event_dictionary, config) + restored_stream = TickStreamGenerator(restored_generator) + restored_stream.restore_state( + type(state).from_dict(json.loads(json.dumps(state.to_dict()))), + restarted_at=tick_at + timedelta(hours=1), + ) + + resumed = restored_stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(hours=1), + ) + restored_user = next( + user for user in restored_stream.population.users + if user.user_domain_id == state.active_visits[0]["user_domain_id"] + ) + last_sent_at = datetime.fromisoformat(state.active_visits[0]["started_at"]) + + assert resumed["browser_events"] == [] + assert restored_stream.active_visit_count == 0 + assert restored_stream.population_user_ids == population_ids + assert restored_user.last_finished_at == last_sent_at + + def test_tick_stream_drops_births_when_active_limit_is_reached( + self, event_dictionary, base_config + ): + """При заполненном потолке новые рождения пропускаются без накопления бюджета.""" + config = replace( + base_config, + max_session_events=2, + max_active_sessions=1, + population_max=2, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + blocked_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + final_tick = stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(hours=1), + ) + later_tick = stream.generate_tick( + event_budget=0, + tick_started_at=tick_at + timedelta(hours=2), + ) + + click_id = first_tick["browser_events"][0]["click_id"] + + assert len(first_tick["browser_events"]) == 1 + assert blocked_tick["browser_events"] == [] + assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id} + assert later_tick["browser_events"] == [] + + def test_tick_stream_reuses_users_across_visits(self, event_dictionary, base_config): + """Длинная симуляция даёт пирамиду users < sessions < events.""" + config = replace( + base_config, + tick_seconds=60, + max_session_events=3, + max_active_sessions=50, + population_max=60, + p_new_user=0, + min_return_minutes=0, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + + events = _run_stream( + stream=stream, + tick_at=datetime(2026, 6, 11, 12, 0), + ticks_count=240, + event_budget=6, + tick_seconds=config.tick_seconds, + ) + users_by_session = {} + for event in events: + users_by_session.setdefault(event["click_id"], set()).add(event["user_domain_id"]) + + unique_users = {event["user_domain_id"] for event in events} + repeated_users = [ + user_id + for user_id in unique_users + if len({ + event["click_id"] + for event in events + if event["user_domain_id"] == user_id + }) > 1 + ] + + assert len(unique_users) < len(users_by_session) < len(events) + assert repeated_users + assert all(len(user_ids) == 1 for user_ids in users_by_session.values()) + + def test_tick_stream_new_user_share_matches_config_after_warmup( + self, event_dictionary, base_config + ): + """Доля новых пользователей в потоке близка к GEN_P_NEW_USER.""" + config = replace( + base_config, + tick_seconds=60, + jitter_pct=0, + min_events_per_tick=30, + max_events_per_tick=30, + lambda_base_per_min=30, + max_active_sessions=200, + population_max=300, + p_new_user=0.15, + min_return_minutes=30, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + warmup_ticks = 240 + measure_ticks = 480 + seen_users = set() + seen_sessions = set() + total_sessions = 0 + new_user_sessions = 0 + + for tick_index in range(warmup_ticks + measure_ticks): + batch = stream.generate_tick( + event_budget=30, + tick_started_at=tick_at + timedelta(seconds=60 * tick_index), + ) + users_by_session = { + event["click_id"]: event["user_domain_id"] + for event in batch["device_events"] + } + for click_id, user_id in users_by_session.items(): + if click_id in seen_sessions: + continue + seen_sessions.add(click_id) + if tick_index >= warmup_ticks: + total_sessions += 1 + if user_id not in seen_users: + new_user_sessions += 1 + seen_users.add(user_id) + + new_user_share = new_user_sessions / total_sessions + + assert new_user_share == pytest.approx(config.p_new_user, rel=0.35) + + def test_tick_stream_replays_same_flow_with_same_seed( + self, event_dictionary, base_config + ): + """Одинаковый seed даёт одинаковые решения популяции и визитов.""" + config = replace( + base_config, + tick_seconds=60, + max_session_events=3, + max_active_sessions=5, + population_max=6, + p_new_user=0, + min_return_minutes=0, + ) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_stream = TickStreamGenerator(EventGenerator(event_dictionary, config)) + second_stream = TickStreamGenerator(EventGenerator(event_dictionary, config)) + + first_events = [] + second_events = [] + for tick_index in range(10): + tick_time = tick_at + timedelta(seconds=config.tick_seconds * tick_index) + first_batch = first_stream.generate_tick( + event_budget=3, + tick_started_at=tick_time, + ) + second_batch = second_stream.generate_tick( + event_budget=3, + tick_started_at=tick_time, + ) + first_events.extend(first_batch["device_events"]) + second_events.extend(second_batch["device_events"]) + + first_decisions = [ + (event["click_id"], event["user_domain_id"]) + for event in first_events + ] + second_decisions = [ + (event["click_id"], event["user_domain_id"]) + for event in second_events + ] + + assert first_decisions == second_decisions + + def test_returning_user_waits_for_cooldown_after_visit_end( + self, event_dictionary, base_config + ): + """Пользователь не получает новый визит раньше кулдауна возврата.""" + config = replace( + base_config, + tick_seconds=60, + max_session_events=3, + max_active_sessions=5, + population_max=20, + p_new_user=0, + min_return_minutes=30, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + + batches = _run_stream_batches( + stream=stream, + tick_at=datetime(2026, 6, 11, 12, 0), + ticks_count=240, + event_budget=1, + tick_seconds=config.tick_seconds, + ) + users_by_session = {} + times_by_session = {} + for batch in batches: + for device_event in batch["device_events"]: + users_by_session.setdefault( + device_event["click_id"], + device_event["user_domain_id"], + ) + for browser_event in batch["browser_events"]: + times_by_session.setdefault(browser_event["click_id"], []).append( + datetime.fromisoformat( + browser_event["event_timestamp"].replace(" ", "T") + ) + ) + + sessions_by_user = {} + for click_id, timestamps in times_by_session.items(): + sessions_by_user.setdefault(users_by_session[click_id], []).append( + (min(timestamps), max(timestamps)) + ) + + repeated_users = [ + sessions + for sessions in sessions_by_user.values() + if len(sessions) > 1 + ] + + assert repeated_users + for sessions in repeated_users: + ordered_sessions = sorted(sessions) + for previous, current in zip(ordered_sessions, ordered_sessions[1:]): + previous_end = previous[1] + current_start = current[0] + assert current_start - previous_end >= timedelta( + minutes=config.min_return_minutes + ) + + def test_returning_user_pause_average_matches_model_scale( + self, event_dictionary, base_config + ): + """Средняя пауза между визитами одного пользователя имеет часовой масштаб.""" + config = replace( + base_config, + tick_seconds=60, + jitter_pct=0, + min_events_per_tick=30, + max_events_per_tick=30, + lambda_base_per_min=30, + max_active_sessions=200, + population_max=300, + p_new_user=0.15, + min_return_minutes=30, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + + batches = _run_stream_batches( + stream=stream, + tick_at=datetime(2026, 6, 11, 12, 0), + ticks_count=16 * 60, + event_budget=30, + tick_seconds=config.tick_seconds, + ) + users_by_session = {} + times_by_session = {} + for batch in batches: + for device_event in batch["device_events"]: + users_by_session.setdefault( + device_event["click_id"], + device_event["user_domain_id"], + ) + for browser_event in batch["browser_events"]: + times_by_session.setdefault(browser_event["click_id"], []).append( + datetime.fromisoformat( + browser_event["event_timestamp"].replace(" ", "T") + ) + ) + + sessions_by_user = {} + for click_id, timestamps in times_by_session.items(): + sessions_by_user.setdefault(users_by_session[click_id], []).append( + (min(timestamps), max(timestamps)) + ) + + pauses_minutes = [] + visit_durations_minutes = [] + for sessions in sessions_by_user.values(): + ordered_sessions = sorted(sessions) + visit_durations_minutes.extend( + (finished_at - started_at).total_seconds() / 60 + for started_at, finished_at in ordered_sessions + ) + for previous, current in zip(ordered_sessions, ordered_sessions[1:]): + pauses_minutes.append((current[0] - previous[1]).total_seconds() / 60) + + mean_pause_minutes = sum(pauses_minutes) / len(pauses_minutes) + mean_visit_duration_minutes = ( + sum(visit_durations_minutes) / len(visit_durations_minutes) + ) + expected_pause_minutes = ( + config.population_max + / ( + config.lambda_base_per_min + / EXPECTED_VISIT_EVENTS + * (1 - config.p_new_user) + ) + - mean_visit_duration_minutes + ) + + assert min(pauses_minutes) >= config.min_return_minutes + assert mean_pause_minutes == pytest.approx(expected_pause_minutes, rel=0.30) + + def test_visit_cooldown_starts_from_planned_last_event_time( + self, event_dictionary, base_config + ): + """Кулдаун считается от запланированного конца визита, а не от позднего тика.""" + config = replace( + base_config, + tick_seconds=60, + max_session_events=3, + max_active_sessions=1, + population_max=2, + p_new_user=0, + min_return_minutes=30, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + user_id = first_tick["device_events"][0]["user_domain_id"] + planned_visit_end = stream.active_visits[0].timestamps[-1] + + stream.generate_tick( + event_budget=0, + tick_started_at=planned_visit_end + timedelta(hours=1), + ) + user = next( + user + for user in stream.population.users + if user.user_domain_id == user_id + ) + + assert user.last_finished_at == planned_visit_end + + def test_tick_stream_creates_new_user_when_no_returning_user_is_available( + self, event_dictionary, base_config + ): + """Если все прежние пользователи в кулдауне, новый визит получает нового пользователя.""" + config = replace( + base_config, + tick_seconds=60, + max_session_events=2, + max_active_sessions=1, + population_max=2, + p_new_user=0, + min_return_minutes=240, + ) + generator = EventGenerator(event_dictionary, config) + stream = TickStreamGenerator(generator) + tick_at = datetime(2026, 6, 11, 12, 0) + + first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) + second_tick = stream.generate_tick( + event_budget=10, + tick_started_at=tick_at + timedelta(hours=1), + ) + third_tick = stream.generate_tick( + event_budget=10, + tick_started_at=tick_at + timedelta(hours=2), + ) + + first_two_users = { + event["user_domain_id"] + for event in first_tick["device_events"] + second_tick["device_events"] + } + later_users = { + event["user_domain_id"] + for event in third_tick["device_events"] + } + first_user = first_tick["device_events"][0]["user_domain_id"] + second_new_users = first_two_users - {first_user} + new_users = later_users - first_two_users + + assert new_users + assert stream.population_size <= config.population_max + assert first_user not in stream.population_user_ids + assert second_new_users <= stream.population_user_ids + assert new_users <= stream.population_user_ids + + def test_generate_batch_creates_one_connected_visit(self, event_dictionary, base_config): + """Публичный вызов генератора создаёт один связанный визит.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(3) + + original_event_ids = {e["event_id"] for e in event_dictionary.browser_events} + original_click_ids = {e["click_id"] for e in event_dictionary.browser_events} + + browser_events = batch["browser_events"] + location_events = batch["location_events"] + device_events = batch["device_events"] + geo_events = batch["geo_events"] + + click_ids = {event["click_id"] for event in browser_events} + event_ids = [event["event_id"] for event in browser_events] + + assert len(browser_events) > 1 + assert len(click_ids) == 1 + click_id = next(iter(click_ids)) + assert click_id not in original_click_ids + uuid.UUID(click_id) + + assert len(set(event_ids)) == len(event_ids) + assert all(event_id not in original_event_ids for event_id in event_ids) + for event_id in event_ids: + uuid.UUID(event_id) + + assert {event["event_id"] for event in location_events} == set(event_ids) + assert {event["click_id"] for event in device_events} == {click_id} + assert {event["click_id"] for event in geo_events} == {click_id} + + device_context = [{k: v for k, v in event.items() if k != "click_id"} for event in device_events] + geo_context = [{k: v for k, v in event.items() if k != "click_id"} for event in geo_events] + assert len({event["user_domain_id"] for event in device_events}) == 1 + assert all(context == device_context[0] for context in device_context) + assert all(context == geo_context[0] for context in geo_context) + + def test_generate_batch_creates_multi_event_visit_when_budget_allows( + self, event_dictionary, base_config + ): + """Минимальный связанный визит не схлопывается в одно событие.""" + config = replace(base_config, seed=2) + generator = EventGenerator(event_dictionary, config) + + batch = generator.generate_batch(3) + + assert len(batch["browser_events"]) >= 2 + + def test_event_ids_are_new_uuids(self, event_dictionary, base_config): + """event_id и click_id — новые UUID, не из оригинальных данных.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(1) + + original_event_ids = {e["event_id"] for e in event_dictionary.browser_events} + original_click_ids = {e["click_id"] for e in event_dictionary.browser_events} + + browser_event = batch["browser_events"][0] + assert browser_event["event_id"] not in original_event_ids + assert browser_event["click_id"] not in original_click_ids + + # Проверяем, что это валидный UUID + uuid.UUID(browser_event["event_id"]) + uuid.UUID(browser_event["click_id"]) + + def test_event_timestamp_format(self, event_dictionary, base_config): + """event_timestamp имеет правильный формат.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(1) + + browser_event = batch["browser_events"][0] + timestamp = browser_event["event_timestamp"] + + # Должен парситься как datetime + dt = datetime.fromisoformat(timestamp.replace(" ", "T")) + assert dt.year >= 2024 + + def test_visit_event_timestamps_strictly_increase(self, event_dictionary, base_config): + """Время событий внутри одного визита строго возрастает.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(8) + + timestamps = _parse_event_timestamps(batch) + + assert all( + previous < current + for previous, current in zip(timestamps, timestamps[1:]) + ) + + def test_visit_event_timestamps_use_planned_user_pauses(self, event_dictionary, base_config): + """Метки времени визита разделены пользовательскими паузами, а не временем цикла.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(8) + + timestamps = _parse_event_timestamps(batch) + pauses_seconds = [ + (current - previous).total_seconds() + for previous, current in zip(timestamps, timestamps[1:]) + ] + + assert min(pauses_seconds) >= 1.0 + + def test_visit_path_uses_known_funnel_pages(self, event_dictionary, base_config): + """Путь визита состоит из страниц воронки.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(30) + + page_paths = [event["page_url_path"] for event in batch["location_events"]] + + assert page_paths + assert set(page_paths) <= ALLOWED_PAGE_PATHS + + def test_visit_starts_from_calibrated_start_distribution(self, event_dictionary, base_config): + """Визиты стартуют не только с /home, а по стартовому распределению.""" + generator = EventGenerator(event_dictionary, base_config) + + visits = _page_path_visits(generator, 1000) + first_pages = [visit[0] for visit in visits] + home_share = first_pages.count("/home") / len(first_pages) + product_entry_share = ( + first_pages.count("/product_a") + first_pages.count("/product_b") + ) / len(first_pages) + + assert 0.54 <= home_share <= 0.64 + assert product_entry_share >= 0.25 + + def test_visit_length_is_capped_by_session_limit(self, event_dictionary, base_config): + """Длина визита ограничена потолком, который защищает от петель.""" + config = replace(base_config, max_session_events=4) + generator = EventGenerator(event_dictionary, config) + + visits = _page_path_visits(generator, 200) + + assert max(len(visit) for visit in visits) <= 4 + assert any(len(visit) == 4 for visit in visits) + + def test_visit_length_distribution_matches_seed_scale( + self, event_dictionary, base_config + ): + """Длина визита сопоставима с сидом: медиана и среднее около 10.""" + generator = EventGenerator(event_dictionary, base_config) + + visits = _page_path_visits(generator, 2000) + visit_lengths = sorted(len(visit) for visit in visits) + median_length = visit_lengths[len(visit_lengths) // 2] + mean_length = sum(visit_lengths) / len(visit_lengths) + + assert 8 <= median_length <= 12 + assert mean_length == pytest.approx(EXPECTED_VISIT_EVENTS, rel=0.10) + assert max(visit_lengths) <= base_config.max_session_events + + def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config): + """Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут.""" + generator = EventGenerator(event_dictionary, base_config) + pauses_seconds = [] + + for _ in range(500): + timestamps = _parse_event_timestamps(generator.generate_batch(30)) + pauses_seconds.extend( + (current - previous).total_seconds() + for previous, current in zip(timestamps, timestamps[1:]) + ) + + pauses_seconds.sort() + p95 = pauses_seconds[int(len(pauses_seconds) * 0.95)] + + assert max(pauses_seconds) < 30 * 60 + assert p95 < 5 * 60 + + def test_confirmation_share_matches_seed_scale(self, event_dictionary, base_config): + """Около четверти визитов доходят до /confirmation.""" + generator = EventGenerator(event_dictionary, base_config) + + visits = _page_path_visits(generator, 1000) + confirmation_share = ( + sum("/confirmation" in visit for visit in visits) / len(visits) + ) + + assert 0.20 <= confirmation_share <= 0.30 + + def test_visit_funnel_monotonically_fades(self, event_dictionary, base_config): + """Воронка по визитам монотонно затухает от главной до подтверждения.""" + generator = EventGenerator(event_dictionary, base_config) + + visits = _page_path_visits(generator, 2000) + home_visits = sum("/home" in visit for visit in visits) + product_visits = sum( + "/product_a" in visit or "/product_b" in visit + for visit in visits + ) + cart_visits = sum("/cart" in visit for visit in visits) + payment_visits = sum("/payment" in visit for visit in visits) + confirmation_visits = sum("/confirmation" in visit for visit in visits) + + assert home_visits >= product_visits >= cart_visits + assert cart_visits >= payment_visits >= confirmation_visits + + def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config): + """/confirmation не обязан быть последним событием визита.""" + generator = EventGenerator(event_dictionary, base_config) + + visits = _page_path_visits(generator, 1000) + + assert any( + page_path == "/confirmation" and index < len(visit) - 1 + for visit in visits + for index, page_path in enumerate(visit) + ) + + def test_links_consistency(self, event_dictionary, base_config): + """Связи между событиями сохраняются.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(5) + + for i, browser in enumerate(batch["browser_events"]): + event_id = browser["event_id"] + click_id = browser["click_id"] + + # Location должен иметь тот же event_id + assert batch["location_events"][i]["event_id"] == event_id + + # Device и Geo должны иметь тот же click_id + assert batch["device_events"][i]["click_id"] == click_id + assert batch["geo_events"][i]["click_id"] == click_id + + def test_required_fields_present(self, event_dictionary, base_config): + """Все обязательные поля присутствуют в событиях.""" + generator = EventGenerator(event_dictionary, base_config) + batch = generator.generate_batch(1) + + browser = batch["browser_events"][0] + required_fields = [ + "event_id", "event_timestamp", "event_type", "click_id", + "browser_name", "browser_user_agent", "browser_language" + ] + + for field in required_fields: + assert field in browser, f"Missing field: {field}" + + +class TestPoissonDistribution: + """Тесты статистической модели.""" + + def test_hour_factor_uses_model_timezone(self): + """Дневной коэффициент считается по заданному часовому поясу модели.""" + assert hour_factor( + datetime(2026, 1, 1, 2, 30, tzinfo=timezone.utc), + "Europe/Moscow", + ) == 0.7 + assert hour_factor( + datetime(2026, 1, 1, 6, 30, tzinfo=timezone.utc), + "Europe/Moscow", + ) == 1.2 + + def test_event_budget_mean_follows_lambda_and_hour_factor(self, base_config): + """Средний событийный бюджет следует λ и часовому коэффициенту.""" + config = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=30, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=100, + model_t0=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + model_timezone="UTC", + ) + rng = random.Random(config.seed) + + samples = [calculate_events_count(config, rng) for _ in range(1000)] + mean_budget = sum(samples) / len(samples) + + assert mean_budget == pytest.approx(30 * 1.2, rel=0.15) + + def test_default_tick_budget_floor_does_not_outgrow_target_lambda(self, base_config): + """Дефолтная нижняя граница бюджета не разгоняет lambda=30 на тике 5 секунд.""" + config = replace( + base_config, + tick_seconds=5, + lambda_base_per_min=30, + jitter_pct=0, + max_events_per_tick=50, + model_t0=datetime(2026, 1, 1, 7, 0, tzinfo=timezone.utc), + model_timezone="UTC", + ) + rng = random.Random(config.seed) + + samples = [calculate_events_count(config, rng) for _ in range(1000)] + events_per_minute = sum(samples) / len(samples) * 60 / config.tick_seconds + + assert events_per_minute == pytest.approx(config.lambda_base_per_min, rel=0.20) + + def test_event_budget_uses_model_tick_duration(self, base_config): + """При ускорении событийный бюджет растёт по модельной длительности тика.""" + base = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=30, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=10_000, + model_time_speed=1, + ) + accelerated = replace(base, model_time_speed=10) + model_tick_at = datetime(2026, 1, 1, 7, 0) + + normal_rng = random.Random(base.seed) + accelerated_rng = random.Random(accelerated.seed) + normal_samples = [ + calculate_events_count(base, normal_rng, now=model_tick_at) + for _ in range(300) + ] + accelerated_samples = [ + calculate_events_count(accelerated, accelerated_rng, now=model_tick_at) + for _ in range(300) + ] + + normal_mean = sum(normal_samples) / len(normal_samples) + accelerated_mean = sum(accelerated_samples) / len(accelerated_samples) + + assert accelerated_mean / normal_mean == pytest.approx(10, rel=0.15) + + def test_large_event_budget_does_not_stick_on_knuth_underflow(self, base_config): + """Для λ > 1000 средний бюджет растёт вместе с целевой интенсивностью.""" + config = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=1200, + jitter_pct=0, + min_events_per_tick=0, + max_events_per_tick=10_000, + model_t0=datetime(2026, 1, 1, 7, 0, tzinfo=timezone.utc), + model_timezone="UTC", + ) + rng = random.Random(config.seed) + + samples = [calculate_events_count(config, rng) for _ in range(500)] + mean_budget = sum(samples) / len(samples) + + assert mean_budget == pytest.approx(1200, rel=0.05) + assert mean_budget > 1000 + + def test_event_generator_hour_factor_defaults_to_model_t0( + self, event_dictionary, base_config + ): + """Wrapper без аргумента берёт модельную точку, а не настенный час.""" + config = replace( + base_config, + model_t0=datetime(2026, 1, 1, 6, 30, tzinfo=timezone.utc), + model_timezone="Europe/Moscow", + ) + generator = EventGenerator(event_dictionary, config) + + assert generator._hour_factor() == 1.2 + + def test_calculate_events_respects_bounds(self, event_dictionary, base_config): + """Расчет количества событий уважает границы.""" + generator = EventGenerator(event_dictionary, base_config) + + samples = [generator._calculate_events_count() for _ in range(100)] + + assert all(s >= base_config.min_events_per_tick for s in samples) + assert all(s <= base_config.max_events_per_tick for s in samples) + + def test_jitter_increases_variance(self, event_dictionary, base_config): + """Jitter увеличивает дисперсию.""" + config_with_jitter = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=30, + jitter_pct=50, + min_events_per_tick=1, + max_events_per_tick=100, + model_t0=datetime(2026, 1, 1, 7, 0, tzinfo=timezone.utc), + model_timezone="UTC", + ) + config_without_jitter = replace(config_with_jitter, jitter_pct=0) + gen_with = EventGenerator(event_dictionary, config_with_jitter) + gen_without = EventGenerator(event_dictionary, config_without_jitter) + + samples_with = [gen_with._calculate_events_count() for _ in range(200)] + samples_without = [gen_without._calculate_events_count() for _ in range(200)] + + mean_with = sum(samples_with) / len(samples_with) + mean_without = sum(samples_without) / len(samples_without) + + var_with = sum((x - mean_with) ** 2 for x in samples_with) / len(samples_with) + var_without = sum((x - mean_without) ** 2 for x in samples_without) / len(samples_without) + + assert var_with > var_without, \ + f"Jitter should increase variance: {var_with} vs {var_without}" + + def test_mean_is_reasonable(self, event_dictionary, base_config): + """Среднее значение в разумных пределах.""" + config = replace( + base_config, + jitter_pct=0, + min_events_per_tick=1, + ) + generator = EventGenerator(event_dictionary, config) + + samples = [generator._calculate_events_count() for _ in range(500)] + mean = sum(samples) / len(samples) + + # Ожидаем: lambda_base * tick_seconds / 60 * hour_factor + # hour_factor обычно 0.7-1.2 + expected_base = config.lambda_base_per_min * config.tick_seconds / 60.0 + + # Допустимое отклонение до 50% + assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})" + assert mean < expected_base * 1.5, f"Mean {mean} too high (expected ~{expected_base})" + + +class TestEmptyData: + """Тесты обработки пустых данных.""" + + def test_empty_jsonl_raises_error(self, empty_temp_dir): + """Пустые JSONL файлы вызывают ValueError при загрузке.""" + with pytest.raises(ValueError, match="browser_events.jsonl is empty"): + EventDictionary.load(empty_temp_dir) diff --git a/generator/tests/test_generator_control_dag_contract.py b/generator/tests/test_generator_control_dag_contract.py new file mode 100644 index 0000000..3412034 --- /dev/null +++ b/generator/tests/test_generator_control_dag_contract.py @@ -0,0 +1,282 @@ +""" +Контракт DAG generator_control без запуска Airflow. +""" + +import ast +from pathlib import Path + +from clickstream_generator.launch import PROFILES + + +DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "generator_control_dag.py" +REPO_ROOT = Path(__file__).parents[2] + + +def _tree(): + return ast.parse(DAG_PATH.read_text(encoding="utf-8")) + + +def _declared_param_types() -> dict[str, set[str]]: + param_types = {} + for node in ast.walk(_tree()): + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values, strict=True): + if not ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "Param" + ): + continue + type_keyword = next( + keyword.value for keyword in value.keywords if keyword.arg == "type" + ) + if isinstance(type_keyword, ast.List): + param_types[key.value] = { + item.value for item in type_keyword.elts if isinstance(item, ast.Constant) + } + else: + param_types[key.value] = {type_keyword.value} + return param_types + + +def test_dag_file_exists_and_uses_dynamic_profiles(): + """DAG берёт варианты профилей из PROFILES, а не из ручного списка.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert "dag_id=\"generator_control\"" in text + assert "sorted(PROFILES)" in text + for profile in PROFILES: + assert profile not in {"hardcoded-profile"} + + +def test_trigger_form_has_expected_param_enums(): + """Форма запуска ограничивает операции и профили.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert 'enum=["backfill", "import", "next-day", "check"]' in text + assert "enum=sorted(PROFILES)" in text + assert '"duration": Param(' in text + assert '"artifact_path": Param(' in text + assert '"expected_t_end": Param(' in text + + +def test_trigger_form_marks_only_optional_params_as_nullable(): + """Форма разрешает оставить необязательные поля пустыми.""" + param_types = _declared_param_types() + + for name in { + "duration", + "seed", + "model_time_speed", + "artifact_path", + "expected_t_end", + }: + assert "null" in param_types[name] + for name in {"operation", "profile"}: + assert "null" not in param_types[name] + + +def test_dag_branches_and_waits_for_etl_completion(): + """Backfill/import/next-day запускают ETL и ждут завершения перед check.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert "BranchPythonOperator" in text + assert "TriggerDagRunOperator" in text + assert "trigger_dag_id=ETL_DAG_ID" in text + assert "wait_for_completion=True" in text + assert 'allowed_states=["success"]' in text + assert 'failed_states=["failed"]' in text + + +def test_next_day_has_own_boundary_precheck_and_serial_execution(): + """Next-day не использует clean-guard и не допускает параллельных запусков.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert "schedule=None" in text + assert "max_active_runs=1" in text + assert 'return "check_etl_not_paused_before_next_day"' in text + assert "assert_next_day_snapshot" in text + assert "assert_expected_t_end" in text + next_day_precheck = text.split("def precheck_next_day", maxsplit=1)[1].split( + "\ndef ", maxsplit=1 + )[0] + assert "assert_stand_clean" not in next_day_precheck + assert "assert_live_generator_not_running" in next_day_precheck + assert "run_next_day" in text + + +def test_generator_control_prechecks_etl_dag_not_paused_before_waiting(): + """Пульт проверяет паузу etl_pipeline до долгого ожидания.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert "assert_target_dag_not_paused" in text + assert 'ETL_DAG_ID = "etl_pipeline"' in text + assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text + assert "session.query(DagModel)" in text + assert "DagModel.dag_id == dag_id" in text + assert "target_dag_trigger_error" in text + assert "Airflow 2.10.5" in text + assert "fail_when_dag_is_paused" in text + assert 'return "check_etl_not_paused_before_backfill"' in text + assert 'return "check_etl_not_paused_before_import"' in text + assert 'return "check_etl_not_paused_before_next_day"' in text + assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text + assert "check_etl_not_paused_before_import >> precheck_import_task" in text + assert "check_etl_not_paused_before_next_day >> precheck_next_day_task" in text + assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index( + "precheck_backfill_task >> backfill_task" + ) + assert text.index("check_etl_not_paused_before_import >> precheck_import_task") < text.index( + "precheck_import_task >> import_task" + ) + + +def test_make_up_rebuilds_airflow_images_after_repo_update(): + """make up пересобирает Airflow, но не запускает Superset до DM.""" + text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + up_block = text.split("\nup:\n", maxsplit=1)[1].split( + "\n\n# Остановить", + maxsplit=1, + )[0] + assert "$(COMPOSE) up -d --build" in up_block + assert "airflow-webserver" in up_block + assert "airflow-scheduler" in up_block + assert "superset" not in up_block + assert "superset-init" not in up_block + + +def test_readme_quick_start_lists_prerequisites_and_runs_ddl_before_generator_control(): + """Быстрый старт называет инструменты и DDL до generator_control.""" + text = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + + assert "uv" in text + assert "Docker" in text + assert "docker compose" in text + assert "make up\nmake ddl" in text + assert text.index("make ddl") < text.index("generator_control") + + +def test_course_readme_lists_uv_before_first_command(): + """Курс называет uv до первой команды.""" + text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8") + + assert "uv" in text + assert text.index("uv") < text.index("make generated-history-analytics") + + +def test_startup_history_runbook_warns_about_daily_wave_idle_gap(): + """Runbook предупреждает о дыре модельного времени при простое daily-wave.""" + text = (REPO_ROOT / "docs" / "runbooks" / "startup-history.md").read_text( + encoding="utf-8" + ) + + assert "daily-wave" in text + assert "прост" in text + assert "дыр" in text + assert "make generator-reset" in text + assert "startup-history-import" in text + + +def test_no_docker_or_continue_operation_in_dag(): + """Пульт не управляет Docker и не содержит операцию continue.""" + text = DAG_PATH.read_text(encoding="utf-8") + + assert "docker" not in text.lower() + assert '"continue"' not in text + + +def test_compose_mounts_generator_code_without_socket_and_gates_live_service(): + """Airflow видит код генератора, но не получает Docker socket.""" + text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "PYTHONPATH: /opt/airflow/generator_src" in text + assert "./generator/src:/opt/airflow/generator_src:ro" in text + assert "/var/run/docker.sock" not in text + assert "profiles:\n - live-generator" in text + + +def test_down_and_clean_include_live_generator_profile(): + """Остановка стенда удаляет профильный live-генератор.""" + text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "down:\n\t$(COMPOSE) --profile live-generator down" in text + assert "clean:\n\t$(COMPOSE) --profile live-generator down -v --remove-orphans" in text + + +def test_console_generator_paths_precheck_clean_stand_before_writes(): + """Backfill/import с хоста проверяют чистый стенд до записи.""" + run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text( + encoding="utf-8" + ) + import_artifact = ( + REPO_ROOT / "scripts" / "import_startup_history_artifact.sh" + ).read_text(encoding="utf-8") + + assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in run_generator + assert run_generator.index("assert_stand_clean.sh") < run_generator.index( + "run --rm --no-deps" + ) + assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in import_artifact + assert import_artifact.index("assert_stand_clean.sh") < import_artifact.index( + "startup_history_artifact import" + ) + + +def test_console_continue_checks_state_or_clean_stand_before_live_start(): + """Continue не стартует новый live-мир на грязном стенде без state.""" + run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text( + encoding="utf-8" + ) + + assert "elif [[ \"${VERB}\" == \"continue\" ]]" in run_generator + assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" continue" in run_generator + assert run_generator.index("assert_stand_clean.sh\" continue") < run_generator.index( + "up -d --build generator" + ) + + +def test_console_reset_checks_clean_stand_before_live_start(): + """Reset не пишет новый мир поверх старых данных.""" + run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text( + encoding="utf-8" + ) + reset_branch = run_generator.split("else\n", maxsplit=1)[1] + + assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in reset_branch + assert reset_branch.index("assert_stand_clean.sh\" clean") < reset_branch.index( + "up -d --build generator" + ) + + +def test_clean_start_paths_include_live_generator_profile(): + """Все чистые сбросы видят профильный live-генератор.""" + generated_history = ( + REPO_ROOT / "scripts" / "run_generated_history_analytics.sh" + ).read_text(encoding="utf-8") + + assert "--profile live-generator down -v --remove-orphans" in generated_history + + +def test_host_clean_precheck_checks_live_before_kafka_and_stg(): + """Host backfill/import сначала проверяет, что live-генератор не запущен.""" + text = (REPO_ROOT / "scripts" / "assert_stand_clean.sh").read_text( + encoding="utf-8" + ) + + assert "--mode live" in text + assert text.index("--mode live") < text.index("clickhouse-client") + assert text.index("clickhouse-client") < text.index("--mode \"${CHECK_MODE}\"") + + +def test_airflow_and_generator_kafka_dependency_versions_match(): + """Airflow и генератор используют одну версию kafka-python.""" + airflow_req = (REPO_ROOT / "airflow" / "requirements.txt").read_text(encoding="utf-8") + generator_req = (REPO_ROOT / "generator" / "requirements.txt").read_text(encoding="utf-8") + + assert "kafka-python==2.0.6" in airflow_req + assert "kafka-python==2.0.6" in generator_req + assert "prometheus-client==0.21.1" in airflow_req diff --git a/generator/tests/test_history.py b/generator/tests/test_history.py new file mode 100644 index 0000000..362c1ce --- /dev/null +++ b/generator/tests/test_history.py @@ -0,0 +1,33 @@ +""" +Тесты структуры записи батча. +""" + +from datetime import datetime, timezone + +import pytest +from generator import BatchRecord + + +class TestBatchRecord: + """Тесты структуры записи.""" + + def test_record_creation(self): + """Создание записи с всеми полями.""" + now = datetime.now(timezone.utc) + record = BatchRecord( + batch_id="abc123", + started_at=now, + finished_at=now, + sent_total=400, + sent_browser=100, + sent_location=100, + sent_device=100, + sent_geo=100, + status="partial", + error_message="Some errors occurred", + ) + + assert record.batch_id == "abc123" + assert record.sent_total == 400 + assert record.status == "partial" + assert record.error_message == "Some errors occurred" diff --git a/generator/tests/test_kafka_history.py b/generator/tests/test_kafka_history.py new file mode 100644 index 0000000..47ffcda --- /dev/null +++ b/generator/tests/test_kafka_history.py @@ -0,0 +1,216 @@ +""" +Тесты для KafkaBatchHistory. +""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch, call + +import pytest +from generator import BatchRecord, KafkaBatchHistory + + +class TestBatchRecordSerialization: + """Тесты сериализации BatchRecord.""" + + def test_to_dict_serializes_all_fields(self): + """to_dict сериализует все поля.""" + now = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + record = BatchRecord( + batch_id="abc123", + started_at=now, + finished_at=now, + sent_total=100, + sent_browser=25, + sent_location=25, + sent_device=25, + sent_geo=25, + status="success", + error_message=None, + ) + + data = record.to_dict() + + assert data["batch_id"] == "abc123" + assert data["started_at"] == "2024-01-15T10:30:00+00:00" + assert data["finished_at"] == "2024-01-15T10:30:00+00:00" + assert data["sent_total"] == 100 + assert data["sent_browser"] == 25 + assert data["sent_location"] == 25 + assert data["sent_device"] == 25 + assert data["sent_geo"] == 25 + assert data["status"] == "success" + assert data["error_message"] is None + + def test_to_dict_with_error_message(self): + """to_dict сериализует error_message если есть.""" + now = datetime.now(timezone.utc) + record = BatchRecord( + batch_id="err456", + started_at=now, + finished_at=now, + sent_total=0, + sent_browser=0, + sent_location=0, + sent_device=0, + sent_geo=0, + status="error", + error_message="Connection failed", + ) + + data = record.to_dict() + + assert data["error_message"] == "Connection failed" + + def test_isoformat_includes_timezone(self): + """ISO формат включает timezone.""" + now = datetime.now(timezone.utc) + record = BatchRecord( + batch_id="tz789", + started_at=now, + finished_at=now, + sent_total=50, + sent_browser=12, + sent_location=13, + sent_device=12, + sent_geo=13, + status="partial", + error_message="Some errors", + ) + + data = record.to_dict() + + # Должен содержать +00:00 или Z + assert "+" in data["started_at"] or "Z" in data["started_at"] + + +class TestKafkaBatchHistory: + """Тесты KafkaBatchHistory.""" + + def test_history_topic_constant(self): + """Константа топика истории.""" + assert KafkaBatchHistory.HISTORY_TOPIC == "generator_batch_history" + + @patch("generator._import_kafka") + def test_init_connects_to_kafka(self, mock_import): + """Инициализация подключается к Kafka.""" + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + + assert history.bootstrap_servers == "localhost:9092" + mock_producer_class.assert_called_once() + + @patch("generator._with_retry") + @patch("generator._import_kafka") + def test_init_uses_retry(self, mock_import, mock_retry): + """Инициализация использует retry для подключения.""" + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, Exception) + mock_retry.side_effect = lambda f, **kwargs: f() # Выполняем функцию сразу + + KafkaBatchHistory("localhost:9092") + + # Проверяем что _with_retry был вызван + mock_retry.assert_called() + + @patch("generator._import_kafka") + def test_add_sends_to_kafka(self, mock_import): + """add отправляет сообщение в Kafka.""" + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + now = datetime.now(timezone.utc) + record = BatchRecord( + batch_id="test123", + started_at=now, + finished_at=now, + sent_total=100, + sent_browser=25, + sent_location=25, + sent_device=25, + sent_geo=25, + status="success", + error_message=None, + ) + + history.add(record) + + mock_producer.send.assert_called_once() + call_args = mock_producer.send.call_args + assert call_args[0][0] == "generator_batch_history" + assert call_args[1]["key"] == "test123" + assert "value" in call_args[1] + + @patch("generator._import_kafka") + def test_add_retries_on_failure(self, mock_import): + """add делает retry при ошибке и пытается реконнект.""" + mock_producer = MagicMock() + # Первые 3 вызова падают, потом успех + mock_producer.send.side_effect = [ + Exception("Fail 1"), + Exception("Fail 2"), + Exception("Fail 3"), + MagicMock(), # Успех после реконнекта + ] + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + now = datetime.now(timezone.utc) + record = BatchRecord( + batch_id="retry456", + started_at=now, + finished_at=now, + sent_total=50, + sent_browser=12, + sent_location=13, + sent_device=12, + sent_geo=13, + status="success", + error_message=None, + ) + + # Не должно упасть - должен быть retry + reconnect + history.add(record) + + # Проверяем что producer.send вызывался несколько раз (retry) + assert mock_producer.send.call_count >= 1 + + @patch("generator._import_kafka") + def test_flush_calls_producer_flush(self, mock_import): + """flush вызывает flush у producer.""" + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + history.flush() + + mock_producer.flush.assert_called_once() + + @patch("generator._import_kafka") + def test_close_calls_producer_close(self, mock_import): + """close вызывает close у producer.""" + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + history.close() + + mock_producer.close.assert_called_once() + + @patch("generator._import_kafka") + def test_close_ignores_errors(self, mock_import): + """close игнорирует ошибки при закрытии.""" + mock_producer = MagicMock() + mock_producer.close.side_effect = Exception("Close failed") + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, Exception) + + history = KafkaBatchHistory("localhost:9092") + # Не должно упасть + history.close() diff --git a/generator/tests/test_launch.py b/generator/tests/test_launch.py new file mode 100644 index 0000000..fa7660e --- /dev/null +++ b/generator/tests/test_launch.py @@ -0,0 +1,91 @@ +""" +Тесты профилей и глаголов запуска генератора. +""" + +from datetime import datetime, timezone + + +def test_duration_2d_sets_model_t_end_from_t0(): + """Длительность 2d считается от T0 без ручного GEN_MODEL_T_END.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci", duration="2d") + + assert env["GEN_MODEL_T0"] == "2026-01-01T00:00:00+00:00" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + + +def test_daily_wave_profile_uses_two_days_by_default(): + """Профиль daily-wave даёт историю с суточной волной.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="daily-wave") + + assert env["GEN_LAUNCH_PROFILE"] == "daily-wave" + assert env["GEN_HISTORY_DURATION"] == "2d" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + assert env["GEN_MODEL_TIME_SPEED"] == "60" + assert env["GEN_TICK_SECONDS"] == "1" + + +def test_backfill_verb_maps_to_existing_low_level_flags(): + """Глагол backfill оставляет прежнюю механику запуска под капотом.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci") + + assert env["GEN_RUN_MODE"] == "backfill" + assert env["GEN_STATE_RESET"] == "true" + + +def test_continue_and_reset_verbs_map_to_existing_low_level_flags(): + """Глаголы continue и reset задают только старый контракт state.""" + from clickstream_generator.launch import build_launch_env + + continue_env = build_launch_env("continue", profile_name="ci") + reset_env = build_launch_env( + "reset", + profile_name="ci", + overrides={"GEN_MODEL_T_END": "2026-01-09T00:00:00+00:00"}, + ) + + assert continue_env["GEN_RUN_MODE"] == "live" + assert continue_env["GEN_STATE_RESET"] == "false" + assert continue_env["GEN_MODEL_T_END"] == "2026-01-01T06:00:00+00:00" + assert reset_env["GEN_RUN_MODE"] == "live" + assert reset_env["GEN_STATE_RESET"] == "true" + assert "GEN_MODEL_T_END" not in reset_env + + +def test_next_day_verb_restores_snapshot_without_reset(): + """Глагол next-day включает отдельный ограниченный режим от текущей границы.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env( + "next-day", + profile_name="ci", + overrides={ + "GEN_MODEL_T0": "2026-01-01T00:00:00+00:00", + "GEN_MODEL_T_END": "2026-01-03T00:00:00+00:00", + }, + ) + + assert env["GEN_RUN_MODE"] == "next-day" + assert env["GEN_STATE_RESET"] == "false" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + + +def test_ci_profile_keeps_previous_default_backfill_window(): + """CI-профиль сохраняет прежний 6-часовой проверочный запуск.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci") + + t0 = datetime.fromisoformat(env["GEN_MODEL_T0"]) + t_end = datetime.fromisoformat(env["GEN_MODEL_T_END"]) + assert t0 == datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + assert (t_end - t0).total_seconds() == 6 * 60 * 60 + assert env["GEN_MODEL_TIME_SPEED"] == "1" + assert env["GEN_TICK_SECONDS"] == "60" + assert env["GEN_LAMBDA_BASE_PER_MIN"] == "60" + assert env["GEN_JITTER_PCT"] == "0" diff --git a/generator/tests/test_service.py b/generator/tests/test_service.py new file mode 100644 index 0000000..4eda6f4 --- /dev/null +++ b/generator/tests/test_service.py @@ -0,0 +1,1492 @@ +""" +Тесты для GeneratorService и интеграционных сценариев. +""" + +import logging +import random +import hashlib +import json +import signal +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from time import sleep as real_sleep +from unittest.mock import MagicMock, patch + +import pytest +from generator import ( + Config, + EventDictionary, + EventGenerator, + EXPECTED_VISIT_EVENTS, + GeneratorService, + GeneratorState, + KafkaBatchHistory, + TickStreamGenerator, + main, +) +from clickstream_generator.state import UnsupportedStateVersionError + + +class TestBatchRecordWithDictConversion: + """Тесты конвертации BatchRecord в dict.""" + + def test_dict_contains_all_batch_info(self): + """Словарь содержит всю информацию о батче.""" + from datetime import datetime, timezone + from generator import BatchRecord + + started = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc) + finished = datetime(2024, 6, 15, 12, 0, 5, tzinfo=timezone.utc) + + record = BatchRecord( + batch_id="batch_001", + started_at=started, + finished_at=finished, + sent_total=400, + sent_browser=100, + sent_location=100, + sent_device=100, + sent_geo=100, + status="success", + error_message=None, + ) + + data = record.to_dict() + + # Проверяем структуру JSON + assert isinstance(data, dict) + assert data["batch_id"] == "batch_001" + assert data["sent_total"] == 400 + assert data["status"] == "success" + assert "started_at" in data + assert "finished_at" in data + + # Проверяем что можно сериализовать в JSON + import json + json_str = json.dumps(data) + assert isinstance(json_str, str) + + # Проверяем что можно десериализовать + restored = json.loads(json_str) + assert restored["batch_id"] == "batch_001" + + +class TestGeneratorServiceInit: + """Тесты инициализации GeneratorService.""" + + def test_service_initializes_dictionary(self, base_config): + """Service загружает словарь при инициализации.""" + service = GeneratorService(base_config) + + assert service.dictionary is not None + assert len(service.dictionary.browser_events) == 1000 + assert service.generator is not None + assert service.config == base_config + + def test_service_history_is_none_before_start(self, base_config): + """История None до вызова start.""" + service = GeneratorService(base_config) + + # История и publisher инициализируются в start() + assert service.history is None + assert service.publisher is None + + +class TestGeneratorServiceDisabled: + """Тесты отключенного генератора.""" + + def test_disabled_generator_logs_warning(self, base_config, caplog): + """Отключенный генератор логирует warning.""" + from dataclasses import replace + import logging + + disabled_config = replace(base_config, enabled=False) + service = GeneratorService(disabled_config) + + with caplog.at_level(logging.WARNING): + service.start() + + assert "disabled" in caplog.text.lower() or "GEN_ENABLED" in caplog.text + + +class TestGeneratorServiceSteadyStream: + """Проверки сервисного тика без настоящей Kafka.""" + + def test_service_live_tick_uses_model_time_for_events_and_day_factor(self, base_config): + """Живой тик пишет события от T0 и не зависит от реального часа запуска.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=1, + max_active_sessions=250, + population_max=251, + model_t0=model_t0, + ) + + night_wall_run = self._run_service_ticks( + config, + wall_now=datetime(2026, 6, 14, 3, 0, tzinfo=timezone.utc), + ticks_count=2, + ) + day_wall_run = self._run_service_ticks( + config, + wall_now=datetime(2026, 6, 14, 11, 0, tzinfo=timezone.utc), + ticks_count=2, + ) + + assert len(night_wall_run["browser_events"]) == len(day_wall_run["browser_events"]) + assert night_wall_run["budget_model_times"] == [ + datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + datetime(2026, 1, 1, 10, 1, tzinfo=timezone.utc), + ] + assert day_wall_run["budget_model_times"] == night_wall_run["budget_model_times"] + assert night_wall_run["browser_events"] + timestamps = { + event["event_timestamp"] + for event in night_wall_run["browser_events"] + } + assert timestamps == { + "2026-01-01 10:00:00.000000", + "2026-01-01 10:01:00.000000", + } + + def test_service_live_tick_advances_event_timestamps_by_model_speed(self, base_config): + """При ×K сервис сдвигает события на ускоренный модельный шаг.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=1, + max_active_sessions=250, + population_max=251, + model_t0=model_t0, + model_time_speed=10, + ) + + published = self._run_service_ticks( + config, + wall_now=datetime(2026, 6, 14, 11, 0, tzinfo=timezone.utc), + ticks_count=2, + ) + + timestamps = { + event["event_timestamp"] + for event in published["browser_events"] + } + assert timestamps == { + "2026-01-01 10:00:00.000000", + "2026-01-01 10:10:00.000000", + } + + def test_successful_live_tick_does_not_log_per_tick_info(self, base_config, caplog): + """Успешный live-тик не пишет подробный пер-тиковый журнал на INFO.""" + config = replace( + base_config, + tick_seconds=1, + lambda_base_per_min=60, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=1, + max_active_sessions=10, + population_max=11, + ) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.history = MagicMock() + service._running = True + + def stop_after_first_tick(_sleep_seconds): + service._running = False + + with caplog.at_level(logging.INFO, logger="generator"), \ + patch.object( + service._shutdown_event, + "wait", + side_effect=stop_after_first_tick, + ): + service._main_loop() + + assert "=== Tick" not in caplog.text + assert "Generating with event budget" not in caplog.text + assert "completed:" not in caplog.text + assert "sent" not in caplog.text + + def test_sigterm_during_publish_finishes_current_batch(self, base_config): + """SIGTERM посреди публикации не обрывает связанные топики batch.""" + config = replace( + base_config, + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=3, + metrics_enabled=False, + state_enabled=False, + ) + service = GeneratorService(config) + publisher = MagicMock() + history = MagicMock() + published_topics = [] + + def publish_and_request_stop(topic, events): + published_topics.append(topic) + if len(published_topics) == 1: + service.request_stop(signal.SIGTERM, None) + return len(events), 0 + + publisher.publish.side_effect = publish_and_request_stop + + with patch( + "clickstream_generator.service.start_http_server", + side_effect=AssertionError("тест не должен открывать порт метрик"), + ), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch( + "clickstream_generator.service.KafkaPublisher", + return_value=publisher, + ), \ + patch( + "clickstream_generator.service.KafkaBatchHistory", + return_value=history, + ), \ + patch.object( + service.generator, + "_calculate_events_count", + return_value=int(EXPECTED_VISIT_EVENTS), + ): + service.start() + + assert published_topics == [ + "browser_events", + "location_events", + "device_events", + "geo_events", + ] + publisher.flush.assert_called_once_with() + history.add.assert_called_once() + history.flush.assert_called_once_with() + publisher.close.assert_called_once_with() + history.close.assert_called_once_with() + assert service._tick == 1 + + def test_main_connects_sigterm_to_graceful_stop(self, base_config): + """Точка входа передаёт SIGTERM сервису как штатный запрос остановки.""" + service = MagicMock() + + with patch("clickstream_generator.service.Config", return_value=base_config), \ + patch("clickstream_generator.service.GeneratorService", return_value=service), \ + patch("clickstream_generator.service.signal.signal") as register_signal: + main() + + register_signal.assert_called_once_with(signal.SIGTERM, service.request_stop) + service.start.assert_called_once_with() + + def _run_service_ticks(self, config, wall_now: datetime, ticks_count: int): + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.history = MagicMock() + service._running = True + sleep_calls = 0 + budget_model_times = [] + original_calculate_events_count = service.generator._calculate_events_count + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + if tz is None: + return wall_now.replace(tzinfo=None) + return wall_now.astimezone(tz) + + def stop_after_tick(_sleep_seconds): + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= ticks_count: + service._running = False + + def calculate_events_count(now=None): + budget_model_times.append(now) + return original_calculate_events_count(now=now) + + with patch("clickstream_generator.service.datetime", FrozenDateTime), \ + patch.object( + service.generator, + "_calculate_events_count", + side_effect=calculate_events_count, + ), \ + patch.object( + service._shutdown_event, + "wait", + side_effect=stop_after_tick, + ): + service._main_loop() + + published = {} + for call in service.publisher.publish.call_args_list: + topic, events = call.args + published.setdefault(topic, []).extend(events) + published["budget_model_times"] = budget_model_times + return published + + def test_service_ticks_publish_connected_multi_event_visit(self, base_config): + """Сервисные тики публикуют несколько связанных событий одного визита.""" + config = replace(base_config, tick_seconds=1, max_session_events=3) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.history = MagicMock() + service._running = True + sleep_calls = 0 + + def stop_after_second_tick(sleep_seconds): + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls == 1: + real_sleep(sleep_seconds) + else: + service._running = False + + with patch.object( + service.generator, + "_calculate_events_count", + side_effect=[int(EXPECTED_VISIT_EVENTS), 0], + ), patch.object( + service.generator, + "_visit_pause_seconds", + return_value=0.05, + ), patch.object(service._shutdown_event, "wait") as sleep_mock: + sleep_mock.side_effect = stop_after_second_tick + + service._main_loop() + + published = {} + for call in service.publisher.publish.call_args_list: + topic, events = call.args + published.setdefault(topic, []).extend(events) + + browser_events = published["browser_events"] + location_events = published["location_events"] + device_events = published["device_events"] + geo_events = published["geo_events"] + + assert set(published) == { + "browser_events", + "location_events", + "device_events", + "geo_events", + } + assert len(browser_events) >= 2 + assert len({event["click_id"] for event in browser_events}) == 1 + assert len({event["event_id"] for event in browser_events}) == len(browser_events) + assert {event["event_id"] for event in location_events} == { + event["event_id"] + for event in browser_events + } + assert {event["click_id"] for event in device_events} == { + browser_events[0]["click_id"] + } + assert {event["click_id"] for event in geo_events} == { + browser_events[0]["click_id"] + } + + history_records = [ + call.args[0] + for call in service.history.add.call_args_list + ] + assert [record.status for record in history_records] == ["success", "success"] + assert sum(record.sent_browser for record in history_records) == len(browser_events) + assert sum(record.sent_location for record in history_records) == len(location_events) + assert sum(record.sent_device for record in history_records) == len(device_events) + assert sum(record.sent_geo for record in history_records) == len(geo_events) + + +class TestGeneratorServiceBackfill: + """Проверки режима промотки стартовой истории.""" + + def test_backfill_publishes_half_open_history_state_and_manifest( + self, base_config + ): + """Backfill пишет [T0, T_end), state на T_end и повторяемый manifest.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=3) + config = replace( + base_config, + run_mode="backfill", + model_t0=model_t0, + model_t_end=model_t_end, + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + state_enabled=True, + ) + + first = self._run_backfill(config) + second = self._run_backfill(config) + + browser_events = first["published"]["browser_events"] + timestamps = [ + datetime.fromisoformat(event["event_timestamp"].replace(" ", "T")) + for event in browser_events + ] + saved_state = first["state_manager"].save.call_args.args[0] + manifest = first["manifest_manager"].save.call_args.args[0] + + assert browser_events + assert min(timestamps) >= model_t0.replace(tzinfo=None) + assert max(timestamps) < model_t_end.replace(tzinfo=None) + assert saved_state.model_timestamp == model_t_end + assert saved_state.last_timestamp == model_t_end + assert manifest["run_mode"] == "backfill" + assert manifest["model_t0"] == model_t0.isoformat() + assert manifest["model_t_end"] == model_t_end.isoformat() + assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id + assert manifest["topics"]["browser_events"]["rows"] == len(browser_events) + for topic in ( + "browser_events", + "location_events", + "device_events", + "geo_events", + ): + assert manifest["topics"][topic]["min_event_timestamp"] is not None + assert manifest["topics"][topic]["max_event_timestamp"] is not None + assert manifest["totals"]["events"] == len(browser_events) + assert manifest["totals"]["visits"] == len({ + event["click_id"] + for event in browser_events + }) + assert manifest["totals"]["users"] == len({ + event["user_domain_id"] + for event in first["published"]["device_events"] + }) + assert first["digest"] == second["digest"] + assert first["manifest_digest"] == second["manifest_digest"] + + def test_backfill_writes_portable_artifact_when_requested( + self, base_config, tmp_path + ): + """Backfill пишет переносимый файл с событиями, state и manifest.""" + from clickstream_generator.startup_history_artifact import ( + load_startup_history_artifact, + validate_startup_history_artifact, + ) + + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=1) + artifact_path = tmp_path / "startup-history.json" + config = replace( + base_config, + run_mode="backfill", + model_t0=model_t0, + model_t_end=model_t_end, + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + state_enabled=True, + startup_history_artifact=artifact_path, + ) + + self._run_backfill(config) + + artifact = load_startup_history_artifact(artifact_path) + state, manifest, topics, _ = validate_startup_history_artifact( + artifact, + expected_config=config, + ) + assert topics["browser_events"] + assert state.last_batch_id == manifest["state"]["last_batch_id"] + assert manifest["totals"]["events"] == len(topics["browser_events"]) + + def test_live_start_uses_startup_manifest_without_wall_delta( + self, base_config, event_dictionary + ): + """Live-запуск из backfill-state стартует с T_end без wall-дельты.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=5) + config = replace( + base_config, + model_t0=model_t0, + tick_seconds=60, + model_time_speed=3600, + ) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="startup-history-state", + last_timestamp=model_t_end, + model_timestamp=model_t_end, + wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + manifest = { + "run_mode": "backfill", + "gen_seed": config.seed, + "model_t0": model_t0.isoformat(), + "model_t_end": model_t_end.isoformat(), + "model_timezone": config.model_timezone, + "generation_settings": GeneratorService(config)._generation_settings(), + "state_version": state.version, + "state": {"last_batch_id": state.last_batch_id}, + } + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = manifest + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(config) + service.start() + + assert service._model_time == model_t_end + assert service._tick == state.tick + + def test_live_start_rejects_startup_manifest_with_different_config_t_end( + self, base_config, event_dictionary + ): + """Manifest от другого T_end не считается стартовой историей запуска.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + manifest_t_end = model_t0 + timedelta(minutes=5) + config_t_end = model_t0 + timedelta(minutes=10) + wall_saved_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + wall_restarted_at = wall_saved_at + timedelta(seconds=30) + config = replace( + base_config, + model_t0=model_t0, + model_t_end=config_t_end, + tick_seconds=60, + model_time_speed=10, + ) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="startup-history-state", + last_timestamp=manifest_t_end, + model_timestamp=manifest_t_end, + wall_timestamp=wall_saved_at, + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + manifest = { + "run_mode": "backfill", + "gen_seed": config.seed, + "model_t0": model_t0.isoformat(), + "model_t_end": manifest_t_end.isoformat(), + "model_timezone": config.model_timezone, + "generation_settings": GeneratorService(config)._generation_settings(), + "state_version": state.version, + "state": {"last_batch_id": state.last_batch_id}, + } + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = manifest + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + if tz is None: + return wall_restarted_at.replace(tzinfo=None) + return wall_restarted_at.astimezone(tz) + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch("clickstream_generator.service.datetime", FrozenDateTime), \ + patch.object( + GeneratorService, + "restore_from_startup_history", + ) as restore_from_startup_history, \ + patch.object( + GeneratorService, + "_restore_live_state", + ) as restore_live_state, \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(config) + with pytest.raises(ValueError, match="GEN_MODEL_T_END.*GEN_STATE_RESET=true"): + service.start() + + restore_from_startup_history.assert_not_called() + restore_live_state.assert_not_called() + + def test_live_start_fails_loudly_on_readable_incompatible_state( + self, base_config, event_dictionary + ): + """Читаемый state другого мира при продолжении даёт жёсткий отказ.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + model_t0=model_t0, + tick_seconds=60, + model_time_speed=10, + state_reset=False, + ) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="live-state", + last_timestamp=model_t0, + model_timestamp=model_t0, + wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed + 1, + ) + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(config) + with pytest.raises(ValueError, match="GEN_SEED.*GEN_STATE_RESET=true"): + service.start() + + def test_live_start_rejects_orphan_startup_state_without_live_restore( + self, base_config, event_dictionary, caplog + ): + """Orphan startup-history state не восстанавливается как live-state.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=5) + config = replace(base_config, model_t0=model_t0, model_time_speed=10) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="startup-history-orphan", + last_timestamp=model_t_end, + model_timestamp=model_t_end, + wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object( + GeneratorService, + "restore_from_startup_history", + ) as restore_from_startup_history, \ + patch.object( + GeneratorService, + "_restore_live_state", + ) as restore_live_state, \ + patch.object(GeneratorService, "_main_loop", return_value=None), \ + caplog.at_level(logging.WARNING, logger="generator"): + + service = GeneratorService(config) + with pytest.raises( + ValueError, + match="generator_startup_history_manifest.*GEN_STATE_RESET=true", + ): + service.start() + + restore_from_startup_history.assert_not_called() + restore_live_state.assert_not_called() + + def test_startup_history_state_checks_state_fields( + self, base_config, event_dictionary + ): + """Startup-history state сверяется с manifest/config по полям state.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=5) + config = replace(base_config, model_t0=model_t0, model_time_speed=10) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="startup-history-state", + last_timestamp=model_t_end, + model_timestamp=model_t_end, + wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed + 1, + ) + manifest = { + "run_mode": "backfill", + "gen_seed": config.seed, + "model_t0": model_t0.isoformat(), + "model_t_end": model_t_end.isoformat(), + "model_timezone": config.model_timezone, + "generation_settings": GeneratorService(config)._generation_settings(), + "state_version": state.version, + "state": {"last_batch_id": state.last_batch_id}, + } + + service = GeneratorService(config) + + assert not service._is_startup_history_state(state, manifest) + + def test_startup_history_state_checks_state_version( + self, base_config, event_dictionary + ): + """Startup-history state сверяется с версией state из manifest.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=5) + config = replace(base_config, model_t0=model_t0, model_time_speed=10) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="startup-history-state", + last_timestamp=model_t_end, + model_timestamp=model_t_end, + wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + manifest = { + "run_mode": "backfill", + "gen_seed": config.seed, + "model_t0": model_t0.isoformat(), + "model_t_end": model_t_end.isoformat(), + "model_timezone": config.model_timezone, + "generation_settings": GeneratorService(config)._generation_settings(), + "state_version": "1.0", + "state": {"last_batch_id": state.last_batch_id}, + } + + service = GeneratorService(config) + + assert not service._is_startup_history_state(state, manifest) + + def test_backfill_publish_error_does_not_save_state_or_manifest( + self, base_config + ): + """Backfill не создаёт валидный артефакт при ошибке публикации.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + run_mode="backfill", + model_t0=model_t0, + model_t_end=model_t0 + timedelta(minutes=1), + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + state_enabled=True, + ) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 1) + if topic == "location_events" + else (len(events), 0) + ) + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + + with pytest.raises(RuntimeError, match="Backfill publish failed"): + service._run_backfill() + + service.state_manager.save.assert_not_called() + service.state_manager.flush.assert_not_called() + service.manifest_manager.save.assert_not_called() + service.manifest_manager.flush.assert_not_called() + + def test_backfill_manifest_save_error_does_not_save_state(self, base_config): + """Если manifest не записан, state стартовой истории не сохраняется.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + run_mode="backfill", + model_t0=model_t0, + model_t_end=model_t0 + timedelta(minutes=1), + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + state_enabled=True, + ) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.publisher.flush.return_value = None + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + service.manifest_manager.save.side_effect = RuntimeError("manifest down") + + with pytest.raises(RuntimeError, match="manifest down"): + service._run_backfill() + + service.manifest_manager.save.assert_called_once() + service.state_manager.save.assert_not_called() + service.state_manager.flush.assert_not_called() + + def test_backfill_manifest_flush_error_does_not_save_state(self, base_config): + """Если manifest не сброшен в Kafka, state стартовой истории не сохраняется.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + config = replace( + base_config, + run_mode="backfill", + model_t0=model_t0, + model_t_end=model_t0 + timedelta(minutes=1), + tick_seconds=60, + lambda_base_per_min=600, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + state_enabled=True, + ) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.publisher.flush.return_value = None + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + service.manifest_manager.flush.side_effect = RuntimeError("flush down") + + with pytest.raises(RuntimeError, match="flush down"): + service._run_backfill() + + service.manifest_manager.save.assert_called_once() + service.manifest_manager.flush.assert_called_once() + service.state_manager.save.assert_not_called() + service.state_manager.flush.assert_not_called() + + def _run_backfill(self, config): + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = ( + lambda topic, events: (len(events), 0) + ) + service.publisher.flush.return_value = None + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + + service._run_backfill() + + published = {} + for call in service.publisher.publish.call_args_list: + topic, events = call.args + published.setdefault(topic, []).extend(events) + + digest = hashlib.sha256( + json.dumps(published, sort_keys=True, default=str).encode() + ).hexdigest() + manifest = service.manifest_manager.save.call_args.args[0] + stable_manifest = { + key: value + for key, value in manifest.items() + if key != "generated_at" + } + manifest_digest = hashlib.sha256( + json.dumps(stable_manifest, sort_keys=True, default=str).encode() + ).hexdigest() + + return { + "published": published, + "state_manager": service.state_manager, + "manifest_manager": service.manifest_manager, + "digest": digest, + "manifest_digest": manifest_digest, + } + + +class TestGeneratorServiceNextDay: + """Проверки ограниченной доливки следующего модельного дня.""" + + def test_next_day_publishes_24h_then_state_then_cumulative_manifest( + self, base_config + ): + """Next-day пишет [T_end, T_end+24h), затем state и manifest.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + ) + + model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + current_t_end = model_t0 + timedelta(hours=1) + config = replace( + base_config, + run_mode="next-day", + model_t0=model_t0, + model_t_end=current_t_end, + tick_seconds=3600, + model_time_speed=1, + lambda_base_per_min=60, + jitter_pct=0, + min_events_per_tick=1, + max_events_per_tick=1000, + max_session_events=5, + max_active_sessions=250, + population_max=251, + ) + service = GeneratorService(config) + state = service.stream.to_state( + tick=1, + rng_state=service.generator.rng.getstate(), + last_batch_id="startup-history-initial", + last_timestamp=current_t_end, + model_timestamp=current_t_end, + wall_timestamp=current_t_end, + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + old_batch = { + "browser_events": [{ + "event_id": "old-event", + "click_id": "old-click", + "event_timestamp": "2026-01-01 00:30:00.000000", + }], + "location_events": [{"event_id": "old-event"}], + "device_events": [{ + "click_id": "old-click", + "user_domain_id": "old-user", + }], + "geo_events": [{"click_id": "old-click"}], + } + initial_builder = StartupHistoryArtifactBuilder() + initial_builder.add_batch(old_batch) + manifest = build_manifest(config, initial_builder.counters, state) + + published = {topic: [] for topic in old_batch} + service.publisher = MagicMock() + + def publish(topic, events): + published[topic].extend(events) + return len(events), 0 + + service.publisher.publish.side_effect = publish + service.history = MagicMock() + order = [] + service.publisher.flush.side_effect = lambda: order.append("data") + service.state_manager = MagicMock() + service.state_manager.save.side_effect = lambda _state: order.append("state") + service.manifest_manager = MagicMock() + service.manifest_manager.save.side_effect = ( + lambda _manifest: order.append("manifest") + ) + + class DataReader: + def load(self): + return { + topic: old_batch[topic] + published[topic] + for topic in old_batch + } + + service.data_reader = DataReader() + service.restore_from_startup_history(state, model_t_end=current_t_end) + + service._run_next_day(manifest) + + browser_events = published["browser_events"] + timestamps = [ + datetime.fromisoformat(event["event_timestamp"].replace(" ", "T")) + for event in browser_events + ] + target_t_end = current_t_end + timedelta(hours=24) + saved_state = service.state_manager.save.call_args.args[0] + saved_manifest = service.manifest_manager.save.call_args.args[0] + + assert browser_events + assert min(timestamps) >= current_t_end.replace(tzinfo=None) + assert max(timestamps) < target_t_end.replace(tzinfo=None) + assert saved_state.model_timestamp == target_t_end + assert saved_manifest["model_t_end"] == target_t_end.isoformat() + assert saved_manifest["boundaries"] == [ + model_t0.isoformat(), + current_t_end.isoformat(), + target_t_end.isoformat(), + ] + assert saved_manifest["totals"]["events"] == len(browser_events) + 1 + assert order == ["data", "state", "manifest"] + + first_day_events = len(browser_events) + first_day_checksum = saved_manifest["topics"]["browser_events"][ + "checksum_sha256" + ] + service._run_next_day(saved_manifest) + + second_state = service.state_manager.save.call_args.args[0] + second_manifest = service.manifest_manager.save.call_args.args[0] + expected_builder = StartupHistoryArtifactBuilder() + expected_builder.add_batch(DataReader().load()) + assert second_state.model_timestamp == target_t_end + timedelta(hours=24) + assert second_manifest["boundaries"] == [ + model_t0.isoformat(), + current_t_end.isoformat(), + target_t_end.isoformat(), + (target_t_end + timedelta(hours=24)).isoformat(), + ] + assert second_manifest["totals"]["events"] == len(browser_events) + 1 + assert len(browser_events) > first_day_events + assert ( + second_manifest["topics"]["browser_events"]["checksum_sha256"] + != first_day_checksum + ) + assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics() + assert order == [ + "data", + "state", + "manifest", + "data", + "state", + "manifest", + ] + + def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config): + """Ошибка data-топика оставляет обе точки фиксации без изменений.""" + model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + config = replace( + base_config, + run_mode="next-day", + model_t0=model_t0, + model_t_end=model_t0 + timedelta(hours=1), + tick_seconds=3600, + model_time_speed=1, + max_active_sessions=250, + population_max=251, + ) + service = GeneratorService(config) + service.publisher = MagicMock() + service.publisher.publish.side_effect = lambda topic, events: ( + (len(events), 1) if topic == "location_events" else (len(events), 0) + ) + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + service._model_time = config.model_t_end + manifest = { + "model_t0": model_t0.isoformat(), + "model_t_end": config.model_t_end.isoformat(), + "boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()], + } + + with pytest.raises(RuntimeError, match="Публикация next-day не удалась"): + service._run_next_day(manifest) + + service.state_manager.save.assert_not_called() + service.manifest_manager.save.assert_not_called() + + +class TestGeneratorServiceState: + """Тесты подключения state к сервисному запуску.""" + + def test_start_restores_tick_stream_state(self, base_config, event_dictionary): + """Сервис восстанавливает популяцию и активные визиты из state.""" + source_generator = EventGenerator(event_dictionary, base_config) + source_stream = TickStreamGenerator(source_generator) + tick_at = datetime.now(timezone.utc).replace(tzinfo=None) + source_stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = source_stream.to_state( + tick=3, + rng_state=source_generator.rng.getstate(), + last_batch_id="batch-3", + last_timestamp=tick_at, + model_timestamp=tick_at.replace(tzinfo=timezone.utc), + wall_timestamp=tick_at.replace(tzinfo=timezone.utc), + model_time_speed=base_config.model_time_speed, + model_timezone=base_config.model_timezone, + model_t0=base_config.model_t0, + gen_seed=base_config.seed, + ) + + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(base_config) + service.start() + + assert service._tick == 3 + assert service.stream.population_user_ids == source_stream.population_user_ids + assert service.stream.active_visit_count == source_stream.active_visit_count + + def test_start_restores_model_time_from_state_wall_delta( + self, base_config, event_dictionary + ): + """Live-восстановление считает точку модели из сохранённой wall-метки.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + wall_saved_at = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc) + wall_restarted_at = wall_saved_at + timedelta(seconds=30) + config = replace( + base_config, + model_t0=model_t0, + model_time_speed=10, + tick_seconds=60, + ) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t0) + state = source_stream.to_state( + tick=3, + rng_state=source_generator.rng.getstate(), + last_batch_id="batch-3", + last_timestamp=model_t0, + model_timestamp=model_t0, + wall_timestamp=wall_saved_at, + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + if tz is None: + return wall_restarted_at.replace(tzinfo=None) + return wall_restarted_at.astimezone(tz) + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch("clickstream_generator.service.datetime", FrozenDateTime), \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(config) + service.start() + + assert service._tick == 3 + assert service._model_time == model_t0 + timedelta(seconds=300) + assert service.stream.active_visit_count == source_stream.active_visit_count + + def test_restore_from_startup_history_uses_passed_model_point_without_wall_delta( + self, base_config, event_dictionary + ): + """Стартовая история продолжает с T_end, а не с wall-простоя.""" + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + model_t_end = model_t0 + timedelta(minutes=5) + old_wall_saved_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + config = replace( + base_config, + model_t0=model_t0, + model_time_speed=3600, + tick_seconds=60, + max_session_events=5, + ) + source_generator = EventGenerator(event_dictionary, config) + source_stream = TickStreamGenerator(source_generator) + source_stream.generate_tick(event_budget=10, tick_started_at=model_t_end) + state = source_stream.to_state( + tick=99, + rng_state=source_generator.rng.getstate(), + last_batch_id="history-end", + last_timestamp=model_t_end, + model_timestamp=model_t_end, + wall_timestamp=old_wall_saved_at, + model_time_speed=config.model_time_speed, + model_timezone=config.model_timezone, + model_t0=config.model_t0, + gen_seed=config.seed, + ) + + service = GeneratorService(config) + service.restore_from_startup_history(state, model_t_end=model_t_end) + + assert service._tick == 99 + assert service._model_time == model_t_end + assert service.stream.active_visit_count == source_stream.active_visit_count + + def test_save_state_writes_tick_stream_state(self, base_config): + """Сервис сохраняет снимок тикового слоя.""" + service = GeneratorService(base_config) + service.state_manager = MagicMock() + tick_at = datetime.now(timezone.utc).replace(tzinfo=None) + service.stream.generate_tick(event_budget=10, tick_started_at=tick_at) + service._tick = 1 + service._model_time = tick_at.replace(tzinfo=timezone.utc) + + service._save_state("batch-1") + + saved_state = service.state_manager.save.call_args.args[0] + assert saved_state.version == "3.0" + assert saved_state.model_timestamp == tick_at.replace(tzinfo=timezone.utc) + assert saved_state.wall_timestamp.tzinfo is not None + assert saved_state.model_time_speed == base_config.model_time_speed + assert saved_state.model_timezone == base_config.model_timezone + assert saved_state.model_t0 == base_config.model_t0 + assert saved_state.gen_seed == base_config.seed + assert saved_state.population + assert saved_state.active_visits + service.state_manager.flush.assert_called_once() + + def test_incompatible_seed_state_fails_loudly(self, base_config, event_dictionary, caplog): + """State от другого GEN_SEED даёт жёсткий отказ при продолжении.""" + source_config = replace(base_config, seed=7) + source_generator = EventGenerator(event_dictionary, source_config) + source_stream = TickStreamGenerator(source_generator) + tick_at = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + source_stream.generate_tick(event_budget=10, tick_started_at=tick_at) + state = source_stream.to_state( + tick=5, + rng_state=source_generator.rng.getstate(), + last_batch_id="other-seed", + last_timestamp=tick_at, + model_timestamp=tick_at, + wall_timestamp=datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc), + model_time_speed=base_config.model_time_speed, + model_timezone=base_config.model_timezone, + model_t0=base_config.model_t0, + gen_seed=source_config.seed, + ) + state_manager = MagicMock() + state_manager.load.return_value = state + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None), \ + caplog.at_level(logging.WARNING, logger="generator"): + + service = GeneratorService(base_config) + with pytest.raises(ValueError, match="GEN_SEED.*GEN_STATE_RESET=true"): + service.start() + + assert "Readable generator state is incompatible" in caplog.text + + def test_state_reset_skips_loading_saved_state(self, base_config): + """GEN_STATE_RESET=true запускает сервис с чистого состояния.""" + reset_config = replace(base_config, state_reset=True) + state_manager = MagicMock() + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None): + + service = GeneratorService(reset_config) + service.start() + + state_manager.load.assert_not_called() + assert service._tick == 0 + + def test_known_old_state_version_fails_instead_of_starting_new_world( + self, base_config, caplog + ): + """GEN_STATE_RESET=false не скрывает старый state за чистым стартом.""" + state_manager = MagicMock() + state_manager.load.side_effect = UnsupportedStateVersionError("2.0", "3.0") + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None), \ + caplog.at_level(logging.ERROR, logger="generator"): + + service = GeneratorService(base_config) + with pytest.raises(ValueError, match="state version 2.0.*GEN_STATE_RESET=true"): + service.start() + + assert service._tick == 0 + assert "Readable generator state uses an old format" in caplog.text + + def test_invalid_restored_state_starts_fresh(self, base_config, caplog): + """Сервис не падает, если state ссылается на неизвестный профиль.""" + state_manager = MagicMock() + state_manager.load.return_value = GeneratorState( + tick=9, + rng_state=random.Random(42).getstate(), + last_batch_id="bad-state", + last_timestamp=datetime.now(timezone.utc), + model_timestamp=base_config.model_t0, + wall_timestamp=datetime.now(timezone.utc), + model_time_speed=base_config.model_time_speed, + model_timezone=base_config.model_timezone, + model_t0=base_config.model_t0, + gen_seed=base_config.seed, + population=[ + { + "user_domain_id": "user-unknown", + "seed_click_id": "missing-click-id", + } + ], + active_visits=[], + ) + manifest_manager = MagicMock() + manifest_manager.load.return_value = None + + with patch("clickstream_generator.service.start_http_server"), \ + patch("clickstream_generator.service.ensure_topics"), \ + patch("clickstream_generator.service.KafkaPublisher"), \ + patch("clickstream_generator.service.KafkaBatchHistory"), \ + patch( + "clickstream_generator.service.KafkaStateManager", + return_value=state_manager, + ), \ + patch( + "clickstream_generator.service.KafkaStartupHistoryManifest", + return_value=manifest_manager, + ), \ + patch.object(GeneratorService, "_main_loop", return_value=None), \ + caplog.at_level(logging.WARNING, logger="generator"): + + service = GeneratorService(base_config) + service.start() + + assert service._tick == 0 + assert "State data was invalid" in caplog.text diff --git a/generator/tests/test_service_cleanup.py b/generator/tests/test_service_cleanup.py new file mode 100644 index 0000000..090389f --- /dev/null +++ b/generator/tests/test_service_cleanup.py @@ -0,0 +1,55 @@ +""" +Контракт уборки сервиса генератора. +""" + +from pathlib import Path +import subprocess +import sys + +from generator import ( + Config, + EventDictionary, + EventGenerator, + GeneratorService, + KafkaPublisher, + GeneratorState, +) + + +def test_dockerfile_copies_split_python_modules(): + """Контейнерный запуск видит все модули генератора после разбиения.""" + dockerfile = (Path(__file__).parent.parent / "Dockerfile").read_text(encoding="utf-8") + + assert "COPY src ./src" in dockerfile + assert 'ENV PYTHONPATH="/app/src"' in dockerfile + + +def test_generator_facade_imports_without_pythonpath(): + """Локальный фасад сам находит src-пакет без внешнего PYTHONPATH.""" + generator_dir = Path(__file__).parent.parent + + result = subprocess.run( + [sys.executable, "-c", "import generator"], + cwd=generator_dir, + env={"PATH": ""}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_generator_facade_preserves_public_imports_after_split(): + """Старый импорт из generator работает, но классы живут в отдельных модулях.""" + assert Config.__module__ == "clickstream_generator.config" + assert EventDictionary.__module__ == "clickstream_generator.dictionary" + assert EventGenerator.__module__ == "clickstream_generator.generation" + assert KafkaPublisher.__module__ == "clickstream_generator.kafka_io" + assert GeneratorState.__module__ == "clickstream_generator.state" + assert GeneratorService.__module__ == "clickstream_generator.service" + + +def test_tick_batch_is_not_part_of_pure_visit_generation(): + """Временная сборка тика вынесена из чистой модели одного визита.""" + assert not hasattr(EventGenerator, "generate_tick_batch") diff --git a/generator/tests/test_startup_history_artifact.py b/generator/tests/test_startup_history_artifact.py new file mode 100644 index 0000000..c90d511 --- /dev/null +++ b/generator/tests/test_startup_history_artifact.py @@ -0,0 +1,641 @@ +""" +Тесты портативного артефакта стартовой истории. +""" + +from dataclasses import replace +from datetime import datetime, timezone + +import pytest + +from generator import GeneratorState + + +def _state() -> GeneratorState: + import random + + now = datetime(2026, 1, 1, 1, 0, tzinfo=timezone.utc) + return GeneratorState( + tick=1, + rng_state=random.Random(42).getstate(), + last_batch_id="startup-history-abc", + last_timestamp=now, + model_timestamp=now, + wall_timestamp=now, + model_time_speed=1, + model_timezone="UTC", + model_t0=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + gen_seed=42, + population=[ + { + "user_domain_id": "user-1", + "seed_click_id": "seed-1", + "active_click_id": None, + "last_finished_at": None, + } + ], + ) + + +def _batch() -> dict[str, list[dict]]: + return { + "browser_events": [ + { + "event_id": "event-1", + "click_id": "click-1", + "user_domain_id": "user-1", + "event_timestamp": "2026-01-01 00:00:00.000000", + } + ], + "location_events": [ + { + "event_id": "event-1", + "page_url_path": "/home", + } + ], + "device_events": [ + { + "click_id": "click-1", + "user_domain_id": "user-1", + } + ], + "geo_events": [ + { + "click_id": "click-1", + "country": "RU", + } + ], + } + + +def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config): + """Артефакт хранит события, state и manifest одним проверяемым набором.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + load_startup_history_artifact, + validate_startup_history_artifact, + write_startup_history_artifact, + ) + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest( + config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ), + counters=builder.counters, + state=state, + ) + + artifact = builder.to_artifact(manifest=manifest, state=state) + ( + loaded_state, + loaded_manifest, + loaded_topics, + loaded_raw_topics, + ) = validate_startup_history_artifact( + artifact, + expected_config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ), + ) + + assert artifact["artifact_version"] == "1.0" + assert loaded_state.last_batch_id == state.last_batch_id + assert loaded_manifest["state"]["last_batch_id"] == state.last_batch_id + assert loaded_topics["browser_events"][0]["event_id"] == "event-1" + assert loaded_raw_topics["browser_events"][0]["value_json"].encode("utf-8") == ( + b'{"event_id": "event-1", "click_id": "click-1", ' + b'"user_domain_id": "user-1", ' + b'"event_timestamp": "2026-01-01 00:00:00.000000"}' + ) + + path = base_config.data_dir.parent / "tmp-startup-history-artifact.json" + try: + write_startup_history_artifact(path, artifact) + loaded = load_startup_history_artifact(path) + assert loaded["manifest"]["totals"]["events"] == 1 + finally: + path.unlink(missing_ok=True) + + +def test_manifest_and_artifact_show_launch_profile(base_config): + """Manifest и файл артефакта показывают выбранный профиль запуска.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + load_startup_history_artifact, + write_startup_history_artifact, + ) + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest( + config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + launch_profile="daily-wave", + ), + counters=builder.counters, + state=state, + ) + artifact = builder.to_artifact(manifest=manifest, state=state) + + path = base_config.data_dir.parent / "tmp-startup-history-profile.json" + try: + write_startup_history_artifact(path, artifact) + loaded = load_startup_history_artifact(path) + assert manifest["launch_profile"] == "daily-wave" + assert loaded["manifest"]["launch_profile"] == "daily-wave" + finally: + path.unlink(missing_ok=True) + + +def test_manifest_records_initial_boundary_chain(base_config): + """Новый manifest явно хранит начало и правую границу истории.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + manifest_boundaries, + ) + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest( + config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ), + counters=builder.counters, + state=state, + ) + + assert manifest["boundaries"] == [ + "2026-01-01T00:00:00+00:00", + "2026-01-01T01:00:00+00:00", + ] + assert manifest_boundaries(manifest) == manifest["boundaries"] + + +def test_legacy_manifest_without_boundaries_gets_endpoint_chain(): + """Старый manifest без boundaries читается как пара [T0, T_end].""" + from clickstream_generator.startup_history_artifact import manifest_boundaries + + manifest = { + "model_t0": "2026-01-01T00:00:00+00:00", + "model_t_end": "2026-01-03T00:00:00+00:00", + } + + assert manifest_boundaries(manifest) == [ + "2026-01-01T00:00:00+00:00", + "2026-01-03T00:00:00+00:00", + ] + + +def test_manifest_rejects_boundary_chain_with_wrong_endpoint(): + """Цепочка не может расходиться с текущим model_t_end.""" + from clickstream_generator.startup_history_artifact import manifest_boundaries + + manifest = { + "model_t0": "2026-01-01T00:00:00+00:00", + "model_t_end": "2026-01-03T00:00:00+00:00", + "boundaries": [ + "2026-01-01T00:00:00+00:00", + "2026-01-02T00:00:00+00:00", + ], + } + + with pytest.raises(ValueError, match="model_t_end"): + manifest_boundaries(manifest) + + +def test_build_manifest_rejects_explicit_empty_boundaries(base_config): + """Явно пустая цепочка не подменяется границами по умолчанию.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + ) + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + + with pytest.raises(ValueError, match="T0 и T_end"): + build_manifest(config, builder.counters, state, boundaries=[]) + + +def test_artifact_validation_rejects_mismatched_manifest(base_config): + """Валидация отвергает артефакт, где manifest не совпадает с событиями.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + validate_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest(config=config, counters=builder.counters, state=state) + artifact = builder.to_artifact(manifest=manifest, state=state) + artifact["manifest"]["gen_seed"] = 43 + + with pytest.raises(ValueError, match="GEN_SEED"): + validate_startup_history_artifact(artifact, expected_config=config) + + +def test_manifest_clickhouse_stats_detect_mismatch(): + """Сверка ClickHouse с manifest ловит расхождение контрольных чисел.""" + from clickstream_generator.startup_history_artifact import ( + compare_clickhouse_stats_to_manifest, + ) + + manifest = { + "totals": { + "events": 10, + "visits": 4, + "users": 3, + "min_event_timestamp": "2026-01-01 00:00:00.000000", + "max_event_timestamp": "2026-01-01 00:10:00.000000", + } + } + stats = { + "events": "9", + "visits": "4", + "users": "3", + "min_event_timestamp": "2026-01-01 00:00:00.000000", + "max_event_timestamp": "2026-01-01 00:10:00.000000", + } + + assert compare_clickhouse_stats_to_manifest(manifest, stats) == ["events"] + + +def test_raw_topic_value_can_keep_original_json_bytes(base_config): + """Raw value проверяется как JSON, но импортируется без пересборки строки.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + raw_value = ( + '{"event_timestamp":"2026-01-01 00:00:00.000000",' + '"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}' + ) + artifact["raw_topics"]["browser_events"][0]["value_json"] = raw_value + + class Publisher: + def __init__(self): + self.records = None + + def publish_records(self, topic, records): + if topic == "browser_events": + self.records = records + return len(records), 0 + + def flush(self): + return None + + class CompactWriter: + def save(self, value): + self.saved = value + + def flush(self): + return None + + publisher = Publisher() + import_startup_history_artifact( + artifact, + publisher=publisher, + state_manager=CompactWriter(), + manifest_manager=CompactWriter(), + expected_config=config, + ) + + assert publisher.records[0]["value_json"] == raw_value + + +def test_artifact_v1_requires_raw_topics_for_import(base_config): + """Artifact v1.0 без raw value не может обещать byte-for-byte импорт.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + del artifact["raw_topics"] + + class Publisher: + def publish_records(self, topic, records): + raise AssertionError("publish must not be called") + + with pytest.raises(ValueError, match="raw_topics"): + import_startup_history_artifact( + artifact, + publisher=Publisher(), + state_manager=None, + manifest_manager=None, + expected_config=config, + ) + + +def test_import_rejects_publisher_without_raw_publish_records(base_config): + """Импорт не должен пересобирать dict, если publisher не умеет raw records.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + artifact["raw_topics"]["browser_events"][0]["value_json"] = ( + '{"event_timestamp":"2026-01-01 00:00:00.000000",' + '"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}' + ) + + class DictPublisher: + def publish(self, topic, events): + raise AssertionError("dict publish must not be called") + + def flush(self): + return None + + with pytest.raises(TypeError, match="publish_records"): + import_startup_history_artifact( + artifact, + publisher=DictPublisher(), + state_manager=None, + manifest_manager=None, + expected_config=config, + ) + + +def test_import_rejects_dirty_kafka_topics_before_publish(base_config): + """Повторный импорт не должен дописывать дубли в непустые data-топики.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + + class Publisher: + def publish(self, topic, events): + raise AssertionError("publish must not be called") + + class DirtyTopics: + def assert_data_topics_empty(self): + raise RuntimeError("Kafka data topics are not empty") + + with pytest.raises(RuntimeError, match="not empty"): + import_startup_history_artifact( + artifact, + publisher=Publisher(), + state_manager=None, + manifest_manager=None, + expected_config=config, + topic_inspector=DirtyTopics(), + ) + + +def test_import_rolls_back_kafka_topics_after_partial_publish(base_config): + """При частичной публикации импорт откатывает Kafka-топики до повтора.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + + class PartialPublisher: + def __init__(self): + self.calls = [] + + def publish_records(self, topic, records): + self.calls.append(topic) + if topic == "location_events": + raise RuntimeError("location down") + return len(records), 0 + + class CompactWriter: + def save(self, value): + raise AssertionError("compact topics must not be written") + + class Inspector: + def __init__(self): + self.snapshot = None + self.rollback = None + + def assert_data_topics_empty(self): + return None + + def snapshot_import_topics(self): + self.snapshot = {"topics": ["browser_events", "location_events"]} + return self.snapshot + + def rollback_import_topics(self, snapshot): + self.rollback = snapshot + + publisher = PartialPublisher() + inspector = Inspector() + + with pytest.raises(RuntimeError, match="location down"): + import_startup_history_artifact( + artifact, + publisher=publisher, + state_manager=CompactWriter(), + manifest_manager=CompactWriter(), + expected_config=config, + topic_inspector=inspector, + ) + + assert publisher.calls == ["browser_events", "location_events"] + assert inspector.rollback == inspector.snapshot + + +def test_import_replays_events_and_compact_topics(base_config): + """Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + import_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + artifact = builder.to_artifact( + manifest=build_manifest(config=config, counters=builder.counters, state=state), + state=state, + ) + + class Publisher: + def __init__(self): + self.calls = [] + self.flushed = False + + def publish_records(self, topic, records): + self.calls.append((topic, records)) + return len(records), 0 + + def flush(self): + self.flushed = True + + class CompactWriter: + def __init__(self): + self.saved = None + self.flushed = False + + def save(self, value): + self.saved = value + + def flush(self): + self.flushed = True + + publisher = Publisher() + state_manager = CompactWriter() + manifest_manager = CompactWriter() + + result = import_startup_history_artifact( + artifact, + publisher=publisher, + state_manager=state_manager, + manifest_manager=manifest_manager, + expected_config=config, + ) + + assert [topic for topic, _ in publisher.calls] == [ + "browser_events", + "location_events", + "device_events", + "geo_events", + ] + assert publisher.calls[0][1][0]["value_json"].encode("utf-8") == ( + b'{"event_id": "event-1", "click_id": "click-1", ' + b'"user_domain_id": "user-1", ' + b'"event_timestamp": "2026-01-01 00:00:00.000000"}' + ) + assert result["events"] == 4 + assert state_manager.saved.last_batch_id == state.last_batch_id + assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id + assert publisher.flushed + assert state_manager.flushed + assert manifest_manager.flushed diff --git a/generator/tests/test_state.py b/generator/tests/test_state.py new file mode 100644 index 0000000..e3b2228 --- /dev/null +++ b/generator/tests/test_state.py @@ -0,0 +1,953 @@ +""" +Тесты сохранения и восстановления состояния генератора. +""" +import json +import logging +import random +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from generator import GeneratorState, KafkaStateManager +from clickstream_generator.state import UnsupportedStateVersionError + + +def _make_valid_rng_state(seed: int = 42): + """Создаёт валидный RNG state для тестов.""" + rng = random.Random(seed) + return rng.getstate() + + +def _make_valid_state_data() -> dict: + """Создаёт минимальный валидный state для тестов загрузки.""" + return { + "tick": 42, + "rng_state": list(_make_valid_rng_state(42)), + "last_batch_id": "v2", + "last_timestamp": "2026-06-11T12:00:00+00:00", + "model_timestamp": "2026-01-01T10:00:00+00:00", + "wall_timestamp": "2026-06-11T12:00:00+00:00", + "model_time_speed": 10, + "model_timezone": "UTC", + "model_t0": "2026-01-01T00:00:00+00:00", + "gen_seed": 42, + "version": "3.0", + "population": [ + { + "user_domain_id": "user-1", + "seed_click_id": "seed-1", + "active_click_id": "visit-1", + "last_finished_at": None, + } + ], + "active_visits": [ + { + "user_domain_id": "user-1", + "click_id": "visit-1", + "base_click_id": "seed-1", + "next_index": 1, + "started_at": "2026-06-11T12:00:00", + "offsets_us": [0, 60_000_000], + "page_url_paths": ["/home", "/cart"], + } + ], + "pending_visit_births": 0.5, + } + + +def _minimal_population() -> list[dict]: + return [ + { + "user_domain_id": "user-1", + "seed_click_id": "seed-1", + "active_click_id": None, + "last_finished_at": None, + } + ] + + +def _with_resume_fields(data: dict) -> dict: + """Добавляет обязательные поля state, не связанные с проверяемой ошибкой.""" + return { + **data, + "model_timestamp": "2026-01-01T10:00:00+00:00", + "wall_timestamp": "2024-01-01T00:00:00+00:00", + "model_time_speed": 10, + "model_timezone": "UTC", + "model_t0": "2026-01-01T00:00:00+00:00", + "gen_seed": 42, + } + + +class TestGeneratorState: + """Тесты структуры состояния генератора.""" + + def test_state_creation(self): + """Создание состояния с всеми полями.""" + now = datetime.now(timezone.utc) + rng_state = _make_valid_rng_state(42) + + state = GeneratorState( + tick=42, + rng_state=rng_state, + last_batch_id="abc123", + last_timestamp=now, + model_timestamp=now, + wall_timestamp=now, + model_time_speed=10, + model_timezone="UTC", + model_t0=now, + gen_seed=42, + version="3.0", + ) + + assert state.tick == 42 + assert state.rng_state == rng_state + assert state.last_batch_id == "abc123" + assert state.last_timestamp == now + assert state.model_timestamp == now + assert state.wall_timestamp == now + assert state.model_time_speed == 10 + assert state.model_timezone == "UTC" + assert state.model_t0 == now + assert state.gen_seed == 42 + assert state.version == "3.0" + + def test_default_version(self): + """Новые состояния по умолчанию пишутся в версии 2.""" + now = datetime.now(timezone.utc) + rng_state = _make_valid_rng_state(42) + + state = GeneratorState( + tick=1, + rng_state=rng_state, + last_batch_id="test", + last_timestamp=now, + ) + + assert state.version == "3.0" + + def test_to_dict_serialization(self): + """Сериализация в словарь (JSON-safe, без pickle).""" + now = datetime.now(timezone.utc) + rng_state = _make_valid_rng_state(42) + + state = GeneratorState( + tick=42, + rng_state=rng_state, + last_batch_id="abc123", + last_timestamp=now, + population=_minimal_population(), + ) + + data = state.to_dict() + + assert data["tick"] == 42 + assert data["last_batch_id"] == "abc123" + assert data["last_timestamp"] == now.isoformat() + assert data["model_timestamp"] == now.isoformat() + assert data["wall_timestamp"] == now.isoformat() + assert data["model_time_speed"] == 1.0 + assert data["model_timezone"] == "UTC" + assert data["model_t0"] == now.isoformat() + assert data["gen_seed"] is None + assert data["version"] == "3.0" + + # Проверяем что rng_state сериализован как tuple (JSON-safe, без pickle) + assert "rng_state" in data + # После to_dict rng_state должен быть tuple + assert isinstance(data["rng_state"], tuple) + # Проверяем что можно сериализовать в JSON и восстановить + json_str = json.dumps(data) + restored_data = json.loads(json_str) + restored_state = GeneratorState.from_dict(restored_data) + assert restored_state.rng_state == rng_state + + def test_from_dict_deserialization(self): + """Десериализация из словаря.""" + now = datetime.now(timezone.utc) + rng_state = _make_valid_rng_state(42) + + # Создаём исходное состояние + original = GeneratorState( + tick=42, + rng_state=rng_state, + last_batch_id="abc123", + last_timestamp=now, + population=_minimal_population(), + ) + + # Сериализуем и десериализуем + data = original.to_dict() + restored = GeneratorState.from_dict(data) + + assert restored.tick == original.tick + assert restored.rng_state == original.rng_state + assert restored.last_batch_id == original.last_batch_id + assert restored.last_timestamp == original.last_timestamp + assert restored.model_timestamp == original.model_timestamp + assert restored.wall_timestamp == original.wall_timestamp + assert restored.model_time_speed == original.model_time_speed + assert restored.model_timezone == original.model_timezone + assert restored.model_t0 == original.model_t0 + assert restored.gen_seed == original.gen_seed + assert restored.version == original.version + + def test_roundtrip_with_real_random(self): + """Проверка что RNG state действительно восстанавливает последовательность.""" + # Создаём генератор и делаем несколько вызовов + rng = random.Random(12345) + values_before = [rng.random() for _ in range(5)] + + # Сохраняем состояние + state = GeneratorState( + tick=10, + rng_state=rng.getstate(), + last_batch_id="test", + last_timestamp=datetime.now(timezone.utc), + population=_minimal_population(), + ) + + # Десериализуем + data = state.to_dict() + restored_state = GeneratorState.from_dict(data) + + # Создаём новый генератор с восстановленным состоянием + new_rng = random.Random() + new_rng.setstate(restored_state.rng_state) + + # Проверяем что следующие значения совпадают + values_after = [new_rng.random() for _ in range(5)] + + # Если state восстановлен корректно, values должны совпадать + # Но т.к. rng уже "прокручен" на 5 значений, берём следующие + rng.setstate(state.rng_state) + next_values = [rng.random() for _ in range(5)] + + assert next_values == values_after + + def test_state_roundtrip_keeps_population_and_active_visits(self): + """State хранит популяцию и активные визиты в JSON.""" + rng_state = _make_valid_rng_state(42) + state = GeneratorState( + tick=7, + rng_state=rng_state, + last_batch_id="batch-7", + last_timestamp=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc), + model_timestamp=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc), + wall_timestamp=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc), + model_time_speed=10, + model_timezone="Europe/Moscow", + model_t0=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc), + gen_seed=42, + version="3.0", + population=[ + { + "user_domain_id": "user-1", + "seed_click_id": "seed-1", + "active_click_id": "visit-1", + "last_finished_at": "2026-06-11T11:30:00", + } + ], + active_visits=[ + { + "user_domain_id": "user-1", + "click_id": "visit-1", + "base_click_id": "seed-1", + "next_index": 1, + "started_at": "2026-06-11T12:00:00", + "offsets_us": [0, 60_000_000], + "page_url_paths": ["/home", "/cart"], + } + ], + pending_visit_births=0.5, + ) + + restored = GeneratorState.from_dict(json.loads(json.dumps(state.to_dict()))) + + assert restored.version == "3.0" + assert restored.tick == state.tick + assert restored.rng_state == rng_state + assert restored.population == state.population + assert restored.active_visits == state.active_visits + assert restored.pending_visit_births == 0.5 + assert restored.model_timestamp == state.model_timestamp + assert restored.wall_timestamp == state.wall_timestamp + assert restored.model_time_speed == 10 + assert restored.model_timezone == "Europe/Moscow" + assert restored.model_t0 == state.model_t0 + assert restored.gen_seed == 42 + + +class TestGeneratorStateValidation: + """Тесты валидации состояния и graceful degradation.""" + + def test_from_dict_missing_version_raises(self): + """from_dict выбрасывает исключение при отсутствии версии.""" + data = { + "tick": 42, + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + } + + with pytest.raises(ValueError, match="version"): + GeneratorState.from_dict(data) + + def test_from_dict_invalid_rng_state_raises(self): + """from_dict выбрасывает исключение при невалидном rng_state.""" + data = _with_resume_fields({ + "tick": 42, + "rng_state": "not_a_tuple", + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + }) + + with pytest.raises(ValueError): + GeneratorState.from_dict(data) + + def test_from_dict_insufficient_rng_state_raises(self): + """from_dict выбрасывает исключение при коротком rng_state.""" + data = _with_resume_fields({ + "tick": 42, + "rng_state": [1], # Слишком короткий + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + }) + + with pytest.raises(ValueError): + GeneratorState.from_dict(data) + + def test_from_dict_invalid_setstate_raises(self): + """from_dict выбрасывает исключение если setstate падает.""" + data = _with_resume_fields({ + "tick": 42, + "rng_state": [999, [1, 2, 3], None], # Невалидный state + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + }) + + with pytest.raises(ValueError): + GeneratorState.from_dict(data) + + def test_from_dict_safe_returns_none_on_invalid(self): + """from_dict_safe возвращает None при невалидных данных.""" + data = _with_resume_fields({ + "tick": 42, + "rng_state": "invalid", + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + }) + + result = GeneratorState.from_dict_safe(data) + assert result is None + + def test_from_dict_safe_returns_state_on_valid(self): + """from_dict_safe возвращает state при валидных данных.""" + rng = random.Random(42) + data = { + "tick": 42, + "rng_state": list(rng.getstate()), # JSON сериализует tuple как list + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "model_timestamp": "2026-01-01T10:00:00+00:00", + "wall_timestamp": "2024-01-01T00:00:00+00:00", + "model_time_speed": 10, + "model_timezone": "UTC", + "model_t0": "2026-01-01T00:00:00+00:00", + "gen_seed": 42, + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + } + + result = GeneratorState.from_dict_safe(data) + assert result is not None + assert result.tick == 42 + assert result.model_timestamp == datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) + assert result.gen_seed == 42 + + def test_from_dict_safe_returns_none_on_invalid_gen_seed(self): + """gen_seed в JSON state должен быть числом или null.""" + data = _make_valid_state_data() + data["gen_seed"] = "42" + + result = GeneratorState.from_dict_safe(data) + + assert result is None + + def test_from_dict_safe_returns_none_on_bool_model_time_speed(self): + """model_time_speed не принимает bool как числовую скорость.""" + data = _make_valid_state_data() + data["model_time_speed"] = True + + result = GeneratorState.from_dict_safe(data) + + assert result is None + + def test_from_dict_safe_returns_none_without_model_resume_fields(self): + """State без связки модельного и настенного времени несовместим.""" + rng = random.Random(42) + data = { + "tick": 42, + "rng_state": list(rng.getstate()), + "last_batch_id": "test", + "last_timestamp": "2024-01-01T00:00:00+00:00", + "version": "3.0", + "population": _minimal_population(), + "active_visits": [], + } + + result = GeneratorState.from_dict_safe(data) + + assert result is None + + def test_from_dict_rejects_old_state_without_version(self): + """from_dict не восстанавливает старое state v1 без версии.""" + rng = random.Random(42) + data = { + "rng_state": list(rng.getstate()), + } + + with pytest.raises(ValueError, match="version"): + GeneratorState.from_dict(data) + + def test_from_dict_rejects_known_old_state_version_loudly(self): + """Известная старая версия state даёт отдельную ошибку миграции.""" + data = _make_valid_state_data() + data["version"] = "2.0" + + with pytest.raises(UnsupportedStateVersionError, match="2.0.*3.0"): + GeneratorState.from_dict(data) + + def test_from_dict_rejects_future_state_version_loudly(self): + """Любая неизвестная версия state требует решения оператора.""" + data = _make_valid_state_data() + data["version"] = "4.0" + + with pytest.raises(UnsupportedStateVersionError, match="4.0.*3.0"): + GeneratorState.from_dict(data) + + def test_from_dict_safe_does_not_hide_old_state_version(self): + """Безопасная загрузка не превращает старый state в тихий fresh-start.""" + data = _make_valid_state_data() + data["version"] = "2.0" + + with pytest.raises(UnsupportedStateVersionError, match="2.0.*3.0"): + GeneratorState.from_dict_safe(data) + + +class TestKafkaStateManager: + """Тесты менеджера состояния.""" + + def test_init(self): + """Инициализация менеджера.""" + with patch("generator._import_kafka") as mock_import: + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + manager = KafkaStateManager("kafka:29092") + + assert manager.bootstrap_servers == "kafka:29092" + assert manager.STATE_TOPIC == "generator_state" + assert manager.STATE_KEY == "default" + mock_producer_class.assert_called_once() + + def test_save(self): + """Сохранение состояния.""" + with patch("generator._import_kafka") as mock_import: + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, None) + + manager = KafkaStateManager("kafka:29092") + + now = datetime.now(timezone.utc) + state = GeneratorState( + tick=42, + rng_state=_make_valid_rng_state(42), + last_batch_id="abc123", + last_timestamp=now, + ) + + manager.save(state) + + # Проверяем что producer.send был вызван + mock_producer.send.assert_called_once() + call_args = mock_producer.send.call_args + assert call_args[0][0] == "generator_state" # topic + assert call_args[1]["key"] == "default" # key + assert call_args[1]["value"]["tick"] == 42 + assert call_args[1]["value"]["last_batch_id"] == "abc123" + + def test_load_no_messages(self): + """Загрузка при отсутствии состояния.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + # Мокаем пустой consumer (нет сообщений) + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + result = manager.load() + + assert result is None + + def test_load_with_messages(self): + """Загрузка существующего состояния.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + now = datetime.now(timezone.utc) + state = GeneratorState( + tick=100, + rng_state=_make_valid_rng_state(100), + last_batch_id="xyz789", + last_timestamp=now, + version="3.0", + population=_minimal_population(), + ) + + # Мокаем consumer с сообщением + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = state.to_dict() + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + result = manager.load() + + assert result is not None + assert result.tick == 100 + assert result.last_batch_id == "xyz789" + + def test_load_invalid_state_returns_none(self): + """Загрузка невалидного state возвращает None (graceful degradation).""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + # Мокаем consumer с невалидным сообщением текущей версии. + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = { + "tick": 42, + "rng_state": "invalid", + "version": "3.0", + } + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + result = manager.load() + + # Должно вернуть None из-за невалидного state + assert result is None + + def test_load_invalid_nested_state_returns_none(self, caplog): + """Битое state с валидным rng_state даёт чистый старт.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = { + "tick": 42, + "rng_state": list(_make_valid_rng_state(42)), + "last_batch_id": "bad-v2", + "last_timestamp": "2026-06-11T12:00:00+00:00", + "version": "3.0", + "population": [{"user_domain_id": "user-1"}], + "active_visits": [ + { + "user_domain_id": "user-1", + "click_id": "visit-1", + "base_click_id": "seed-1", + "next_index": 1, + "started_at": "2026-06-11T12:00:00", + "offsets_us": [0], + } + ], + } + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_empty_population_returns_none(self, caplog): + """Пустая популяция в state не восстанавливается.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + bad_state["population"] = [] + bad_state["active_visits"] = [] + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_bad_pending_births_returns_none(self, caplog): + """Нечисловой pending_visit_births в state не восстанавливается.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + bad_state["pending_visit_births"] = "bad" + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_active_visit_without_base_click_id_returns_none(self, caplog): + """Активный визит без донора фактуры не восстанавливается.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + del bad_state["active_visits"][0]["base_click_id"] + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "base_click_id" in caplog.text + + def test_load_active_visit_with_unknown_user_returns_none(self, caplog): + """Активный визит должен ссылаться на пользователя из популяции.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + bad_state["active_visits"][0]["user_domain_id"] = "missing-user" + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_active_visit_with_conflicting_click_id_returns_none(self, caplog): + """active_click_id пользователя не должен противоречить визиту.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + bad_state["population"][0]["active_click_id"] = "other-visit" + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_population_ghost_active_click_id_returns_none(self, caplog): + """active_click_id пользователя должен иметь соответствующий активный визит.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + bad_state = _make_valid_state_data() + bad_state["population"][0]["active_click_id"] = "ghost" + bad_state["active_visits"] = [] + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = bad_state + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.WARNING, logger="generator"): + result = manager.load() + + assert result is None + assert "Invalid state" in caplog.text + + def test_load_version_1_state_fails_loudly(self, caplog): + """Старое state v1 не скрывается за чистым стартом.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + old_state = GeneratorState( + tick=100, + rng_state=_make_valid_rng_state(100), + last_batch_id="old", + last_timestamp=datetime.now(timezone.utc), + version="1.0", + ) + + mock_message = MagicMock() + mock_message.key = b"default" + mock_message.value = old_state.to_dict() + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + with caplog.at_level(logging.ERROR, logger="generator"): + with pytest.raises(UnsupportedStateVersionError, match="1.0.*3.0"): + manager.load() + + assert "version" in caplog.text + + def test_load_ignores_wrong_key(self): + """Загрузка игнорирует сообщения с другим ключом.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + # Мокаем consumer с сообщением с неправильным ключом + mock_message = MagicMock() + mock_message.key = b"other_generator" # Другой ключ + mock_message.value = {"tick": 999} + + mock_consumer = MagicMock() + mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message])) + mock_consumer_class.return_value = mock_consumer + + manager = KafkaStateManager("kafka:29092") + result = manager.load() + + # Не должно быть загружено, т.к. ключ не совпадает + assert result is None + + def test_load_exception_returns_none(self): + """При ошибке загрузки возвращается None.""" + with patch("generator._import_kafka") as mock_import, \ + patch("kafka.KafkaConsumer") as mock_consumer_class: + + mock_producer_class = MagicMock() + mock_import.return_value = (mock_producer_class, None) + + # Симулируем ошибку при создании consumer + mock_consumer_class.side_effect = Exception("Kafka unavailable") + + manager = KafkaStateManager("kafka:29092") + result = manager.load() + + assert result is None + + def test_flush(self): + """Сброс буфера.""" + with patch("generator._import_kafka") as mock_import: + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, None) + + manager = KafkaStateManager("kafka:29092") + manager.flush() + + mock_producer.flush.assert_called_once() + + def test_close(self): + """Закрытие соединения.""" + with patch("generator._import_kafka") as mock_import: + mock_producer = MagicMock() + mock_producer_class = MagicMock(return_value=mock_producer) + mock_import.return_value = (mock_producer_class, None) + + manager = KafkaStateManager("kafka:29092") + manager.close() + + mock_producer.close.assert_called_once() + + +class TestJsonSafeState: + """Тесты JSON-safe сериализации state (без pickle).""" + + def test_json_roundtrip_with_nested_tuples(self): + """Проверка что nested tuple корректно восстанавливается после JSON.""" + from generator import _nested_list_to_tuple + + # Симулируем что получаем после json.loads() - все tuple становятся list + json_loaded = [3, [1, 2, 3], None] + + result = _nested_list_to_tuple(json_loaded) + + assert result == (3, (1, 2, 3), None) + assert isinstance(result, tuple) + assert isinstance(result[1], tuple) + + def test_json_roundtrip_rng_state(self): + """Полный цикл: rng.getstate() -> JSON -> from_dict -> setstate.""" + rng = random.Random(42) + # Делаем несколько вызовов + values_before = [rng.random() for _ in range(10)] + + # Сохраняем state + state = GeneratorState( + tick=100, + rng_state=rng.getstate(), + last_batch_id="test123", + last_timestamp=datetime.now(timezone.utc), + population=_minimal_population(), + ) + + # Сериализуем через JSON (как в Kafka) + data = state.to_dict() + json_str = json.dumps(data) + restored_data = json.loads(json_str) + + # Восстанавливаем + restored_state = GeneratorState.from_dict(restored_data) + + # Проверяем что RNG state восстановлен корректно + rng2 = random.Random() + rng2.setstate(restored_state.rng_state) + + # Проверяем что следующие значения совпадают + values_after = [rng2.random() for _ in range(5)] + + # Оригинальный RNG должен дать те же значения + values_expected = [rng.random() for _ in range(5)] + + assert values_after == values_expected + + +class TestWithRetry: + """Тесты функции _with_retry.""" + + def test_success_on_first_attempt(self): + """Успех с первой попытки.""" + from generator import _with_retry + + operation = MagicMock(return_value="success") + + result = _with_retry(operation, max_retries=3, base_delay=0.01) + + assert result == "success" + assert operation.call_count == 1 + + def test_success_after_retries(self): + """Успех после нескольких попыток.""" + from generator import _with_retry + + operation = MagicMock(side_effect=[Exception("fail1"), Exception("fail2"), "success"]) + + result = _with_retry(operation, max_retries=3, base_delay=0.01) + + assert result == "success" + assert operation.call_count == 3 + + def test_failure_after_all_retries(self): + """Исчерпание всех попыток.""" + from generator import _with_retry + + operation = MagicMock(side_effect=Exception("always fails")) + + with pytest.raises(Exception, match="always fails"): + _with_retry(operation, max_retries=3, base_delay=0.01) + + assert operation.call_count == 3 diff --git a/plans/generator_demo_stream_plan.md b/plans/generator_demo_stream_plan.md new file mode 100644 index 0000000..212fead --- /dev/null +++ b/plans/generator_demo_stream_plan.md @@ -0,0 +1,221 @@ +# План реализации: простой автономный генератор (rev5) + +Дата ревизии: 14 февраля 2026. + +--- + +## 1) Цель + +Сделать **простой** генератор, который: + +- работает автономно и не зависит от потребителей; +- может стабильно работать несколько часов; +- публикует данные в текущие Kafka-топики по существующему контракту; +- реально внедряется за короткий срок, без «проекта на недели». + +--- + +## 2) Что делаем в MVP (и что не делаем) + +### Делаем + +- один режим генерации; +- один автономный сервис (контейнер); +- простая конфигурация через env; +- базовые метрики и логи; +- экспорт метрик в Prometheus; +- минимальная история запусков. + +### Не делаем в MVP + +- много сценариев (`promo/incident/...`); +- сложные state machines; +- сложный replay; +- сложную оркестрацию с зависимостями от ETL; +- «идеальный прод» (exactly-once и т.д.). + +--- + +## 3) Архитектура MVP + +`generator-service -> Kafka topics -> (потребители отдельно)` + +Генератор не вызывает Airflow DAG-и и не ждёт их. +Потребители запускаются по своему расписанию/логике. + +--- + +## 4) Единственный режим генерации: `steady-stream` + +Логика режима: + +- публикуем в Kafka **постепенно**, короткими тиками (например, каждые 1-10 секунд); +- на каждом тике отправляем небольшую порцию сообщений; +- держим целевую интенсивность в `events/min`, а не крупный минутный batch; +- распределяем события по 4 топикам: + - `browser_events` + - `location_events` + - `device_events` + - `geo_events` +- слегка «оживляем» поток: + - варьируем объём в небольшом диапазоне; + - обновляем `event_timestamp`; + - сохраняем реалистичные связи `event_id <-> location`, `click_id <-> device/geo`. + +Этого достаточно, чтобы стенд жил часами, выглядел как реальный streaming и не создавал искусственных «пакетов раз в минуту». + +### 4.1 Минимальная статистическая модель (Poisson) + +Чтобы линия не была «ровной», используем простую интенсивность: + +- базовая интенсивность в минуту: `lambda_minute` (например, 200 событий/мин); +- для текущего тика: + - `lambda_tick = lambda_minute * hour_factor(t) * tick_seconds / 60`; + - `N_t ~ Poisson(lambda_tick)`. + +Где `hour_factor(t)` можно сделать очень простым: + +- дневное окно: `1.2` +- ночное окно: `0.7` +- остальное время: `1.0` + +Плюс добавляем «защиту от шума»: + +- `N_min` и `N_max` (жёсткие границы); +- опционально короткое сглаживание по 3 последним тикам. + +Итог: поведение уже похоже на живой поток, но код остаётся компактным. + +Важно: режим «раз в минуту» не удаляем полностью, но рассматриваем только как опцию (`GEN_TICK_SECONDS=60`) для контролируемых демо. + +--- + +## 5) Как упростить реализацию + +Чтобы не писать сложную генеративную модель: + +1. Берём существующие JSONL как «базовый словарь» валидных событий. +2. На каждом тике семплируем записи из этого словаря. +3. Перегенерируем только необходимые поля (`event_id`, `click_id`, `event_timestamp`) с сохранением связности. +4. Публикуем в Kafka. + +Плюс: +- быстро; +- совместимо с текущим ODS-парсингом; +- минимум риска «сломать контракт». + +--- + +## 6) Минимальная конфигурация сервиса + +Через env: + +- `GEN_TICK_SECONDS` (по умолчанию `5`) +- `GEN_JITTER_PCT` (например, `20`) +- `GEN_SEED` (для воспроизводимости) +- `KAFKA_BOOTSTRAP_SERVERS` +- `GEN_LAMBDA_BASE_PER_MIN` (базовый `lambda` для Poisson) +- `GEN_MIN_EVENTS_PER_TICK` (нижняя граница) +- `GEN_MAX_EVENTS_PER_TICK` (верхняя граница) +- `GEN_METRICS_PORT` (порт HTTP-эндпоинта `/metrics`, по умолчанию `9109`) + +Опционально: + +- `GEN_ENABLED` (быстро включать/выключать цикл) +- `GEN_HOUR_PROFILE` (простая карта коэффициентов по часам) +- `GEN_TICK_SECONDS=60` (опциональный режим «раз в минуту») + +--- + +## 7) Минимальная наблюдаемость + +### Логи + +- старт/стоп сервиса; +- batch_id, объём отправки по топикам; +- длительность тика; +- ошибки публикации. + +### Метрики (минимум) + +- `generator_events_total` +- `generator_publish_errors_total` +- `generator_tick_duration_seconds` +- `generator_last_success_timestamp` + +### Интеграция с Prometheus (MVP) + +- генератор поднимает HTTP-эндпоинт `/metrics` (например, на `:9109`); +- в `configs/prometheus.yml` добавляется `scrape_config` для job `generator`; +- target внутри docker-сети: `generator:9109`; +- в runbook добавляется проверка, что метрики генератора видны в Prometheus. + +### Минимальная история + +Таблица `meta.generator_batches` (или файл/лог на первом шаге): + +- `batch_id` +- `started_at` +- `finished_at` +- `sent_total` +- `sent_browser/location/device/geo` +- `status` + +--- + +## 8) План внедрения (короткий и реалистичный) + +### Шаг 1. Skeleton сервиса + +- отдельная папка `generator/`; +- бесконечный цикл с тиком; +- подключение к Kafka; +- публикация в 4 топика. + +Готово, если: +- сервис работает 1+ час без падений. + +### Шаг 2. Контрактная генерация из словаря + +- чтение базовых JSONL; +- семплирование + обновление ключевых полей; +- проверка, что downstream не ломается. + +Готово, если: +- ODS/DDS наполняются штатно. + +### Шаг 3. Логи/метрики/история batch + +- добавить базовые метрики; +- подключить экспорт метрик в Prometheus (`/metrics` + `scrape_config`); +- писать историю batch; +- оформить runbook запуска/проверки. + +Готово, если: +- можно показать историю работы стенда за несколько часов. + +--- + +## 9) Критерии успеха MVP + +MVP успешен, если: + +1. Генератор автономно работает 2-4 часа. +2. Потребители можно останавливать/запускать отдельно, генератор продолжает работу. +3. Данные остаются совместимыми с текущим пайплайном. +4. Поток публикуется равномерно малыми порциями (без искусственного минутного burst). +5. Есть история batch-ов для учебного разбора. +6. Метрики генератора стабильно собираются Prometheus. + +--- + +## 10) Что делаем потом (после рабочего MVP) + +Когда простой генератор стабильно работает: + +1. Добавляем второй режим (например, «spike»). +2. Делаем инкрементальных потребителей и расписание ETL. +3. Расширяем учебные кейсы по observability и DQ. +4. Усиливаем статистику: например, Gamma-Poisson/Negative Binomial для более «рваного» трафика. + +Принцип: сначала работающий простой baseline, потом расширение. diff --git a/plans/incremental-etl-v2.md b/plans/incremental-etl-v2.md new file mode 100644 index 0000000..695e38b --- /dev/null +++ b/plans/incremental-etl-v2.md @@ -0,0 +1,452 @@ +# План: Инкрементальная загрузка ETL v2 + +**Дата:** 15 февраля 2026 +**Статус:** Утверждён +**Цель:** Переход с полной перезагрузки на инкрементальную для учебного стенда + +--- + +## 1. Общие принципы + +### 1.1 Watermark +- **Тип:** `event_ts` (бизнес-время события) +- **Хранение:** Таблица `meta.etl_watermarks_history` +- **Гранулярность:** Отдельный watermark на каждую таблицу +- **Lookback:** 5 минут для обработки late arrivals + +### 1.2 Обработка ошибок +- При падении загрузки watermark **НЕ обновляется** +- При retry данные дедуплицируются через `ReplacingMergeTree` +- Транзакционность: все слои в рамках одного DAG run + +### 1.3 Параллельность +- **ODS слой:** 4 таблицы грузятся **параллельно** (browser, location, device, geo) +- **DDS слой:** Последовательно (зависимости между таблицами) +- **DM слой:** Последовательно + +### 1.4 Late Arrivals +- Демонстрация через **код + логи** (без отдельного DAG) +- Комментарии в SQL объясняют логику lookback +- Логирование количества "поздних" событий + +--- + +## 2. Структура хранилища watermark + +### 2.1 DDL + +```sql +-- meta/etl_watermarks_history.sql +CREATE TABLE IF NOT EXISTS meta.etl_watermarks_history ( + dag_id String, + dag_run_id String, + table_name String, + watermark DateTime64(6), + rows_processed UInt64, + created_at DateTime64(3) DEFAULT now64(3) +) ENGINE = ReplacingMergeTree(created_at) +ORDER BY (dag_id, table_name, created_at) +TTL created_at + INTERVAL 30 DAY; -- Автоудаление старше 30 дней +``` + +### 2.2 View для получения актуального watermark + +```sql +CREATE VIEW IF NOT EXISTS meta.v_latest_watermarks AS +SELECT + dag_id, + table_name, + argMax(watermark, created_at) as last_watermark, + argMax(rows_processed, created_at) as last_rows_processed, + max(created_at) as last_updated +FROM meta.etl_watermarks_history +GROUP BY dag_id, table_name; +``` + +--- + +## 3. Модификация SQL загрузок + +### 3.1 Шаблон для всех слоёв + +Каждый SQL файл получает параметры: +- `{{ dag_id }}` - ID DAG +- `{{ dag_run_id }}` - ID запуска +- `{{ lookback_minutes }}` - 5 (по умолчанию) +- `{{ watermark_table }}` - имя таблицы назначения + +### 3.2 STG → ODS (Параллельная загрузка) + +**Файлы:** +- `sql/ods/20_stg_to_ods_browser.sql` +- `sql/ods/20_stg_to_ods_location.sql` +- `sql/ods/20_stg_to_ods_device.sql` +- `sql/ods/20_stg_to_ods_geo.sql` + +```sql +-- ============================================================================ +-- Инкрементальная загрузка STG → ODS (browser_event) +-- +-- Логика: +-- 1. Получаем последний watermark для таблицы +-- 2. Вычитаем lookback window (для late arrivals) +-- 3. Загружаем данные с event_ts > effective_watermark +-- 4. Дедупликация через ReplacingMergeTree(event_ts) +-- +-- Late Arrivals: +-- Если событие пришло с задержкой (например, network latency), +-- оно попадёт в следующую загрузку благодаря lookback. +-- ReplacingMergeTree позаботится о дубликатах. +-- ============================================================================ + +WITH + -- Получаем последний обработанный watermark + last_watermark AS ( + SELECT COALESCE( + max(last_watermark), + toDateTime64('1970-01-01 00:00:00.000000', 6) + ) + FROM meta.v_latest_watermarks + WHERE dag_id = '{{ dag_id }}' + AND table_name = '{{ watermark_table }}' + ), + + -- Добавляем lookback window для late arrivals + -- Это гарантирует, что события с задержкой не потеряются + effective_watermark AS ( + SELECT last_watermark - INTERVAL {{ lookback_minutes }} MINUTE + FROM last_watermark + ), + + -- Считаем сколько "поздних" событий мы обработаем повторно + stats AS ( + SELECT + count() as total_rows, + countIf(event_ts <= (SELECT * FROM last_watermark)) as late_arrival_rows + FROM stg.browser_raw + WHERE event_ts > (SELECT * FROM effective_watermark) + ) + +-- Загрузка данных +INSERT INTO ods.browser_event +SELECT + event_id, + click_id, + user_id, + event_type, + event_ts, + page_url, + referrer_url, + -- ... остальные поля + src_ingest_ts, + kafka_topic, + kafka_partition, + kafka_offset +FROM stg.browser_raw +WHERE event_ts > (SELECT * FROM effective_watermark); + +-- Сохраняем watermark только если загрузка успешна +-- Этот INSERT выполняется отдельным task в Airflow +``` + +### 3.3 ODS → DDS (Последовательная загрузка) + +**Файл:** `sql/dds/30_ods_to_dds_incremental.sql` + +Аналогичная структура, но: +- Источник: таблицы ODS +- Watermark: `src_ingest_ts` +- Дедупликация: `ReplacingMergeTree(dds_update_ts)` +- Загрузка таблиц последовательно (click → event) + +### 3.4 DDS → DM (Последовательная загрузка) + +**Файл:** `sql/dm/40_dds_to_dm_incremental.sql` + +- Источник: таблицы DDS +- Watermark: `dds_update_ts` +- Витрины пересчитываются за период [watermark - lookback, now] + +--- + +## 4. Модификация Airflow DAG + +### 4.1 Параметры + +```python +params={ + "mode": Param( + "incremental", + enum=["incremental", "full_refresh"], + description="Режим загрузки" + ), + "lookback_minutes": Param( + 5, + type="integer", + minimum=0, + maximum=60, + description="Lookback window для late arrivals (минуты)" + ), +} +``` + +### 4.2 Структура DAG (псевдокод) + +```python +with DAG(...) as dag: + + # Task 1: Проверка таблиц + check_tables = ClickHouseOperator(...) + + # Task 2: Получение watermark для всех ODS таблиц + get_watermark_ods_browser = PythonOperator(..., op_kwargs={'table': 'ods.browser_event'}) + get_watermark_ods_location = PythonOperator(..., op_kwargs={'table': 'ods.location_event'}) + get_watermark_ods_device = PythonOperator(..., op_kwargs={'table': 'ods.device_by_click'}) + get_watermark_ods_geo = PythonOperator(..., op_kwargs={'table': 'ods.geo_by_click'}) + + # Task 3: Параллельная загрузка STG → ODS + load_ods_browser = ClickHouseOperator(..., sql='sql/ods/20_stg_to_ods_browser.sql') + load_ods_location = ClickHouseOperator(..., sql='sql/ods/20_stg_to_ods_location.sql') + load_ods_device = ClickHouseOperator(..., sql='sql/ods/20_stg_to_ods_device.sql') + load_ods_geo = ClickHouseOperator(..., sql='sql/ods/20_stg_to_ods_geo.sql') + + # Task 4: Сохранение watermark (только при успехе) + save_watermark_ods_browser = PythonOperator( + ..., + trigger_rule='all_success' + ) + save_watermark_ods_location = PythonOperator(..., trigger_rule='all_success') + save_watermark_ods_device = PythonOperator(..., trigger_rule='all_success') + save_watermark_ods_geo = PythonOperator(..., trigger_rule='all_success') + + # Task 5: Последовательная загрузка ODS → DDS + get_watermark_dds = PythonOperator(...) + load_dds_click = ClickHouseOperator(...) + save_watermark_dds_click = PythonOperator(..., trigger_rule='all_success') + load_dds_event = ClickHouseOperator(...) + save_watermark_dds_event = PythonOperator(..., trigger_rule='all_success') + + # Task 6: Загрузка DDS → DM + get_watermark_dm = PythonOperator(...) + load_dm = ClickHouseOperator(...) + save_watermark_dm = PythonOperator(..., trigger_rule='all_success') + + # Зависимости + check_tables >> [get_watermark_ods_browser, get_watermark_ods_location, + get_watermark_ods_device, get_watermark_ods_geo] + + get_watermark_ods_browser >> load_ods_browser >> save_watermark_ods_browser + get_watermark_ods_location >> load_ods_location >> save_watermark_ods_location + get_watermark_ods_device >> load_ods_device >> save_watermark_ods_device + get_watermark_ods_geo >> load_ods_geo >> save_watermark_ods_geo + + [save_watermark_ods_browser, save_watermark_ods_location, + save_watermark_ods_device, save_watermark_ods_geo] >> get_watermark_dds + + get_watermark_dds >> load_dds_click >> save_watermark_dds_click >> load_dds_event >> save_watermark_dds_event + + save_watermark_dds_event >> get_watermark_dm >> load_dm >> save_watermark_dm +``` + +### 4.3 Функции watermark + +```python +def get_watermark(table: str, **context) -> str: + """ + Получает последний watermark для таблицы. + Если таблица пустая или watermark не найден - возвращает '1970-01-01'. + """ + sql = f""" + SELECT COALESCE( + max(last_watermark), + toDateTime64('1970-01-01 00:00:00.000000', 6) + ) + FROM meta.v_latest_watermarks + WHERE dag_id = '{context['dag'].dag_id}' + AND table_name = '{table}' + """ + result = execute_sql(sql) + watermark = result[0][0] if result else '1970-01-01 00:00:00.000000' + + context['ti'].log.info(f"Watermark for {table}: {watermark}") + return watermark + +def save_watermark(table: str, **context): + """ + Сохраняет watermark после успешной загрузки. + Выполняется только если upstream task успешен. + """ + sql = f""" + SELECT max(event_ts), count() + FROM {table} + WHERE src_ingest_ts > now() - INTERVAL 10 MINUTE + """ + result = execute_sql(sql) + watermark, rows = result[0] if result else ('1970-01-01', 0) + + insert_sql = f""" + INSERT INTO meta.etl_watermarks_history + (dag_id, dag_run_id, table_name, watermark, rows_processed) + VALUES + ('{context['dag'].dag_id}', '{context['run_id']}', '{table}', '{watermark}', {rows}) + """ + execute_sql(insert_sql) + + context['ti'].log.info( + f"Saved watermark for {table}: {watermark} ({rows} rows)" + ) +``` + +--- + +## 5. Алерты (Grafana) + +### 5.1 Late Arrivals Alert + +**Условие:** Процент late arrivals > 10% за последний час + +**PromQL:** +```promql +( + sum(increase(generator_events_total[1h])) + - + sum(increase(generator_events_total[1h] offset 5m)) +) +/ +sum(increase(generator_events_total[1h])) * 100 > 10 +``` + +**Сообщение:** +"Обнаружено высокое количество late arrivals (>10%). Проверьте задержки в сети или нагрузку на generator." + +### 5.2 Stale Watermark Alert + +**Условие:** Watermark не обновлялся > 15 минут + +**PromQL:** +```promql +time() - max(etl_watermark_timestamp) > 900 +``` + +### 5.3 Ошибки загрузки Alert + +**Условие:** Error rate > 0 за последние 5 минут + +**PromQL:** +```promql +increase(airflow_dag_run_failures_total{dag_id="etl_pipeline"}[5m]) > 0 +``` + +--- + +## 6. Демонстрация Late Arrivals + +### 6.1 Комментарии в коде + +В SQL файлах: +```sql +-- Late Arrivals: +-- Если событие пришло с задержкой (например, network latency), +-- оно попадёт в следующую загрузку благодаря lookback. +-- ReplacingMergeTree позаботится о дубликатах. +``` + +### 6.2 Логирование + +```python +# Логируем количество late arrivals +if late_arrival_rows > 0: + logger.info( + f"Processing {late_arrival_rows} late arrival events " + f"(received after watermark but within lookback window)" + ) +``` + +### 6.3 Визуализация в Grafana + +Добавить панель: +- **Late Arrivals Rate:** Процент событий, обработанных повторно +- **Lookback Efficiency:** Сколько событий в lookback window vs новых + +--- + +## 7. Тестирование + +### 7.1 Сценарии + +1. **Первый запуск (empty watermark)** + - Ожидаемое поведение: Загружаются все данные с 1970 года + - Проверка: `SELECT count() FROM ods.browser_event` > 0 + +2. **Второй запуск (нет новых данных)** + - Ожидаемое поведение: Загружено 0 строк + - Проверка: watermark обновлён, но rows_processed = 0 + +3. **Late arrival симуляция** + - Вручную вставить в STG событие с `event_ts` из прошлого + - Запустить DAG + - Ожидаемое поведение: Событие загружено (в lookback window) + +4. **Ошибка загрузки** + - Симулировать ошибку (например, неправильный SQL) + - Ожидаемое поведение: watermark НЕ обновлён + - Retry должен загрузить те же данные (дедупликация) + +### 7.2 Проверочные запросы + +```sql +-- Проверить историю watermark +SELECT + table_name, + watermark, + rows_processed, + created_at +FROM meta.etl_watermarks_history +ORDER BY created_at DESC +LIMIT 10; + +-- Проверить late arrivals +SELECT + count() as total_events, + countIf(event_ts < now() - INTERVAL 5 MINUTE) as late_arrivals +FROM ods.browser_event +WHERE src_ingest_ts > now() - INTERVAL 1 HOUR; +``` + +--- + +## 8. Оценка трудозатрат + +| Задача | Сложность | Оценка | +|--------|-----------|--------| +| DDL для watermark таблиц | Низкая | 30 мин | +| Модификация SQL ODS (4 файла, параллельно) | Средняя | 3 часа | +| Модификация SQL DDS | Средняя | 2 часа | +| Модификация SQL DM | Средняя | 1.5 часа | +| Рефакторинг DAG (параллельность) | Выше среднего | 4-5 часов | +| Функции watermark + логирование | Низкая | 1 час | +| Алерты Grafana | Низкая | 1 час | +| Тестирование | Средняя | 2 часа | +| **Итого** | | **15-16 часов** | + +--- + +## 9. Следующие шаги + +- [ ] Создать ветку `feat/incremental-etl` +- [ ] Создать DDL для watermark таблиц +- [ ] Модифицировать SQL для ODS слоя (4 файла, параллельная загрузка) +- [ ] Модифицировать SQL для DDS слоя +- [ ] Модифицировать SQL для DM слоя +- [ ] Рефакторинг DAG с параллельностью +- [ ] Добавить функции watermark +- [ ] Настроить алерты в Grafana +- [ ] Тестирование +- [ ] Обновление документации +- [ ] Code review +- [ ] Merge в `feature/data-generator` + +--- + +**Согласовано:** _______________ +**Дата:** 15 февраля 2026 diff --git a/scripts/assert_stand_clean.sh b/scripts/assert_stand_clean.sh new file mode 100755 index 0000000..a3872ab --- /dev/null +++ b/scripts/assert_stand_clean.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Проверяет, что новый мир генератора не смешается со старым. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +KAFKA_BOOTSTRAP_SERVERS_HOST="${KAFKA_BOOTSTRAP_SERVERS_HOST:-localhost:9092}" +GENERATOR_METRICS_URL_HOST="${GENERATOR_METRICS_URL_HOST:-http://localhost:9109/metrics}" +CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}" +CLICKHOUSE_DB="${CLICKHOUSE_DB:-default}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}" +CHECK_MODE="${1:-clean}" + +cd "${REPO_ROOT}" + +if [[ "${CHECK_MODE}" != "clean" && "${CHECK_MODE}" != "continue" ]]; then + echo "Ошибка: неизвестный режим предпроверки: ${CHECK_MODE}" >&2 + exit 2 +fi + +if [[ "${CHECK_MODE}" == "clean" ]]; then + echo "Предпроверка: live-генератор не должен быть запущен." + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.stand_clean \ + --mode live \ + --live-metrics-url "${GENERATOR_METRICS_URL_HOST}" +fi + +echo "Предпроверка: Kafka data-топики и STG должны быть пустыми или state совместим." + +if ! stg_counts="$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \ + --user="${CLICKHOUSE_USER}" \ + --password="${CLICKHOUSE_PASSWORD}" \ + --database="${CLICKHOUSE_DB}" \ + --format=TSV \ + --query " +SELECT + (SELECT count() FROM stg.browser_raw) AS browser_raw, + (SELECT count() FROM stg.location_raw) AS location_raw, + (SELECT count() FROM stg.device_raw) AS device_raw, + (SELECT count() FROM stg.geo_raw) AS geo_raw +")"; then + echo "Ошибка: не удалось проверить STG через ClickHouse." >&2 + echo "Проверьте, что ClickHouse запущен и DDL применён: make ddl" >&2 + echo "${stg_counts}" >&2 + exit 1 +fi + +KAFKA_BOOTSTRAP_SERVERS="${KAFKA_BOOTSTRAP_SERVERS_HOST}" \ +GEN_DATA_DIR="${REPO_ROOT}/data" \ +PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.stand_clean \ + --mode "${CHECK_MODE}" \ + --kafka-bootstrap-servers "${KAFKA_BOOTSTRAP_SERVERS_HOST}" \ + --stg-counts "${stg_counts}" diff --git a/scripts/check_generated_analytics.sh b/scripts/check_generated_analytics.sh new file mode 100644 index 0000000..3d70729 --- /dev/null +++ b/scripts/check_generated_analytics.sh @@ -0,0 +1,776 @@ +#!/usr/bin/env bash +# +# Повторяемая проверка, что DM-витрины и Superset metadata работают на данных +# стартовой истории генератора, а не на архивном сиде 2022 года. + +set -euo pipefail + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}" +GEN_MODEL_T0="${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}" +GEN_MODEL_T_END="${GEN_MODEL_T_END:-2026-01-01T06:00:00+00:00}" +REQUIRE_SUPERSET="${REQUIRE_SUPERSET:-1}" +CHECK_LIVE_SEAM="${CHECK_LIVE_SEAM:-1}" +GEN_LIVE_CHECK_MINUTES="${GEN_LIVE_CHECK_MINUTES:-10}" +WAIT_LIVE_ROWS_SECONDS="${WAIT_LIVE_ROWS_SECONDS:-10}" + +fail() { + echo "Ошибка: $*" >&2 + exit 1 +} + +clickhouse_datetime_literal() { + local value="$1" + value="${value/T/ }" + value="${value%Z}" + if [[ "${value}" =~ ^(.*)[+-][0-9]{2}:[0-9]{2}$ ]]; then + value="${BASH_REMATCH[1]}" + fi + echo "${value}" +} + +ch_query() { + ${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \ + --user="${CLICKHOUSE_USER}" \ + --password="${CLICKHOUSE_PASSWORD}" \ + --query "$1" +} + +pg_query() { + ${COMPOSE_BIN} exec -T postgres-metadata psql \ + -U airflow \ + -d superset \ + -At \ + -c "$1" | tr -d '\r' +} + +CH_MODEL_T0="$(clickhouse_datetime_literal "${GEN_MODEL_T0}")" +CH_MODEL_T_END="$(clickhouse_datetime_literal "${GEN_MODEL_T_END}")" + +[[ "${GEN_LIVE_CHECK_MINUTES}" =~ ^[0-9]+$ ]] \ + || fail "GEN_LIVE_CHECK_MINUTES должен быть целым числом минут" +[[ "${WAIT_LIVE_ROWS_SECONDS}" =~ ^[0-9]+$ ]] \ + || fail "WAIT_LIVE_ROWS_SECONDS должен быть целым числом секунд" +case "${CHECK_LIVE_SEAM}" in + 0|1|auto) ;; + *) fail "CHECK_LIVE_SEAM должен быть 0, 1 или auto" ;; +esac + +echo "=== Проверка startup-history manifest ===" + +manifest_summary="$(${COMPOSE_BIN} run --rm --no-deps \ + generator python -m clickstream_generator.startup_history_artifact kafka-manifest-summary)" + +IFS=$'\t' read -r manifest_events manifest_visits manifest_users manifest_min_ts manifest_max_ts manifest_model_t0 manifest_model_t_end manifest_launch_profile <<< "${manifest_summary}" + +[[ "${manifest_events}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать startup-history manifest из Kafka" +[[ -n "${manifest_model_t0}" ]] || fail "startup-history manifest не содержит model_t0" +[[ -n "${manifest_model_t_end}" ]] || fail "startup-history manifest не содержит model_t_end" + +CH_MODEL_T0="$(clickhouse_datetime_literal "${manifest_model_t0}")" +CH_MODEL_T_END="$(clickhouse_datetime_literal "${manifest_model_t_end}")" + +expected_profile="${PROFILE:-${GEN_LAUNCH_PROFILE:-}}" +if [[ -n "${expected_profile}" && -n "${manifest_launch_profile}" && "${expected_profile}" != "${manifest_launch_profile}" ]]; then + fail "проверка запущена для профиля ${expected_profile}, но manifest от ${manifest_launch_profile}" +fi + +echo "manifest_events=${manifest_events}" +echo "manifest_visits=${manifest_visits}" +echo "manifest_users=${manifest_users}" +echo "manifest_model_t0=${manifest_model_t0}" +echo "manifest_model_t_end=${manifest_model_t_end}" +echo "manifest_launch_profile=${manifest_launch_profile}" + +echo "=== Проверка ClickHouse: данные генерации в DM ===" + +stats_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_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_ts, + toString(max(event_ts)) AS max_event_ts, + users < visits AND visits < events AS pyramid_ok, + min(event_ts) >= t0 AND max(event_ts) < t_end AS half_open_ok, + countIf(toYear(event_ts) = 2022) = 0 AS no_2022_rows, + 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 +) +FORMAT TabSeparated" + +stats="$(ch_query "${stats_query}")" +IFS=$'\t' read -r events visits users min_event_ts max_event_ts pyramid_ok half_open_ok no_2022_rows digest <<< "${stats}" + +[[ "${events}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать число событий из dm.v_events_enriched" +(( events > 0 )) || fail "dm.v_events_enriched пустая в модельном диапазоне" +[[ "${pyramid_ok}" == "1" ]] || fail "нарушена пирамида users < visits < events" +[[ "${half_open_ok}" == "1" ]] || fail "данные вышли за диапазон [GEN_MODEL_T0, GEN_MODEL_T_END)" +[[ "${no_2022_rows}" == "1" ]] || fail "найдены строки 2022 года, похожие на архивный сид" + +echo "events=${events}" +echo "visits=${visits}" +echo "users=${users}" +echo "min_event_ts=${min_event_ts}" +echo "max_event_ts=${max_event_ts}" +echo "digest=${digest}" + +echo "" +echo "=== Проверка ClickHouse: возвраты пользователей ===" + +returns_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_MODEL_T_END}', 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, + max(visits) AS max_visits_per_user +FROM users +FORMAT TabSeparated" + +returns="$(ch_query "${returns_query}")" +IFS=$'\t' read -r total_users returning_users returning_share max_visits_per_user <<< "${returns}" + +[[ "${total_users}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать число пользователей" +(( total_users > 0 )) || fail "нет пользователей для проверки возвратов" +(( returning_users > 0 )) || fail "нет пользователей с повторными визитами" + +echo "users=${total_users}" +echo "returning_users=${returning_users}" +echo "returning_share=${returning_share}" +echo "max_visits_per_user=${max_visits_per_user}" + +echo "" +echo "=== Проверка ClickHouse: форма длины визита ===" + +visit_shape_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_MODEL_T_END}', 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, + quantileExact(0.95)(duration_sec) AS p95_duration_sec, + max(events_count) AS max_events_per_visit +FROM sessions +FORMAT TabSeparated" + +visit_shape="$(ch_query "${visit_shape_query}")" +IFS=$'\t' read -r shape_visits short_visit_share median_events_per_visit avg_events_per_visit capped_visit_share median_duration_sec p95_duration_sec max_events_per_visit <<< "${visit_shape}" + +[[ "${shape_visits}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать число визитов" +(( shape_visits > 0 )) || fail "нет визитов для проверки формы" +(( max_events_per_visit <= 30 )) || fail "длина визита превысила GEN_MAX_SESSION_EVENTS" + +echo "visits=${shape_visits}" +echo "short_visit_share=${short_visit_share}" +echo "median_events_per_visit=${median_events_per_visit}" +echo "avg_events_per_visit=${avg_events_per_visit}" +echo "capped_visit_share=${capped_visit_share}" +echo "median_duration_sec=${median_duration_sec}" +echo "p95_duration_sec=${p95_duration_sec}" +echo "max_events_per_visit=${max_events_per_visit}" + +echo "" +echo "=== Проверка ClickHouse: ordered funnel ===" + +ordered_funnel_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_MODEL_T_END}', 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 +FORMAT TabSeparated" + +ordered_funnel="$(ch_query "${ordered_funnel_query}")" +IFS=$'\t' read -r ordered_home ordered_products ordered_cart ordered_payment ordered_confirmation ordered_monotonic_ok ordered_confirmation_share <<< "${ordered_funnel}" + +[[ "${ordered_home}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать ordered funnel" +(( ordered_home > 0 )) || fail "ordered funnel: нет /home" +[[ "${ordered_monotonic_ok}" == "1" ]] || fail "ordered funnel не монотонен" + +echo "home=${ordered_home}" +echo "products=${ordered_products}" +echo "cart=${ordered_cart}" +echo "payment=${ordered_payment}" +echo "confirmation=${ordered_confirmation}" +echo "monotonic_ok=${ordered_monotonic_ok}" +echo "confirmation_share=${ordered_confirmation_share}" + +echo "" +echo "=== Проверка ClickHouse: contains funnel ===" + +contains_funnel_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_MODEL_T_END}', 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 +FORMAT TabSeparated" + +contains_funnel="$(ch_query "${contains_funnel_query}")" +IFS=$'\t' read -r contains_home contains_products contains_cart contains_payment contains_confirmation contains_monotonic_ok contains_confirmation_share <<< "${contains_funnel}" + +[[ "${contains_home}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать contains funnel" +(( contains_home > 0 )) || fail "contains funnel: нет /home" +[[ "${contains_monotonic_ok}" == "1" ]] || fail "contains funnel не монотонен" + +echo "home=${contains_home}" +echo "products=${contains_products}" +echo "cart=${contains_cart}" +echo "payment=${contains_payment}" +echo "confirmation=${contains_confirmation}" +echo "monotonic_ok=${contains_monotonic_ok}" +echo "confirmation_share=${contains_confirmation_share}" + +should_check_live_seam="${CHECK_LIVE_SEAM}" +if [[ "${should_check_live_seam}" != "0" ]]; then + live_rows_query=" +WITH + toDateTime64('${CH_MODEL_T_END}', 6) AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end +SELECT count() +FROM dds.event +WHERE event_ts >= t_end AND event_ts < t_live_end +FORMAT TabSeparated" + live_rows=0 + deadline=$((SECONDS + WAIT_LIVE_ROWS_SECONDS)) + while true; do + live_rows="$(ch_query "${live_rows_query}")" + [[ "${live_rows}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать live-строки DDS после GEN_MODEL_T_END" + if (( live_rows > 0 || SECONDS >= deadline )); then + break + fi + sleep 1 + done + if [[ "${should_check_live_seam}" == "1" ]] && (( live_rows == 0 )); then + fail "live-продолжение не записало строки в первые ${GEN_LIVE_CHECK_MINUTES} минут после manifest model_t_end" + fi + if [[ "${CHECK_LIVE_SEAM}" == "auto" ]]; then + if (( live_rows > 0 )); then + should_check_live_seam="1" + else + should_check_live_seam="0" + fi + fi +fi + +if [[ "${should_check_live_seam}" == "1" ]]; then + echo "" + echo "=== Проверка ClickHouse: стык backfill/live в DDS ===" + + live_pairing_query=" +WITH + parseDateTime64BestEffort('${manifest_model_t_end}', 6, 'UTC') AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end, + live_browser AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id, + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id + FROM stg.browser_raw + WHERE parseDateTime64BestEffortOrNull( + JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' + ) >= t_end + AND parseDateTime64BestEffortOrNull( + JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' + ) < t_live_end + AND event_id IS NOT NULL + AND click_id IS NOT NULL + GROUP BY event_id, click_id + ), + live_click_ids AS ( + SELECT click_id + FROM live_browser + GROUP BY click_id + ), + browser_counts AS ( + SELECT click_id, count() AS browser_rows + FROM + ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id, + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id + FROM stg.browser_raw + WHERE event_id IS NOT NULL AND click_id IS NOT NULL + GROUP BY event_id, click_id + ) + WHERE click_id IN (SELECT click_id FROM live_click_ids) + GROUP BY click_id + ), + location_ids AS ( + SELECT toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id + FROM stg.location_raw + WHERE event_id IS NOT NULL + GROUP BY event_id + ), + device_counts AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id, + count() AS device_rows + FROM stg.device_raw + WHERE click_id IS NOT NULL + AND click_id IN (SELECT click_id FROM live_click_ids) + GROUP BY click_id + ), + geo_counts AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id, + count() AS geo_rows + FROM stg.geo_raw + WHERE click_id IS NOT NULL + AND click_id IN (SELECT click_id FROM live_click_ids) + GROUP BY click_id + ) +SELECT + (SELECT count() FROM live_browser) AS live_browser_rows, + ( + SELECT count() + FROM live_browser AS b + LEFT JOIN location_ids AS l ON l.event_id = b.event_id + WHERE l.event_id IS NULL + ) AS unpaired_location_rows, + ( + SELECT ifNull(sum(if( + b.browser_rows > ifNull(d.device_rows, 0), + b.browser_rows - ifNull(d.device_rows, 0), + 0 + )), 0) + FROM browser_counts AS b + LEFT JOIN device_counts AS d ON d.click_id = b.click_id + ) AS unpaired_device_rows, + ( + SELECT ifNull(sum(if( + b.browser_rows > ifNull(g.geo_rows, 0), + b.browser_rows - ifNull(g.geo_rows, 0), + 0 + )), 0) + FROM browser_counts AS b + LEFT JOIN geo_counts AS g ON g.click_id = b.click_id + ) AS unpaired_geo_rows +SETTINGS join_use_nulls = 1 +FORMAT TabSeparated" + + live_pairing="$(ch_query "${live_pairing_query}")" + IFS=$'\t' read -r live_browser_rows unpaired_location_rows unpaired_device_rows unpaired_geo_rows <<< "${live_pairing}" + + [[ "${live_browser_rows}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_location_rows}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_device_rows}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_geo_rows}" =~ ^[0-9]+$ ]] \ + || fail "не удалось прочитать пары live-строк" + if (( unpaired_location_rows > 0 || unpaired_device_rows > 0 || unpaired_geo_rows > 0 )); then + fail "непарные live-строки: location=${unpaired_location_rows}, device=${unpaired_device_rows}, geo=${unpaired_geo_rows} из browser=${live_browser_rows}" + fi + + echo "live_browser_rows=${live_browser_rows}" + echo "unpaired_location_rows=${unpaired_location_rows}" + echo "unpaired_device_rows=${unpaired_device_rows}" + echo "unpaired_geo_rows=${unpaired_geo_rows}" + + seam_duplicates_query=" +WITH + toDateTime64('${CH_MODEL_T0}', 6) AS t0, + toDateTime64('${CH_MODEL_T_END}', 6) AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end +SELECT + count() AS events, + uniqExact(event_id) AS unique_events, + events - unique_events AS duplicate_events +FROM dds.event +WHERE event_ts >= t0 AND event_ts < t_live_end +FORMAT TabSeparated" + + seam_duplicates="$(ch_query "${seam_duplicates_query}")" + IFS=$'\t' read -r seam_events seam_unique_events seam_duplicate_events <<< "${seam_duplicates}" + + [[ "${seam_events}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать события DDS на стыке" + (( seam_events > 0 )) || fail "нет событий DDS в диапазоне проверки стыка" + (( seam_duplicate_events == 0 )) || fail "на стыке найдены дубли event_id: ${seam_duplicate_events}" + + echo "events=${seam_events}" + echo "unique_events=${seam_unique_events}" + echo "duplicate_events=${seam_duplicate_events}" + + seam_context_query=" +WITH + toDateTime64('${CH_MODEL_T_END}', 6) AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end, + crossing AS ( + SELECT + e.click_id, + min(e.event_ts) AS first_ts, + max(e.event_ts) AS last_ts, + groupUniqArray(coalesce(toString(e.browser_name), '__NULL__')) AS browser_names, + groupUniqArray(coalesce(toString(e.browser_language), '__NULL__')) AS browser_languages, + groupUniqArray(coalesce(toString(e.browser_user_agent), '__NULL__')) AS browser_user_agents, + groupUniqArray(coalesce(toString(e.referer_url), '__NULL__')) AS referer_urls, + groupUniqArray(coalesce(toString(e.referer_medium), '__NULL__')) AS referer_mediums, + groupUniqArray(coalesce(toString(e.utm_medium), '__NULL__')) AS utm_mediums, + groupUniqArray(coalesce(toString(e.utm_source), '__NULL__')) AS utm_sources, + groupUniqArray(coalesce(toString(e.utm_content), '__NULL__')) AS utm_contents, + groupUniqArray(coalesce(toString(e.utm_campaign), '__NULL__')) AS utm_campaigns, + groupUniqArray(coalesce(toString(c.device_type), '__NULL__')) AS device_types, + groupUniqArray(coalesce(toString(c.os_name), '__NULL__')) AS os_names, + groupUniqArray(coalesce(toString(c.geo_country), '__NULL__')) AS geo_countries + FROM dds.event AS e + LEFT JOIN dds.click AS c ON c.click_id = e.click_id + WHERE e.event_ts >= t_end - INTERVAL 30 MINUTE + AND e.event_ts < t_live_end + GROUP BY e.click_id + HAVING first_ts < t_end AND last_ts >= t_end + ) +SELECT + count() AS crossing_visits, + countIf( + length(browser_names) = 1 + AND length(browser_languages) = 1 + AND length(browser_user_agents) = 1 + AND length(referer_urls) = 1 + AND length(referer_mediums) = 1 + AND length(utm_mediums) = 1 + AND length(utm_sources) = 1 + AND length(utm_contents) = 1 + AND length(utm_campaigns) = 1 + ) AS per_event_homogeneous_visits, + countIf( + length(device_types) = 1 + AND length(os_names) = 1 + AND length(geo_countries) = 1 + ) AS click_context_homogeneous_visits +FROM crossing +FORMAT TabSeparated" + + seam_context="$(ch_query "${seam_context_query}")" + IFS=$'\t' read -r crossing_visits per_event_homogeneous_visits click_context_homogeneous_visits <<< "${seam_context}" + + [[ "${crossing_visits}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать переходящие визиты" + (( crossing_visits > 0 )) || fail "нет визитов, переходящих через GEN_MODEL_T_END" + (( per_event_homogeneous_visits == crossing_visits )) \ + || fail "per-event фактура меняется на стыке: ${per_event_homogeneous_visits}/${crossing_visits}" + (( click_context_homogeneous_visits == crossing_visits )) \ + || fail "device/os/geo меняются на стыке: ${click_context_homogeneous_visits}/${crossing_visits}" + + echo "crossing_visits=${crossing_visits}" + echo "per_event_homogeneous_visits=${per_event_homogeneous_visits}" + echo "click_context_homogeneous_visits=${click_context_homogeneous_visits}" + + ods_context_query=" +WITH + toDateTime64('${CH_MODEL_T_END}', 6) AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end, + crossing AS ( + SELECT + click_id + FROM dds.event + WHERE event_ts >= t_end - INTERVAL 30 MINUTE + AND event_ts < t_live_end + GROUP BY click_id + HAVING min(event_ts) < t_end AND max(event_ts) >= t_end + ), + device_conflicts AS ( + SELECT + d.click_id + FROM ods.device_by_click AS d + INNER JOIN crossing AS c ON c.click_id = d.click_id + WHERE d.click_id IS NOT NULL + GROUP BY d.click_id + HAVING + uniqExact(coalesce(toString(device_type), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(device_is_mobile), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(os_name), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(os), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(os_timezone), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(user_domain_id), '__NULL__')) > 1 + ), + geo_conflicts AS ( + SELECT + g.click_id + FROM ods.geo_by_click AS g + INNER JOIN crossing AS c ON c.click_id = g.click_id + WHERE g.click_id IS NOT NULL + GROUP BY g.click_id + HAVING + uniqExact(coalesce(toString(geo_country), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(geo_timezone), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(geo_region_name), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(geo_latitude), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(geo_longitude), '__NULL__')) > 1 + OR uniqExact(coalesce(toString(ip_address), '__NULL__')) > 1 + ) +SELECT + (SELECT count() FROM crossing) AS crossing_visits, + ( + SELECT count() + FROM ods.device_by_click AS d + INNER JOIN crossing AS c ON c.click_id = d.click_id + WHERE d.click_id IS NOT NULL + ) AS ods_device_rows, + ( + SELECT count() + FROM ods.geo_by_click AS g + INNER JOIN crossing AS c ON c.click_id = g.click_id + WHERE g.click_id IS NOT NULL + ) AS ods_geo_rows, + (SELECT count() FROM device_conflicts) AS ods_device_conflicts, + (SELECT count() FROM geo_conflicts) AS ods_geo_conflicts +FORMAT TabSeparated" + + ods_context="$(ch_query "${ods_context_query}")" + IFS=$'\t' read -r ods_crossing_visits ods_device_rows ods_geo_rows ods_device_conflicts ods_geo_conflicts <<< "${ods_context}" + + [[ "${ods_crossing_visits}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать ODS-проверку стыка" + (( ods_crossing_visits == crossing_visits )) \ + || fail "ODS и DDS нашли разное число переходящих визитов: ${ods_crossing_visits}/${crossing_visits}" + (( ods_device_rows > 0 )) || fail "ODS device пустой на стыке" + (( ods_geo_rows > 0 )) || fail "ODS geo пустой на стыке" + (( ods_device_conflicts == 0 )) || fail "ODS device конфликтует на стыке: ${ods_device_conflicts}" + (( ods_geo_conflicts == 0 )) || fail "ODS geo конфликтует на стыке: ${ods_geo_conflicts}" + + echo "ods_device_rows=${ods_device_rows}" + echo "ods_geo_rows=${ods_geo_rows}" + echo "ods_device_conflicts=${ods_device_conflicts}" + echo "ods_geo_conflicts=${ods_geo_conflicts}" +else + echo "" + echo "Проверка стыка backfill/live пропущена: CHECK_LIVE_SEAM=${CHECK_LIVE_SEAM}, live_rows=${live_rows:-0}" +fi + +echo "" +echo "=== Проверка ClickHouse: основные DM-витрины не пустые ===" + +views_query=" +SELECT source, rows +FROM +( + SELECT 'dm.v_events_enriched' AS source, count() AS rows FROM dm.v_events_enriched + UNION ALL SELECT 'dm.v_daily_traffic', count() FROM dm.v_daily_traffic + UNION ALL SELECT 'dm.v_top_pages_daily', count() FROM dm.v_top_pages_daily + UNION ALL SELECT 'dm.v_utm_effectiveness', count() FROM dm.v_utm_effectiveness + UNION ALL SELECT 'dm.v_session_overview', count() FROM dm.v_session_overview + UNION ALL SELECT 'dm.dq_summary', count() FROM dm.dq_summary +) +ORDER BY source +FORMAT TabSeparated" + +if ! views_rows="$(ch_query "${views_query}")"; then + fail "не удалось прочитать основные DM-витрины" +fi +[[ -n "${views_rows}" ]] || fail "ClickHouse не вернул строки основных DM-витрин" + +while IFS=$'\t' read -r source rows; do + [[ -n "${source}" ]] || continue + [[ "${rows}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать число строк для ${source}" + (( rows > 0 )) || fail "${source} пустая" + echo "${source}=${rows}" +done <<< "${views_rows}" + +if [[ "${REQUIRE_SUPERSET}" != "1" ]]; then + echo "" + echo "Проверка Superset пропущена: REQUIRE_SUPERSET=${REQUIRE_SUPERSET}" + exit 0 +fi + +echo "" +echo "=== Проверка Superset: datasets, charts и dashboard созданы ===" + +if ! ${COMPOSE_BIN} ps --services --filter "status=running" | grep -qx "superset"; then + fail "сервис superset не запущен" +fi + +${COMPOSE_BIN} exec -T superset curl -fsS http://localhost:8088/health >/dev/null \ + || fail "Superset health endpoint не отвечает" + +dataset_count="$(pg_query " +SELECT count(*) +FROM tables +WHERE schema = 'dm' + AND table_name IN ( + 'v_events_enriched', + 'v_daily_traffic', + 'v_utm_effectiveness', + 'v_top_pages_daily', + 'v_session_overview', + 'dq_summary' + );")" + +dashboard_count="$(pg_query " +SELECT count(*) +FROM dashboards +WHERE slug = 'ecommerce-analytics';")" + +chart_count="$(pg_query " +SELECT count(*) +FROM dashboard_slices ds +JOIN dashboards d ON d.id = ds.dashboard_id +WHERE d.slug = 'ecommerce-analytics';")" + +[[ "${dataset_count}" == "6" ]] || fail "ожидалось 6 Superset datasets, найдено ${dataset_count}" +[[ "${dashboard_count}" == "1" ]] || fail "dashboard ecommerce-analytics не найден" +(( chart_count > 0 )) || fail "dashboard ecommerce-analytics не связан с chart" + +echo "superset_datasets=${dataset_count}" +echo "superset_dashboards=${dashboard_count}" +echo "superset_dashboard_charts=${chart_count}" +echo "dashboard_url=http://localhost:8088/superset/dashboard/ecommerce-analytics/" + +echo "" +echo "=== Проверка Superset: dashboard открывается и читает ClickHouse ===" + +superset_probe="$( + ${COMPOSE_BIN} exec -T superset bash -s <<'PY' +set -euo pipefail +python - <<'PYTHON' +import json +import urllib.request + +from sqlalchemy import text + +from superset.app import create_app + +app = create_app() +with app.app_context(): + from superset.extensions import db + from superset.models.core import Database + from superset.models.dashboard import Dashboard + + dashboard = ( + db.session.query(Dashboard) + .filter_by(slug="ecommerce-analytics") + .one() + ) + + database = ( + db.session.query(Database) + .filter_by(database_name="clickhouse_dwh") + .one() + ) + with database.get_sqla_engine() as engine: + with engine.connect() as connection: + events = connection.execute( + text("SELECT count() FROM dm.v_events_enriched") + ).scalar() + +login_payload = json.dumps( + { + "username": "admin", + "password": "admin", + "provider": "db", + "refresh": True, + } +).encode("utf-8") + +login_request = urllib.request.Request( + "http://localhost:8088/api/v1/security/login", + data=login_payload, + headers={"Content-Type": "application/json"}, + method="POST", +) +with urllib.request.urlopen(login_request, timeout=10) as response: + login_status = response.status + token = json.loads(response.read().decode("utf-8"))["access_token"] + +dashboard_request = urllib.request.Request( + f"http://localhost:8088/api/v1/dashboard/{dashboard.id}", + headers={"Authorization": f"Bearer {token}"}, + method="GET", +) +with urllib.request.urlopen(dashboard_request, timeout=10) as response: + dashboard_status = response.status + dashboard_payload = json.loads(response.read().decode("utf-8")) + +dashboard_title = dashboard_payload["result"]["dashboard_title"] + +print(f"login_api_status={login_status}") +print(f"dashboard_api_status={dashboard_status}") +print(f"dashboard_title={dashboard_title}") +print(f"superset_clickhouse_events={events}") + +if login_status >= 400: + raise SystemExit("Superset login API failed") +if dashboard_status >= 400: + raise SystemExit("Superset dashboard API did not open") +if "E-commerce Analytics" not in dashboard_title: + raise SystemExit("Unexpected dashboard title") +if not isinstance(events, int) or events <= 0: + raise SystemExit("Superset ClickHouse query returned no data") +PYTHON +PY +)" + +echo "${superset_probe}" diff --git a/scripts/check_generated_history_chain.sh b/scripts/check_generated_history_chain.sh new file mode 100644 index 0000000..ee3af68 --- /dev/null +++ b/scripts/check_generated_history_chain.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# +# Проверяет завершённые стыки между порциями модельной истории. + +set -euo pipefail + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}" + +fail() { + echo "Ошибка: $*" >&2 + exit 1 +} + +clickhouse_datetime_literal() { + local value="$1" + local normalized + if ! normalized="$(date --utc --date="${value}" '+%Y-%m-%d %H:%M:%S.%6N')"; then + fail "не удалось привести границу к UTC: ${value}" + fi + echo "${normalized}" +} + +ch_query() { + ${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \ + --user="${CLICKHOUSE_USER}" \ + --password="${CLICKHOUSE_PASSWORD}" \ + --query "$1" +} + +mapfile -t boundaries < <( + ${COMPOSE_BIN} run --rm --no-deps generator \ + python -m clickstream_generator.startup_history_artifact kafka-boundaries +) + +boundary_count="${#boundaries[@]}" +(( boundary_count >= 2 )) || fail "manifest boundaries должен содержать T0 и T_end" + +manifest_topic_rows="$(${COMPOSE_BIN} run --rm --no-deps generator \ + python -m clickstream_generator.startup_history_artifact kafka-topic-rows)" +IFS=$'\t' read -r expected_browser_rows expected_location_rows \ + expected_device_rows expected_geo_rows <<< "${manifest_topic_rows}" +for rows in \ + "${expected_browser_rows}" \ + "${expected_location_rows}" \ + "${expected_device_rows}" \ + "${expected_geo_rows}"; do + [[ "${rows}" =~ ^[0-9]+$ ]] || fail "manifest содержит неверные счётчики топиков" +done + +echo "=== Проверка порций истории ===" +for ((index = 0; index < boundary_count - 1; index++)); do + left="$(clickhouse_datetime_literal "${boundaries[index]}")" + right="$(clickhouse_datetime_literal "${boundaries[index + 1]}")" + rows="$(ch_query " +WITH + toDateTime64('${left}', 6, 'UTC') AS segment_start, + toDateTime64('${right}', 6, 'UTC') AS segment_end +SELECT count() +FROM dm.v_events_enriched +WHERE parseDateTime64BestEffortOrNull(toString(event_ts), 6, 'UTC') >= segment_start + AND parseDateTime64BestEffortOrNull(toString(event_ts), 6, 'UTC') < segment_end +FORMAT TabSeparated")" + [[ "${rows}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать порцию ${boundaries[index]}" + (( rows > 0 )) || fail "порция [${boundaries[index]}, ${boundaries[index + 1]}) пуста" + echo "segment_${index}_rows=${rows}" +done + +actual_topic_rows="$(ch_query " +SELECT + (SELECT count() FROM stg.browser_raw) AS actual_topic_rows, + (SELECT count() FROM stg.location_raw) AS actual_location_rows, + (SELECT count() FROM stg.device_raw) AS actual_device_rows, + (SELECT count() FROM stg.geo_raw) AS actual_geo_rows +FORMAT TabSeparated")" +IFS=$'\t' read -r actual_browser_rows actual_location_rows \ + actual_device_rows actual_geo_rows <<< "${actual_topic_rows}" +topic_names=(browser_events location_events device_events geo_events) +expected_rows=( + "${expected_browser_rows}" + "${expected_location_rows}" + "${expected_device_rows}" + "${expected_geo_rows}" +) +actual_rows=( + "${actual_browser_rows}" + "${actual_location_rows}" + "${actual_device_rows}" + "${actual_geo_rows}" +) +for ((index = 0; index < ${#topic_names[@]}; index++)); do + [[ "${actual_rows[index]}" =~ ^[0-9]+$ ]] \ + || fail "не удалось прочитать число строк ${topic_names[index]} в STG" + if (( actual_rows[index] > expected_rows[index] )); then + fail "найден хвост ${topic_names[index]} после manifest: STG=${actual_rows[index]}, manifest=${expected_rows[index]}" + fi + if (( actual_rows[index] < expected_rows[index] )); then + fail "в STG не хватает строк ${topic_names[index]}: STG=${actual_rows[index]}, manifest=${expected_rows[index]}" + fi +done + +last_boundary="$(clickhouse_datetime_literal "${boundaries[boundary_count - 1]}")" +data_tail_rows="$(ch_query " +SELECT count() AS data_tail_rows +FROM stg.browser_raw +WHERE parseDateTime64BestEffortOrNull( + JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' + ) >= toDateTime64('${last_boundary}', 6, 'UTC') +FORMAT TabSeparated")" +[[ "${data_tail_rows}" =~ ^[0-9]+$ ]] \ + || fail "не удалось проверить хвост после ${boundaries[boundary_count - 1]}" +(( data_tail_rows == 0 )) \ + || fail "найден хвост данных после последней границы manifest: ${data_tail_rows}" + +if (( boundary_count == 2 )); then + echo "Завершённых внутренних стыков пока нет." + exit 0 +fi + +echo "=== Проверка внутренних стыков boundaries ===" +for ((index = 1; index < boundary_count - 1; index++)); do + boundary="$(clickhouse_datetime_literal "${boundaries[index]}")" + + pairing="$(ch_query " +WITH + toDateTime64('${boundary}', 6, 'UTC') AS boundary, + browser_rows AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id, + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id, + parseDateTime64BestEffortOrNull( + JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' + ) AS event_ts + FROM stg.browser_raw + WHERE event_id IS NOT NULL AND click_id IS NOT NULL AND event_ts IS NOT NULL + GROUP BY event_id, click_id, event_ts + ), + crossing AS ( + SELECT click_id + FROM browser_rows + GROUP BY click_id + HAVING min(event_ts) < boundary AND max(event_ts) >= boundary + ), + browser_counts AS ( + SELECT click_id, count() AS browser_rows + FROM browser_rows + WHERE click_id IN (SELECT click_id FROM crossing) + GROUP BY click_id + ), + location_ids AS ( + SELECT toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id + FROM stg.location_raw + WHERE event_id IS NOT NULL + GROUP BY event_id + ), + device_counts AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id, + count() AS device_rows + FROM stg.device_raw + WHERE click_id IN (SELECT click_id FROM crossing) + GROUP BY click_id + ), + geo_counts AS ( + SELECT + toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id, + count() AS geo_rows + FROM stg.geo_raw + WHERE click_id IN (SELECT click_id FROM crossing) + GROUP BY click_id + ) +SELECT + (SELECT count() FROM crossing) AS crossing_visits, + ( + SELECT count() + FROM browser_rows AS b + LEFT JOIN location_ids AS l ON l.event_id = b.event_id + WHERE b.click_id IN (SELECT click_id FROM crossing) + AND l.event_id IS NULL + ) AS unpaired_location_rows, + ( + SELECT ifNull(sum(greatest(b.browser_rows - ifNull(d.device_rows, 0), 0)), 0) + FROM browser_counts AS b + LEFT JOIN device_counts AS d ON d.click_id = b.click_id + ) AS unpaired_device_rows, + ( + SELECT ifNull(sum(greatest(b.browser_rows - ifNull(g.geo_rows, 0), 0)), 0) + FROM browser_counts AS b + LEFT JOIN geo_counts AS g ON g.click_id = b.click_id + ) AS unpaired_geo_rows +SETTINGS join_use_nulls = 1 +FORMAT TabSeparated")" + + IFS=$'\t' read -r crossing_visits unpaired_location_rows unpaired_device_rows unpaired_geo_rows <<< "${pairing}" + [[ "${crossing_visits}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_location_rows}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_device_rows}" =~ ^[0-9]+$ ]] \ + && [[ "${unpaired_geo_rows}" =~ ^[0-9]+$ ]] \ + || fail "не удалось прочитать пары на границе ${boundaries[index]}" + if (( unpaired_location_rows > 0 || unpaired_device_rows > 0 || unpaired_geo_rows > 0 )); then + fail "непарные строки на границе ${boundaries[index]}: location=${unpaired_location_rows}, device=${unpaired_device_rows}, geo=${unpaired_geo_rows}" + fi + + context="$(ch_query " +WITH + toDateTime64('${boundary}', 6, 'UTC') AS boundary, + crossing AS ( + SELECT click_id + FROM + ( + SELECT + click_id, + parseDateTime64BestEffortOrNull( + toString(event_ts), 6, 'UTC' + ) AS model_event_ts + FROM dds.event + WHERE event_ts IS NOT NULL + ) + GROUP BY click_id + HAVING min(model_event_ts) < boundary AND max(model_event_ts) >= boundary + ), + facts AS ( + SELECT + e.click_id, + groupUniqArray(coalesce(toString(e.browser_name), '__NULL__')) AS browser_names, + groupUniqArray(coalesce(toString(e.browser_language), '__NULL__')) AS browser_languages, + groupUniqArray(coalesce(toString(e.browser_user_agent), '__NULL__')) AS browser_user_agents, + groupUniqArray(coalesce(toString(e.referer_url), '__NULL__')) AS referer_urls, + groupUniqArray(coalesce(toString(e.referer_medium), '__NULL__')) AS referer_mediums, + groupUniqArray(coalesce(toString(e.utm_medium), '__NULL__')) AS utm_mediums, + groupUniqArray(coalesce(toString(e.utm_source), '__NULL__')) AS utm_sources, + groupUniqArray(coalesce(toString(e.utm_content), '__NULL__')) AS utm_contents, + groupUniqArray(coalesce(toString(e.utm_campaign), '__NULL__')) AS utm_campaigns + FROM dds.event AS e + INNER JOIN crossing AS c ON c.click_id = e.click_id + GROUP BY e.click_id + ) +SELECT + count() AS crossing_visits, + countIf( + length(browser_names) = 1 + AND length(browser_languages) = 1 + AND length(browser_user_agents) = 1 + AND length(referer_urls) = 1 + AND length(referer_mediums) = 1 + AND length(utm_mediums) = 1 + AND length(utm_sources) = 1 + AND length(utm_contents) = 1 + AND length(utm_campaigns) = 1 + ) AS per_event_homogeneous_visits +FROM facts +FORMAT TabSeparated")" + + IFS=$'\t' read -r context_crossing_visits per_event_homogeneous_visits <<< "${context}" + [[ "${context_crossing_visits}" =~ ^[0-9]+$ ]] \ + && [[ "${per_event_homogeneous_visits}" =~ ^[0-9]+$ ]] \ + || fail "не удалось прочитать фактуру на границе ${boundaries[index]}" + (( context_crossing_visits == crossing_visits )) \ + || fail "STG и DDS нашли разное число переходящих визитов на ${boundaries[index]}" + if (( crossing_visits == 0 )); then + echo "boundary ${index}/${boundary_count}: crossing_visits=0 — однородность неприменима" + else + (( per_event_homogeneous_visits == crossing_visits )) \ + || fail "browser/referer/utm меняются на границе ${boundaries[index]}: ${per_event_homogeneous_visits}/${crossing_visits}" + echo "boundary ${index}/${boundary_count}: crossing_visits=${crossing_visits} — однородность подтверждена" + fi + + echo "boundary_${index}=${boundaries[index]}" + echo "boundary_${index}_crossing_visits=${crossing_visits}" + echo "boundary_${index}_unpaired_location_rows=${unpaired_location_rows}" + echo "boundary_${index}_unpaired_device_rows=${unpaired_device_rows}" + echo "boundary_${index}_unpaired_geo_rows=${unpaired_geo_rows}" +done diff --git a/scripts/check_startup_history_manifest.sh b/scripts/check_startup_history_manifest.sh new file mode 100755 index 0000000..06b9ef0 --- /dev/null +++ b/scripts/check_startup_history_manifest.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Сверяет DM-витрину ClickHouse с manifest портативной стартовой истории. + +set -euo pipefail + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}" +CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" +CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}" +ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" + +fail() { + echo "Ошибка: $*" >&2 + exit 1 +} + +clickhouse_datetime_literal() { + local value="$1" + value="${value/T/ }" + value="${value%Z}" + if [[ "${value}" =~ ^(.*)[+-][0-9]{2}:[0-9]{2}$ ]]; then + value="${BASH_REMATCH[1]}" + fi + echo "${value}" +} + +[[ -s "${ARTIFACT}" ]] || fail "артефакт не найден или пуст: ${ARTIFACT}" + +ARTIFACT_DIR="$(dirname "${ARTIFACT}")" +ARTIFACT_FILE="$(basename "${ARTIFACT}")" + +manifest_summary="$(${COMPOSE_BIN} run --rm --no-deps \ + -v "${ARTIFACT_DIR}:/startup-artifacts:ro" \ + generator python -m clickstream_generator.startup_history_artifact manifest-summary \ + --artifact "/startup-artifacts/${ARTIFACT_FILE}")" + +IFS=$'\t' read -r expected_events expected_visits expected_users expected_min_ts expected_max_ts model_t0 model_t_end <<< "${manifest_summary}" + +[[ "${expected_events}" =~ ^[0-9]+$ ]] || fail "не удалось прочитать manifest из артефакта" + +ch_model_t0="$(clickhouse_datetime_literal "${model_t0}")" +ch_model_t_end="$(clickhouse_datetime_literal "${model_t_end}")" + +actual="$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \ + --user="${CLICKHOUSE_USER}" \ + --password="${CLICKHOUSE_PASSWORD}" \ + --query " +WITH + toDateTime64('${ch_model_t0}', 6) AS t0, + toDateTime64('${ch_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 +FORMAT TabSeparated")" + +IFS=$'\t' read -r actual_events actual_visits actual_users actual_min_ts actual_max_ts <<< "${actual}" + +[[ "${actual_events}" == "${expected_events}" ]] || fail "events: ClickHouse=${actual_events}, manifest=${expected_events}" +[[ "${actual_visits}" == "${expected_visits}" ]] || fail "visits: ClickHouse=${actual_visits}, manifest=${expected_visits}" +[[ "${actual_users}" == "${expected_users}" ]] || fail "users: ClickHouse=${actual_users}, manifest=${expected_users}" +[[ "${actual_min_ts}" == "${expected_min_ts}" ]] || fail "min_event_timestamp: ClickHouse=${actual_min_ts}, manifest=${expected_min_ts}" +[[ "${actual_max_ts}" == "${expected_max_ts}" ]] || fail "max_event_timestamp: ClickHouse=${actual_max_ts}, manifest=${expected_max_ts}" + +echo "manifest_events=${expected_events}" +echo "manifest_visits=${expected_visits}" +echo "manifest_users=${expected_users}" +echo "manifest_min_event_timestamp=${expected_min_ts}" +echo "manifest_max_event_timestamp=${expected_max_ts}" +echo "ClickHouse совпадает с manifest артефакта." diff --git a/scripts/export_startup_history_artifact.sh b/scripts/export_startup_history_artifact.sh new file mode 100755 index 0000000..a7b13a9 --- /dev/null +++ b/scripts/export_startup_history_artifact.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Экспортирует стартовую историю в портативный JSON-артефакт. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +CLEAN_START="${CLEAN_START:-1}" +ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + backfill \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + +ARTIFACT_DIR="$(dirname "${ARTIFACT}")" +ARTIFACT_FILE="$(basename "${ARTIFACT}")" +mkdir -p "${ARTIFACT_DIR}" + +echo "=== Экспорт стартовой истории ===" +echo "ARTIFACT=${ARTIFACT}" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +echo "GEN_SEED=${GEN_SEED}" +echo "GEN_MODEL_T0=${GEN_MODEL_T0}" +echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +echo "" + +if [[ "${CLEAN_START}" == "1" ]]; then + echo "Шаг 0: очистка Kafka/state перед повторяемым экспортом" + ${COMPOSE_BIN} down -v --remove-orphans +else + echo "Шаг 0: CLEAN_START=0, очистка пропущена" +fi + +echo "Шаг 1: запуск Kafka" +${COMPOSE_BIN} up -d kafka + +echo "Шаг 2: сборка образа генератора" +${COMPOSE_BIN} build generator + +echo "Шаг 3: backfill и запись артефакта" +${COMPOSE_BIN} run --rm --no-deps \ + -v "${ARTIFACT_DIR}:/startup-artifacts" \ + -e GEN_RUN_MODE=backfill \ + -e GEN_STATE_RESET=true \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_STARTUP_HISTORY_ARTIFACT="/startup-artifacts/${ARTIFACT_FILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator + +test -s "${ARTIFACT}" +echo "Готово: ${ARTIFACT}" diff --git a/scripts/import_startup_history_artifact.sh b/scripts/import_startup_history_artifact.sh new file mode 100755 index 0000000..04436d9 --- /dev/null +++ b/scripts/import_startup_history_artifact.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Импортирует портативный артефакт стартовой истории в Kafka. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + continue \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + +if [[ ! -s "${ARTIFACT}" ]]; then + echo "Ошибка: артефакт не найден или пуст: ${ARTIFACT}" >&2 + exit 1 +fi + +ARTIFACT_DIR="$(dirname "${ARTIFACT}")" +ARTIFACT_FILE="$(basename "${ARTIFACT}")" + +echo "=== Импорт стартовой истории ===" +echo "ARTIFACT=${ARTIFACT}" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +echo "GEN_SEED=${GEN_SEED}" +echo "GEN_MODEL_T0=${GEN_MODEL_T0}" +echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +echo "" + +echo "Шаг 1: запуск Kafka и ClickHouse" +${COMPOSE_BIN} up -d kafka clickhouse + +echo "Шаг 2: предпроверка чистого стенда" +bash "${SCRIPT_DIR}/assert_stand_clean.sh" clean + +echo "Шаг 3: сборка образа генератора" +${COMPOSE_BIN} build generator + +echo "Шаг 4: воспроизведение артефакта в Kafka" +${COMPOSE_BIN} run --rm --no-deps \ + -v "${ARTIFACT_DIR}:/startup-artifacts:ro" \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator python -m clickstream_generator.startup_history_artifact import \ + --artifact "/startup-artifacts/${ARTIFACT_FILE}" + +echo "Готово: артефакт воспроизведён в Kafka." diff --git a/scripts/run_batch.sh b/scripts/run_batch.sh index 885e8da..8e44421 100755 --- a/scripts/run_batch.sh +++ b/scripts/run_batch.sh @@ -14,7 +14,7 @@ # # Требования: # - ClickHouse запущен (make up) -# - STG содержит данные (make data выполнен) +# - STG содержит данные генератора или ручной загрузки # # Стратегия: # Сейчас: полная перезагрузка (TRUNCATE + INSERT) — для демо @@ -81,7 +81,8 @@ STG_COUNT=$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \ if [[ "${STG_COUNT}" == "0" ]]; then echo "Предупреждение: Таблицы STG пусты." - echo "Сначала загрузите данные: make data" + echo "Сначала загрузите данные: make generated-history-analytics" + echo "Для ручной отладки можно выполнить backfill генератора или архивный make data." exit 1 fi diff --git a/scripts/run_generated_history_analytics.sh b/scripts/run_generated_history_analytics.sh new file mode 100644 index 0000000..586bcd9 --- /dev/null +++ b/scripts/run_generated_history_analytics.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Чистый прогон аналитического контура от стартовой истории генератора до DM и Superset. +# +# Команда намеренно очищает volumes по умолчанию: так сбрасываются ClickHouse, +# Kafka-топики данных, state и manifest генератора. Это штатный повторяемый путь +# для проверки ADR-0006 на свежем стенде. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +CLEAN_START="${CLEAN_START:-1}" +WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}" +WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-10}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + backfill \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + +wait_for_clickhouse() { + local deadline + deadline=$((SECONDS + WAIT_CLICKHOUSE_SECONDS)) + + until ${COMPOSE_BIN} exec -T clickhouse clickhouse-client \ + --user=default \ + --password=123456 \ + --query "SELECT 1" >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + echo "Ошибка: ClickHouse не ответил за ${WAIT_CLICKHOUSE_SECONDS} сек." >&2 + exit 1 + fi + sleep 2 + done +} + +echo "=== Чистый прогон стартовой истории как источника аналитики ===" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +echo "GEN_SEED=${GEN_SEED}" +echo "GEN_MODEL_T0=${GEN_MODEL_T0}" +echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +echo "GEN_LAMBDA_BASE_PER_MIN=${GEN_LAMBDA_BASE_PER_MIN}" +echo "" + +if [[ "${CLEAN_START}" == "1" ]]; then + echo "Шаг 0: очистка volumes ClickHouse/Kafka/state" + ${COMPOSE_BIN} --profile live-generator down -v --remove-orphans +else + echo "Шаг 0: CLEAN_START=0, очистка пропущена" +fi + +echo "Шаг 1: запуск ClickHouse и Kafka" +${COMPOSE_BIN} up -d clickhouse kafka +wait_for_clickhouse + +echo "Шаг 2: применение DDL" +bash "${SCRIPT_DIR}/apply_clickhouse_ddl.sh" + +echo "Шаг 3: сборка образа генератора" +${COMPOSE_BIN} build generator + +echo "Шаг 4: backfill стартовой истории в Kafka" +${COMPOSE_BIN} run --rm --no-deps \ + -e GEN_RUN_MODE=backfill \ + -e GEN_STATE_RESET=true \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator + +echo "Шаг 5: ожидание чтения Kafka Materialized View (${WAIT_STG_SECONDS} сек.)" +sleep "${WAIT_STG_SECONDS}" + +echo "Шаг 6: batch STG -> ODS -> DDS -> DM" +bash "${SCRIPT_DIR}/run_batch.sh" + +echo "Шаг 7: инициализация Superset metadata и dashboard" +${COMPOSE_BIN} up -d postgres-metadata clickhouse +${COMPOSE_BIN} up --abort-on-container-exit --exit-code-from superset-init superset-init +${COMPOSE_BIN} up -d --no-deps superset + +echo "Шаг 8: техническая проверка аналитического контура" +GEN_MODEL_T0="${GEN_MODEL_T0}" \ +GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ +CHECK_LIVE_SEAM=0 \ +COMPOSE_BIN="${COMPOSE_BIN}" \ +bash "${SCRIPT_DIR}/check_generated_analytics.sh" + +echo "" +echo "Готово: стартовая история генератора доведена до DM и Superset." diff --git a/scripts/run_generated_history_runtime_check.sh b/scripts/run_generated_history_runtime_check.sh new file mode 100644 index 0000000..32ab7d7 --- /dev/null +++ b/scripts/run_generated_history_runtime_check.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# Быстрый runtime gate для issue 17: проверяет, что daily-wave доходит до +# manifest/check, а live-продолжение реально записывает стык backfill/live. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +PROFILE="${PROFILE:-daily-wave}" +GEN_HISTORY_DURATION="${GEN_HISTORY_DURATION:-1h}" +WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}" +WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-5}" +LIVE_SECONDS="${LIVE_SECONDS:-25}" +GEN_LIVE_CHECK_MINUTES="${GEN_LIVE_CHECK_MINUTES:-10}" +WAIT_LIVE_ROWS_SECONDS="${WAIT_LIVE_ROWS_SECONDS:-2}" + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + backfill \ + --profile "${PROFILE}" \ + --duration "${GEN_HISTORY_DURATION}" +)" +eval "${launch_env}" + +wait_for_clickhouse() { + local deadline + deadline=$((SECONDS + WAIT_CLICKHOUSE_SECONDS)) + + until ${COMPOSE_BIN} exec -T clickhouse clickhouse-client \ + --user=default \ + --password=123456 \ + --query "SELECT 1" >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + echo "Ошибка: ClickHouse не ответил за ${WAIT_CLICKHOUSE_SECONDS} сек." >&2 + exit 1 + fi + sleep 2 + done +} + +stg_total_rows() { + ${COMPOSE_BIN} exec -T clickhouse clickhouse-client \ + --user=default \ + --password=123456 \ + --query " +SELECT + (SELECT count() FROM stg.browser_raw) + + (SELECT count() FROM stg.location_raw) + + (SELECT count() FROM stg.device_raw) + + (SELECT count() FROM stg.geo_raw) +FORMAT TabSeparated" +} + +stg_crossing_visits() { + ${COMPOSE_BIN} exec -T clickhouse clickhouse-client \ + --user=default \ + --password=123456 \ + --query " +WITH + parseDateTime64BestEffort('${GEN_MODEL_T_END}', 6, 'UTC') AS t_end, + t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end, + browser AS ( + SELECT + JSONExtractString(raw, 'click_id') AS click_id, + parseDateTime64BestEffortOrNull( + JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' + ) AS event_ts + FROM stg.browser_raw + WHERE click_id != '' + AND event_ts >= t_end - INTERVAL 30 MINUTE + AND event_ts < t_live_end + ) +SELECT count() +FROM +( + SELECT + click_id, + min(event_ts) AS first_ts, + max(event_ts) AS last_ts + FROM browser + GROUP BY click_id + HAVING first_ts < t_end AND last_ts >= t_end +) +FORMAT TabSeparated" +} + +cleanup_live_generator() { + ${COMPOSE_BIN} stop generator >/dev/null 2>&1 || true +} + +stop_live_generator() { + # docker compose stop возвращается только после фактической остановки контейнера. + ${COMPOSE_BIN} stop generator +} +trap cleanup_live_generator EXIT + +echo "=== Быстрая runtime-проверка startup-history/live seam ===" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +echo "" + +echo "Шаг 0: очистка volumes ClickHouse/Kafka/state" +${COMPOSE_BIN} --profile live-generator down -v --remove-orphans + +echo "Шаг 1: запуск ClickHouse и Kafka" +${COMPOSE_BIN} up -d clickhouse kafka +wait_for_clickhouse + +echo "Шаг 2: применение DDL" +bash "${SCRIPT_DIR}/apply_clickhouse_ddl.sh" + +echo "Шаг 3: сборка образа генератора" +${COMPOSE_BIN} build generator + +echo "Шаг 4: короткий backfill стартовой истории" +${COMPOSE_BIN} run --rm --no-deps \ + -e GEN_RUN_MODE=backfill \ + -e GEN_STATE_RESET=true \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator + +echo "Шаг 5: первый batch STG -> ODS -> DDS -> DM" +sleep "${WAIT_STG_SECONDS}" +bash "${SCRIPT_DIR}/run_batch.sh" +stg_rows_before_live="$(stg_total_rows)" + +echo "Шаг 6: live-продолжение, ждём переходящий визит в STG до ${LIVE_SECONDS} сек." +GEN_RUN_MODE=live \ +GEN_STATE_RESET=false \ +GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ +GEN_SEED="${GEN_SEED}" \ +GEN_MODEL_T0="${GEN_MODEL_T0}" \ +GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ +GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ +GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ +GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ +GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ +GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ +GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ +GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ +${COMPOSE_BIN} up -d --build generator +deadline=$((SECONDS + LIVE_SECONDS)) +while true; do + crossing_visits="$(stg_crossing_visits)" + [[ "${crossing_visits}" =~ ^[0-9]+$ ]] \ + || { echo "Ошибка: не удалось прочитать переходящие визиты из STG." >&2; exit 1; } + if (( crossing_visits > 0 )); then + stg_rows_after_live="$(stg_total_rows)" + echo "live_stg_rows_before=${stg_rows_before_live}" + echo "live_stg_rows_after=${stg_rows_after_live}" + echo "live_stg_crossing_visits=${crossing_visits}" + break + fi + if (( SECONDS >= deadline )); then + echo "Ошибка: live-продолжение не создало переходящий визит в STG за ${LIVE_SECONDS} сек." >&2 + exit 1 + fi + sleep 1 +done +stop_live_generator + +echo "Шаг 7: второй batch после live" +sleep "${WAIT_STG_SECONDS}" +bash "${SCRIPT_DIR}/run_batch.sh" + +echo "Шаг 8: проверка manifest/profile/live seam без Superset" +PROFILE="${PROFILE}" \ +GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ +GEN_LIVE_CHECK_MINUTES="${GEN_LIVE_CHECK_MINUTES}" \ +WAIT_LIVE_ROWS_SECONDS="${WAIT_LIVE_ROWS_SECONDS}" \ +CHECK_LIVE_SEAM=1 \ +REQUIRE_SUPERSET=0 \ +COMPOSE_BIN="${COMPOSE_BIN}" \ +bash "${SCRIPT_DIR}/check_generated_analytics.sh" + +echo "" +echo "Готово: runtime-проверка startup-history/live seam прошла." diff --git a/scripts/run_generator.sh b/scripts/run_generator.sh new file mode 100644 index 0000000..c908d87 --- /dev/null +++ b/scripts/run_generator.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Запускает генератор через понятные глаголы поверх старых env-переменных. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Использование: $0 backfill|continue|reset" >&2 + exit 2 +fi + +VERB="$1" +case "${VERB}" in + backfill|continue|reset) ;; + *) + echo "Ошибка: неизвестный глагол запуска: ${VERB}" >&2 + exit 2 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + "${VERB}" \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + +echo "=== Запуск генератора: ${GEN_LAUNCH_VERB} ===" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +fi +echo "GEN_RUN_MODE=${GEN_RUN_MODE}" +echo "GEN_STATE_RESET=${GEN_STATE_RESET}" +if [[ -n "${GEN_MODEL_T_END:-}" ]]; then + echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +fi +echo "" + +if [[ "${VERB}" == "backfill" ]]; then + echo "Шаг 1: запуск Kafka и ClickHouse" + ${COMPOSE_BIN} up -d kafka clickhouse + + echo "Шаг 2: предпроверка чистого стенда" + bash "${SCRIPT_DIR}/assert_stand_clean.sh" clean + + echo "Шаг 3: сборка образа генератора" + ${COMPOSE_BIN} build generator + + echo "Шаг 4: backfill стартовой истории" + ${COMPOSE_BIN} run --rm --no-deps \ + -e GEN_RUN_MODE="${GEN_RUN_MODE}" \ + -e GEN_STATE_RESET="${GEN_STATE_RESET}" \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator +elif [[ "${VERB}" == "continue" ]]; then + echo "Шаг 1: запуск Kafka и ClickHouse" + ${COMPOSE_BIN} up -d kafka clickhouse + + echo "Шаг 2: проверка state или чистого стенда" + bash "${SCRIPT_DIR}/assert_stand_clean.sh" continue + + echo "Шаг 3: запуск live-генератора" + ${COMPOSE_BIN} up -d --build generator +else + echo "Шаг 1: запуск Kafka и ClickHouse" + ${COMPOSE_BIN} up -d kafka clickhouse + + echo "Шаг 2: предпроверка чистого стенда" + bash "${SCRIPT_DIR}/assert_stand_clean.sh" clean + + echo "Шаг 3: запуск live-генератора" + ${COMPOSE_BIN} up -d --build generator +fi diff --git a/superset/create_dashboard.py b/superset/create_dashboard.py index 43bef21..1a553cb 100644 --- a/superset/create_dashboard.py +++ b/superset/create_dashboard.py @@ -147,18 +147,42 @@ CHARTS_CONFIG = [ }, # География { - "slice_name": "🌍 Geography Map", - "viz_type": "world_map", + "slice_name": "🌍 Top Countries by Events", + "previous_slice_names": ["🌍 Geography Map"], + # Прежняя геовизуализация показывала разреженную географию плохо: нет + # явной легенды, подписи единиц и стабильного tooltip. Для текущего сида + # читаемее top-N стран столбцами: сразу видны страна, значение и порядок. + # Перекос стран — свойство geo-фактуры из сида, а не настройка чарта. + "viz_type": "echarts_timeseries_bar", "dataset_name": "v_events_enriched", "params": { - "entity": "geo_country", - "metric": { - "expressionType": "SQL", - "sqlExpression": "COUNT(*)", - "label": "Events" - }, - "row_limit": 500, - "linear_color_scheme": "blue_white_yellow", + "x_axis": "geo_country", + "metrics": [ + { + "expressionType": "SQL", + "sqlExpression": "COUNT(*)", + "label": "Events, pcs", + } + ], + "row_limit": 15, + "order_desc": True, + "sort_series_type": "sum", + "x_axis_sort": "Events, pcs", + "x_axis_sort_asc": False, + "orientation": "vertical", + "color_scheme": "supersetColors", + "show_legend": True, + "legendOrientation": "top", + "legendType": "scroll", + "rich_tooltip": True, + "tooltipTimeFormat": "smart_date", + "x_axis_title": "Country", + "x_axis_title_margin": 15, + "truncateXAxis": True, + "y_axis_title": "Events, pcs", + "y_axis_title_margin": 15, + "y_axis_title_position": "Left", + "y_axis_format": ",d", "time_range": "No filter" } }, @@ -290,7 +314,7 @@ DASHBOARD_ROWS = [ # Динамика во времени + разрез по устройствам [("📅 Events over Time", 8), ("📱 Traffic by Device", 4)], # География + эффективность маркетинговых каналов - [("🌍 Geography Map", 6), ("🔗 UTM Effectiveness Table", 6)], + [("🌍 Top Countries by Events", 6), ("🔗 UTM Effectiveness Table", 6)], # Популярные страницы + прохождение строк по слоям [("🪜 Page Funnel", 6), ("🧱 Rows by Layer (event)", 6)], ] @@ -360,6 +384,35 @@ def sync_query_context(chart, params: dict, dataset_id: int) -> None: chart.query_context = json.dumps(query_context) +def choose_chart_to_sync(existing_charts: list, current_name: str, dataset_id: int | None = None): + """Выбирает один chart для синхронизации и отдаёт лишние дубли на удаление.""" + if dataset_id is not None: + existing_charts = [ + chart + for chart in existing_charts + if getattr(chart, "datasource_id", None) == dataset_id + ] + if not existing_charts: + return None, [] + + selected = next( + (chart for chart in existing_charts if chart.slice_name == current_name), + existing_charts[0], + ) + duplicates = [chart for chart in existing_charts if chart is not selected] + return selected, duplicates + + +def dashboard_owned_charts(existing_charts: list, dashboard_slug: str) -> list: + """Оставляет только charts, уже привязанные к целевому dashboard.""" + owned = [] + for chart in existing_charts: + dashboards = getattr(chart, "dashboards", []) or [] + if any(getattr(dashboard, "slug", None) == dashboard_slug for dashboard in dashboards): + owned.append(chart) + return owned + + def build_dashboard_metadata(filter_dataset_id: int | None) -> str: """Формирует json_metadata с валидными datasetId для native filters.""" native_filters = [] @@ -435,6 +488,9 @@ def main() -> bool: created_charts = [] datasets_by_name = {} + existing_dashboard = db.session.query(Dashboard).filter_by( + slug=DASHBOARD_CONFIG["slug"] + ).first() # Создаём чарты for chart_config in CHARTS_CONFIG: @@ -467,11 +523,30 @@ def main() -> bool: # идемпотентного rename без дублей в списке Charts. chart_names = [chart_config["slice_name"]] chart_names.extend(chart_config.get("previous_slice_names", [])) - existing = db.session.query(Slice).filter( - Slice.slice_name.in_(chart_names) - ).order_by(Slice.id.asc()).first() + existing_charts = db.session.query(Slice).filter( + Slice.slice_name.in_(chart_names), + Slice.datasource_id == dataset.id, + ).order_by(Slice.id.asc()).all() + if existing_dashboard: + existing_charts = dashboard_owned_charts( + existing_charts, + DASHBOARD_CONFIG["slug"], + ) + existing, duplicate_charts = choose_chart_to_sync( + existing_charts, + chart_config["slice_name"], + dataset_id=dataset.id, + ) if existing: + for duplicate in duplicate_charts: + db.session.delete(duplicate) + logger.info( + "Deleted duplicate chart after rename: %s (ID: %s)", + duplicate.slice_name, + duplicate.id, + ) + # Синхронизируем параметры существующего чарта с конфигом. existing.slice_name = chart_config["slice_name"] existing.viz_type = chart_config["viz_type"] @@ -515,8 +590,19 @@ def main() -> bool: logger.info(f"Created/Found {len(created_charts)} charts") current_chart_names = {chart_config["slice_name"] for chart_config in CHARTS_CONFIG} + target_dataset_ids = set(datasets_by_name.values()) for obsolete_name in sorted(OBSOLETE_CHART_NAMES - current_chart_names): - obsolete_charts = db.session.query(Slice).filter_by(slice_name=obsolete_name).all() + if not existing_dashboard: + obsolete_charts = [] + else: + obsolete_charts = db.session.query(Slice).filter( + Slice.slice_name == obsolete_name, + Slice.datasource_id.in_(target_dataset_ids), + ).all() + obsolete_charts = dashboard_owned_charts( + obsolete_charts, + DASHBOARD_CONFIG["slug"], + ) for obsolete in obsolete_charts: db.session.delete(obsolete) logger.info("Deleted obsolete chart: %s (ID: %s)", obsolete_name, obsolete.id) @@ -604,9 +690,7 @@ def main() -> bool: if created_charts: try: # Проверяем, существует ли дашборд - existing = db.session.query(Dashboard).filter_by( - slug=DASHBOARD_CONFIG["slug"] - ).first() + existing = existing_dashboard if existing: existing.description = DASHBOARD_CONFIG["description"] diff --git a/superset/dashboards/ecommerce_analytics.zip.json b/superset/dashboards/ecommerce_analytics.zip.json index ca643c0..a92616c 100644 --- a/superset/dashboards/ecommerce_analytics.zip.json +++ b/superset/dashboards/ecommerce_analytics.zip.json @@ -3,12 +3,23 @@ { "__Dashboard__": { "dashboard_title": "🛒 E-commerce Analytics Dashboard", - "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и качество данных.", + "description": "Аналитический дашборд для e-commerce кликстрима: трафик, конверсии, география и прохождение строк по слоям.", "slug": "ecommerce-analytics", "published": true, "json_metadata": "{\"native_filter_configuration\": [{\"id\": \"date_filter\", \"name\": \"📅 Date Range\", \"filterType\": \"filter_time\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"event_date\"}}], \"defaultValue\": \"Last week\", \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"cascadeParentIds\": [], \"isInstant\": true}, {\"id\": \"country_filter\", \"name\": \"🌍 Country\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"geo_country\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}, {\"id\": \"device_filter\", \"name\": \"📱 Device Type\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"device_type\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}, {\"id\": \"browser_filter\", \"name\": \"🌐 Browser\", \"filterType\": \"filter_select\", \"targets\": [{\"datasetId\": null, \"column\": {\"name\": \"browser_name\"}}], \"scope\": {\"root\": [\"ROOT_ID\"], \"excluded\": []}, \"isInstant\": true, \"allowsMultipleValues\": true, \"isRequired\": false}], \"color_scheme\": \"supersetColors\", \"label_colors\": {}}", - "position_json": "{\"DASHBOARD_VERSION_KEY\": \"v2\", \"CHART-1\": {\"id\": \"CHART-1\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 1, \"sliceName\": \"📊 Total Events\", \"height\": 50, \"width\": 4, \"x\": 0, \"y\": 0}}, \"CHART-2\": {\"id\": \"CHART-2\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 2, \"sliceName\": \"👤 Unique Users\", \"height\": 50, \"width\": 4, \"x\": 4, \"y\": 0}}, \"CHART-3\": {\"id\": \"CHART-3\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 3, \"sliceName\": \"🎯 Unique Sessions\", \"height\": 50, \"width\": 4, \"x\": 8, \"y\": 0}}, \"CHART-4\": {\"id\": \"CHART-4\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 4, \"sliceName\": \"📈 Avg Events/Session\", \"height\": 50, \"width\": 4, \"x\": 0, \"y\": 50}}, \"CHART-5\": {\"id\": \"CHART-5\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 5, \"sliceName\": \"📅 Events by Hour\", \"height\": 50, \"width\": 8, \"x\": 0, \"y\": 100}}, \"CHART-6\": {\"id\": \"CHART-6\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 6, \"sliceName\": \"📱 Traffic by Device\", \"height\": 50, \"width\": 4, \"x\": 8, \"y\": 100}}, \"CHART-7\": {\"id\": \"CHART-7\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 7, \"sliceName\": \"🌍 Geography Map\", \"height\": 50, \"width\": 6, \"x\": 0, \"y\": 150}}, \"CHART-8\": {\"id\": \"CHART-8\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 8, \"sliceName\": \"🔗 UTM Effectiveness Table\", \"height\": 50, \"width\": 6, \"x\": 6, \"y\": 150}}, \"CHART-9\": {\"id\": \"CHART-9\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 9, \"sliceName\": \"📄 Top Pages\", \"height\": 50, \"width\": 6, \"x\": 0, \"y\": 200}}, \"CHART-10\": {\"id\": \"CHART-10\", \"type\": \"CHART\", \"parents\": [\"ROOT_ID\"], \"meta\": {\"chartId\": 10, \"sliceName\": \"🔍 Data Quality Summary\", \"height\": 50, \"width\": 6, \"x\": 6, \"y\": 200}}}", - "slices": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + "position_json": "{\"DASHBOARD_VERSION_KEY\": \"v2\", \"ROOT_ID\": {\"id\": \"ROOT_ID\", \"type\": \"ROOT\", \"children\": [\"GRID_ID\"]}, \"GRID_ID\": {\"id\": \"GRID_ID\", \"type\": \"GRID\", \"children\": [\"ROW-0\", \"ROW-1\", \"ROW-2\", \"ROW-3\"], \"parents\": [\"ROOT_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-1\": {\"id\": \"CHART-1\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 1, \"sliceName\": \"📊 Total Events\", \"width\": 3, \"height\": 30}}, \"CHART-2\": {\"id\": \"CHART-2\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 2, \"sliceName\": \"👤 Unique Users\", \"width\": 3, \"height\": 30}}, \"CHART-3\": {\"id\": \"CHART-3\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 3, \"sliceName\": \"📈 Avg Events/Visit\", \"width\": 3, \"height\": 30}}, \"CHART-4\": {\"id\": \"CHART-4\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-0\"], \"meta\": {\"chartId\": 4, \"sliceName\": \"🎯 Conversion to /confirmation\", \"width\": 3, \"height\": 30}}, \"ROW-0\": {\"id\": \"ROW-0\", \"type\": \"ROW\", \"children\": [\"CHART-1\", \"CHART-2\", \"CHART-3\", \"CHART-4\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-5\": {\"id\": \"CHART-5\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-1\"], \"meta\": {\"chartId\": 5, \"sliceName\": \"📅 Events over Time\", \"width\": 8, \"height\": 60}}, \"CHART-6\": {\"id\": \"CHART-6\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-1\"], \"meta\": {\"chartId\": 6, \"sliceName\": \"📱 Traffic by Device\", \"width\": 4, \"height\": 60}}, \"ROW-1\": {\"id\": \"ROW-1\", \"type\": \"ROW\", \"children\": [\"CHART-5\", \"CHART-6\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-7\": {\"id\": \"CHART-7\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-2\"], \"meta\": {\"chartId\": 7, \"sliceName\": \"🌍 Top Countries by Events\", \"width\": 6, \"height\": 60}}, \"CHART-8\": {\"id\": \"CHART-8\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-2\"], \"meta\": {\"chartId\": 8, \"sliceName\": \"🔗 UTM Effectiveness Table\", \"width\": 6, \"height\": 60}}, \"ROW-2\": {\"id\": \"ROW-2\", \"type\": \"ROW\", \"children\": [\"CHART-7\", \"CHART-8\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}, \"CHART-9\": {\"id\": \"CHART-9\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-3\"], \"meta\": {\"chartId\": 9, \"sliceName\": \"🪜 Page Funnel\", \"width\": 6, \"height\": 60}}, \"CHART-10\": {\"id\": \"CHART-10\", \"type\": \"CHART\", \"children\": [], \"parents\": [\"ROOT_ID\", \"GRID_ID\", \"ROW-3\"], \"meta\": {\"chartId\": 10, \"sliceName\": \"🧱 Rows by Layer (event)\", \"width\": 6, \"height\": 60}}, \"ROW-3\": {\"id\": \"ROW-3\", \"type\": \"ROW\", \"children\": [\"CHART-9\", \"CHART-10\"], \"parents\": [\"ROOT_ID\", \"GRID_ID\"], \"meta\": {\"background\": \"BACKGROUND_TRANSPARENT\"}}}", + "slices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ] } } ], @@ -16,51 +27,51 @@ { "__Slice__": { "slice_name": "📊 Total Events", - "viz_type": "big_number", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"column\": null, \"aggregate\": null, \"label\": \"Total Events\", \"optionName\": \"metric_1\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Общее количество событий" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"column\": null, \"aggregate\": null, \"label\": \"Total Events\", \"optionName\": \"metric_1\"}, \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { "slice_name": "👤 Unique Users", - "viz_type": "big_number", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT user_domain_id)\", \"label\": \"Unique Users\", \"optionName\": \"metric_2\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Уникальные пользователи" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT user_domain_id)\", \"label\": \"Unique Users\", \"optionName\": \"metric_2\"}, \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "🎯 Unique Sessions", - "viz_type": "big_number", + "slice_name": "📈 Avg Events/Visit", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(DISTINCT click_id)\", \"label\": \"Unique Sessions\", \"optionName\": \"metric_3\"}, \"y_axis_format\": \",d\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Уникальные сессии" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*) / COUNT(DISTINCT click_id)\", \"label\": \"Avg Events/Visit\", \"optionName\": \"metric_4\"}, \"y_axis_format\": \".1f\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "📈 Avg Events/Session", - "viz_type": "big_number", + "slice_name": "🎯 Conversion to /confirmation", + "viz_type": "big_number_total", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"big_number\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*) / COUNT(DISTINCT click_id)\", \"label\": \"Avg Events/Session\", \"optionName\": \"metric_4\"}, \"y_axis_format\": \".2f\", \"show_trend_line\": false, \"time_range\": \"No filter\"}", - "description": "Среднее количество событий на сессию" + "params": "{\"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"if(countIf(page_url_path = '/home') = 0, 0, countIf(page_url_path = '/confirmation') / countIf(page_url_path = '/home'))\", \"label\": \"Conversion to /confirmation\", \"optionName\": \"metric_5\"}, \"y_axis_format\": \".1%\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"big_number_total\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "📅 Events by Hour", + "slice_name": "📅 Events over Time", "viz_type": "echarts_timeseries_line", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_line\", \"granularity_sqla\": \"event_ts\", \"time_grain_sqla\": \"PT1H\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}], \"groupby\": [], \"time_range\": \"Last week\", \"adhoc_filters\": [], \"row_limit\": 10000}", - "description": "События по часам" + "params": "{\"granularity_sqla\": \"event_ts\", \"time_grain_sqla\": \"PT5M\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}], \"groupby\": [], \"time_range\": \"No filter\", \"adhoc_filters\": [], \"row_limit\": 10000, \"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_line\"}", + "description": "Chart created automatically for v_events_enriched" } }, { @@ -69,18 +80,18 @@ "viz_type": "pie", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"pie\", \"groupby\": [\"device_type\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Count\"}, \"row_limit\": 100, \"donut\": true, \"show_legend\": true, \"labels_outside\": true, \"time_range\": \"No filter\"}", - "description": "Распределение трафика по устройствам" + "params": "{\"groupby\": [\"device_type\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Count\"}, \"row_limit\": 100, \"donut\": true, \"show_legend\": true, \"labels_outside\": true, \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"pie\"}", + "description": "Chart created automatically for v_events_enriched" } }, { "__Slice__": { - "slice_name": "🌍 Geography Map", - "viz_type": "world_map", + "slice_name": "🌍 Top Countries by Events", + "viz_type": "echarts_timeseries_bar", "datasource_type": "table", "datasource_name": "dm.v_events_enriched", - "params": "{\"datasource\": \"1__table\", \"viz_type\": \"world_map\", \"entity\": \"geo_country\", \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events\"}, \"row_limit\": 500, \"linear_color_scheme\": \"blue_white_yellow\", \"time_range\": \"No filter\"}", - "description": "География посетителей" + "params": "{\"x_axis\": \"geo_country\", \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"COUNT(*)\", \"label\": \"Events, pcs\"}], \"row_limit\": 15, \"order_desc\": true, \"sort_series_type\": \"sum\", \"x_axis_sort\": \"Events, pcs\", \"x_axis_sort_asc\": false, \"orientation\": \"vertical\", \"color_scheme\": \"supersetColors\", \"show_legend\": true, \"legendOrientation\": \"top\", \"legendType\": \"scroll\", \"rich_tooltip\": true, \"tooltipTimeFormat\": \"smart_date\", \"x_axis_title\": \"Country\", \"x_axis_title_margin\": 15, \"truncateXAxis\": true, \"y_axis_title\": \"Events, pcs\", \"y_axis_title_margin\": 15, \"y_axis_title_position\": \"Left\", \"y_axis_format\": \",d\", \"time_range\": \"No filter\", \"datasource\": \"1__table\", \"viz_type\": \"echarts_timeseries_bar\"}", + "description": "Chart created automatically for v_events_enriched" } }, { @@ -89,28 +100,28 @@ "viz_type": "table", "datasource_type": "table", "datasource_name": "dm.v_utm_effectiveness", - "params": "{\"datasource\": \"2__table\", \"viz_type\": \"table\", \"groupby\": [\"utm_source\", \"utm_medium\", \"utm_campaign\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(clicks)\", \"label\": \"Clicks\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_users)\", \"label\": \"Users\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_sessions)\", \"label\": \"Sessions\"}], \"row_limit\": 100, \"time_range\": \"No filter\", \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"utm_source IS NOT NULL\", \"subject\": null, \"operator\": null, \"comparator\": null}]}\n", - "description": "Эффективность UTM-кампаний" + "params": "{\"groupby\": [\"utm_source\", \"utm_medium\", \"utm_campaign\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(clicks)\", \"label\": \"Clicks\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_users)\", \"label\": \"Users\"}, {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(uniq_sessions)\", \"label\": \"Sessions\"}], \"row_limit\": 100, \"time_range\": \"No filter\", \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"utm_source IS NOT NULL\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"datasource\": \"2__table\", \"viz_type\": \"table\"}", + "description": "Chart created automatically for v_utm_effectiveness" } }, { "__Slice__": { - "slice_name": "📄 Top Pages", - "viz_type": "dist_bar", + "slice_name": "🪜 Page Funnel", + "viz_type": "funnel", "datasource_type": "table", "datasource_name": "dm.v_top_pages_daily", - "params": "{\"datasource\": \"3__table\", \"viz_type\": \"dist_bar\", \"groupby\": [\"page_url_path\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(pageviews)\", \"label\": \"Pageviews\"}], \"row_limit\": 20, \"order_by_cols\": [[\"SUM(pageviews)\", false]], \"time_range\": \"No filter\", \"orientation\": \"vertical\", \"show_legend\": false}", - "description": "Топ страниц по просмотрам" + "params": "{\"groupby\": [\"page_url_path\"], \"metric\": {\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(pageviews)\", \"label\": \"Pageviews\"}, \"row_limit\": 20, \"time_range\": \"No filter\", \"sort_by_metric\": true, \"percent_calculation_type\": \"first_step\", \"color_scheme\": \"supersetColors\", \"show_legend\": true, \"legendOrientation\": \"top\", \"legendMargin\": 50, \"tooltip_label_type\": 5, \"number_format\": \"SMART_NUMBER\", \"show_labels\": true, \"show_tooltip_labels\": true, \"datasource\": \"3__table\", \"viz_type\": \"funnel\"}", + "description": "Chart created automatically for v_top_pages_daily" } }, { "__Slice__": { - "slice_name": "🔍 Data Quality Summary", + "slice_name": "🧱 Rows by Layer (event)", "viz_type": "dist_bar", "datasource_type": "table", "datasource_name": "dm.dq_summary", - "params": "{\"datasource\": \"4__table\", \"viz_type\": \"dist_bar\", \"groupby\": [\"layer\"], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(check_value)\", \"label\": \"Row Count\"}], \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"check_name = 'total_rows'\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"row_limit\": 100, \"time_range\": \"No filter\", \"show_legend\": false}", - "description": "Сводка по качеству данных" + "params": "{\"groupby\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"multiIf(layer = 'stg', '1 · stg', layer = 'ods', '2 · ods', layer = 'dds', '3 · dds', '4 · dm')\", \"label\": \"Layer\"}], \"metrics\": [{\"expressionType\": \"SQL\", \"sqlExpression\": \"SUM(check_value)\", \"label\": \"Rows\"}], \"adhoc_filters\": [{\"clause\": \"WHERE\", \"expressionType\": \"SQL\", \"sqlExpression\": \"check_name = 'total_rows' AND table_name IN ('browser_raw', 'browser_event', 'event', 'v_events_enriched')\", \"subject\": null, \"operator\": null, \"comparator\": null}], \"order_bars\": true, \"row_limit\": 100, \"time_range\": \"No filter\", \"y_axis_format\": \",d\", \"show_legend\": false, \"datasource\": \"4__table\", \"viz_type\": \"dist_bar\"}", + "description": "Chart created automatically for dq_summary" } } ], @@ -122,18 +133,66 @@ "database": "clickhouse_dwh", "description": "Полная обогащённая витрина событий (event + click)", "columns": [ - {"column_name": "event_id", "type": "UUID", "description": "UUID события"}, - {"column_name": "event_ts", "type": "DateTime64(6)", "description": "Время события"}, - {"column_name": "event_date", "type": "Date", "description": "Дата события"}, - {"column_name": "event_type", "type": "String", "description": "Тип события"}, - {"column_name": "click_id", "type": "UUID", "description": "ID сессии"}, - {"column_name": "user_domain_id", "type": "UUID", "description": "ID пользователя"}, - {"column_name": "device_type", "type": "String", "description": "Тип устройства"}, - {"column_name": "geo_country", "type": "String", "description": "Страна"}, - {"column_name": "browser_name", "type": "String", "description": "Браузер"}, - {"column_name": "utm_source", "type": "String", "description": "UTM Source"}, - {"column_name": "utm_medium", "type": "String", "description": "UTM Medium"}, - {"column_name": "page_url_path", "type": "String", "description": "Путь URL"} + { + "column_name": "event_id", + "type": "UUID", + "description": "UUID события" + }, + { + "column_name": "event_ts", + "type": "DateTime64(6)", + "description": "Время события" + }, + { + "column_name": "event_date", + "type": "Date", + "description": "Дата события" + }, + { + "column_name": "event_type", + "type": "String", + "description": "Тип события" + }, + { + "column_name": "click_id", + "type": "UUID", + "description": "ID сессии" + }, + { + "column_name": "user_domain_id", + "type": "UUID", + "description": "ID пользователя" + }, + { + "column_name": "device_type", + "type": "String", + "description": "Тип устройства" + }, + { + "column_name": "geo_country", + "type": "String", + "description": "Страна" + }, + { + "column_name": "browser_name", + "type": "String", + "description": "Браузер" + }, + { + "column_name": "utm_source", + "type": "String", + "description": "UTM Source" + }, + { + "column_name": "utm_medium", + "type": "String", + "description": "UTM Medium" + }, + { + "column_name": "page_url_path", + "type": "String", + "description": "Путь URL" + } ] } }, @@ -144,13 +203,41 @@ "database": "clickhouse_dwh", "description": "Эффективность UTM-кампаний", "columns": [ - {"column_name": "event_date", "type": "Date", "description": "Дата"}, - {"column_name": "utm_source", "type": "String", "description": "UTM Source"}, - {"column_name": "utm_medium", "type": "String", "description": "UTM Medium"}, - {"column_name": "utm_campaign", "type": "String", "description": "UTM Campaign"}, - {"column_name": "clicks", "type": "UInt64", "description": "Клики"}, - {"column_name": "uniq_users", "type": "UInt64", "description": "Уникальные пользователи"}, - {"column_name": "uniq_sessions", "type": "UInt64", "description": "Уникальные сессии"} + { + "column_name": "event_date", + "type": "Date", + "description": "Дата" + }, + { + "column_name": "utm_source", + "type": "String", + "description": "UTM Source" + }, + { + "column_name": "utm_medium", + "type": "String", + "description": "UTM Medium" + }, + { + "column_name": "utm_campaign", + "type": "String", + "description": "UTM Campaign" + }, + { + "column_name": "clicks", + "type": "UInt64", + "description": "Клики" + }, + { + "column_name": "uniq_users", + "type": "UInt64", + "description": "Уникальные пользователи" + }, + { + "column_name": "uniq_sessions", + "type": "UInt64", + "description": "Уникальные сессии" + } ] } }, @@ -161,10 +248,26 @@ "database": "clickhouse_dwh", "description": "Популярность страниц по дням", "columns": [ - {"column_name": "event_date", "type": "Date", "description": "Дата"}, - {"column_name": "page_url_path", "type": "String", "description": "Путь URL"}, - {"column_name": "pageviews", "type": "UInt64", "description": "Просмотры"}, - {"column_name": "uniq_clicks", "type": "UInt64", "description": "Уникальные клики"} + { + "column_name": "event_date", + "type": "Date", + "description": "Дата" + }, + { + "column_name": "page_url_path", + "type": "String", + "description": "Путь URL" + }, + { + "column_name": "pageviews", + "type": "UInt64", + "description": "Просмотры" + }, + { + "column_name": "uniq_clicks", + "type": "UInt64", + "description": "Уникальные клики" + } ] } }, @@ -175,11 +278,31 @@ "database": "clickhouse_dwh", "description": "Сводка по качеству данных", "columns": [ - {"column_name": "check_date", "type": "Date", "description": "Дата проверки"}, - {"column_name": "layer", "type": "String", "description": "Слой (stg/ods/dds)"}, - {"column_name": "table_name", "type": "String", "description": "Имя таблицы"}, - {"column_name": "check_name", "type": "String", "description": "Тип проверки"}, - {"column_name": "check_value", "type": "UInt64", "description": "Значение"} + { + "column_name": "check_date", + "type": "Date", + "description": "Дата проверки" + }, + { + "column_name": "layer", + "type": "String", + "description": "Слой (stg/ods/dds)" + }, + { + "column_name": "table_name", + "type": "String", + "description": "Имя таблицы" + }, + { + "column_name": "check_name", + "type": "String", + "description": "Тип проверки" + }, + { + "column_name": "check_value", + "type": "UInt64", + "description": "Значение" + } ] } } diff --git a/tests/test_generated_analytics_check_contract.py b/tests/test_generated_analytics_check_contract.py new file mode 100644 index 0000000..0ecc4c8 --- /dev/null +++ b/tests/test_generated_analytics_check_contract.py @@ -0,0 +1,575 @@ +from pathlib import Path +import os +import re +import subprocess +import textwrap + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_generated_analytics.sh" +COMPOSE_FILE = REPO_ROOT / "docker-compose.yml" +OPERATIONS_DOC = REPO_ROOT / "docs" / "OPERATIONS.md" +CHAIN_CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_generated_history_chain.sh" + + +def _required_runtime_line(script, command): + matches = [ + line_number + for line_number, line in enumerate(script.splitlines()) + if line == command + ] + assert len(matches) == 1, f"{command} должна быть отдельной командой ровно один раз" + return matches[0] + + +def _fake_compose( + tmp_path, + *, + manifest_profile="ci", + live_rows="3", + live_pairing="3\t0\t0\t0", + seam_context="19\t19\t19", + views_mode="ok", +): + fake = tmp_path / "docker-compose" + fake.write_text( + textwrap.dedent( + f"""\ + #!/usr/bin/env bash + set -euo pipefail + + args="$*" + query="" + previous="" + for arg in "$@"; do + if [[ "$previous" == "--query" ]]; then + query="$arg" + break + fi + previous="$arg" + done + + if [[ "$args" == *"kafka-manifest-summary"* ]]; then + printf '%s\\n' '16054\t2800\t930\t2026-01-01T00:00:00+00:00\t2026-01-01T05:59:00+00:00\t2026-01-01T00:00:00+00:00\t2026-01-01T06:00:00+00:00\t{manifest_profile}' + exit 0 + fi + + if [[ "$args" == *"clickhouse-client"* ]]; then + if [[ "$query" == *"hex(sipHash128"* ]]; then + printf '%s\\n' '16054\t2800\t930\t2026-01-01 00:00:00.000000\t2026-01-01 05:59:00.000000\t1\t1\t1\tdeadbeef' + elif [[ "$query" == *"returning_users / users"* ]]; then + printf '%s\\n' '930\t120\t0.129\t4' + elif [[ "$query" == *"short_visit_share"* ]]; then + printf '%s\\n' '2800\t0.2\t5\t5.7\t0\t120\t900\t18' + elif [[ "$query" == *"minIf(event_ts"* ]]; then + printf '%s\\n' '2800\t2100\t1500\t900\t650\t1\t0.23' + elif [[ "$query" == *"has_home"* ]]; then + printf '%s\\n' '2800\t2200\t1600\t950\t700\t1\t0.25' + elif [[ "$query" == *"SELECT count()"* && "$query" == *"FROM dds.event"* ]]; then + printf '%s\\n' '{live_rows}' + elif [[ "$query" == *"uniqExact(event_id)"* ]]; then + printf '%s\\n' '16200\t16200\t0' + elif [[ "$query" == *"unpaired_location_rows"* ]]; then + printf '%s\\n' '{live_pairing}' + elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then + printf '%s\\n' '{seam_context}' + elif [[ "$query" == *"ods_device_rows"* ]]; then + printf '%s\\n' '19\t19\t19\t0\t0' + elif [[ "$query" == *"SELECT source, rows"* ]]; then + if [[ "{views_mode}" == "fail" ]]; then + exit 42 + fi + printf '%s\\n' \ + 'dm.dq_summary\t1' \ + 'dm.v_daily_traffic\t1' \ + 'dm.v_events_enriched\t16054' \ + 'dm.v_session_overview\t1' \ + 'dm.v_top_pages_daily\t1' \ + 'dm.v_utm_effectiveness\t1' + else + echo "unexpected ClickHouse query: $query" >&2 + exit 91 + fi + exit 0 + fi + + echo "unexpected compose call: $args" >&2 + exit 92 + """ + ), + encoding="utf-8", + ) + fake.chmod(0o755) + return fake + + +def _run_generated_history_check(tmp_path, *, fake_compose, **env_overrides): + env = os.environ.copy() + env.update( + { + "COMPOSE_BIN": str(fake_compose), + "REQUIRE_SUPERSET": "0", + "WAIT_LIVE_ROWS_SECONDS": "0", + } + ) + env.update(env_overrides) + return subprocess.run( + ["bash", str(CHECK_SCRIPT)], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + check=False, + ) + + +def test_generated_history_check_uses_actual_manifest_boundary_and_profile(): + """Проверка берёт стык и профиль из manifest фактического прогона.""" + script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text( + encoding="utf-8" + ) + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "kafka-manifest-summary" in script + assert "manifest_model_t_end" in script + assert "CH_MODEL_T_END=\"$(clickhouse_datetime_literal \"${manifest_model_t_end}\")\"" in script + assert "PROFILE ?= ci" in makefile + assert "GEN_LAUNCH_PROFILE" in makefile + assert "PROFILE" in makefile + + +def test_generated_history_check_does_not_skip_required_live_seam(): + """Обычная проверка требует реальные live-строки после стыка.""" + script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text( + encoding="utf-8" + ) + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert 'CHECK_LIVE_SEAM="${CHECK_LIVE_SEAM:-1}"' in script + assert 'if [[ "${should_check_live_seam}" == "1" ]] && (( live_rows == 0 )); then' in script + assert "live-продолжение не записало строки" in script + assert 'CHECK_LIVE_SEAM="$${CHECK_LIVE_SEAM:-1}"' in makefile + + +def test_generated_history_check_rejects_manifest_profile_mismatch(tmp_path): + """Проверка падает, если ожидаемый профиль не совпал с manifest.""" + fake_compose = _fake_compose(tmp_path, manifest_profile="ci") + + result = _run_generated_history_check( + tmp_path, + fake_compose=fake_compose, + PROFILE="daily-wave", + CHECK_LIVE_SEAM="0", + ) + + assert result.returncode != 0 + assert "профиля daily-wave, но manifest от ci" in result.stderr + + +def test_generated_history_check_requires_live_rows_when_seam_is_required(tmp_path): + """CHECK_LIVE_SEAM=1 падает, если live-продолжение не записало строк.""" + fake_compose = _fake_compose(tmp_path, live_rows="0") + + result = _run_generated_history_check( + tmp_path, + fake_compose=fake_compose, + PROFILE="ci", + CHECK_LIVE_SEAM="1", + ) + + assert result.returncode != 0 + assert "live-продолжение не записало строки" in result.stderr + + +def test_generated_history_check_names_unpaired_live_rows(tmp_path): + """Неполный live-batch получает отдельную ошибку до проверки фактуры.""" + fake_compose = _fake_compose( + tmp_path, + live_pairing="3\t1\t0\t0", + seam_context="19\t18\t19", + ) + + result = _run_generated_history_check( + tmp_path, + fake_compose=fake_compose, + PROFILE="ci", + CHECK_LIVE_SEAM="1", + ) + + assert result.returncode != 0 + assert "непарные live-строки: location=1, device=0, geo=0 из browser=3" in result.stderr + assert "per-event фактура меняется на стыке" not in result.stderr + + +def test_live_pairing_precheck_counts_device_and_geo_messages_in_stg(): + """Старая ODS-строка click_id не скрывает пропуск live device/geo.""" + script = CHECK_SCRIPT.read_text(encoding="utf-8") + live_pairing_query = script.split('live_pairing_query="', 1)[1].split( + 'FORMAT TabSeparated"', + 1, + )[0] + + assert "FROM stg.browser_raw" in live_pairing_query + assert "FROM stg.location_raw" in live_pairing_query + assert "FROM stg.device_raw" in live_pairing_query + assert "FROM stg.geo_raw" in live_pairing_query + assert "browser_rows - ifNull(d.device_rows, 0)" in live_pairing_query + assert "browser_rows - ifNull(g.geo_rows, 0)" in live_pairing_query + assert "FROM ods.device_by_click" not in live_pairing_query + assert "FROM ods.geo_by_click" not in live_pairing_query + assert ( + "parseDateTime64BestEffort('${manifest_model_t_end}', 6, 'UTC') AS t_end" + in live_pairing_query + ) + assert live_pairing_query.count( + "JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'" + ) == 2 + + +def test_generated_history_check_still_rejects_real_per_event_change(tmp_path): + """Полные пары не скрывают настоящую смену per-event фактуры.""" + fake_compose = _fake_compose( + tmp_path, + live_pairing="3\t0\t0\t0", + seam_context="19\t18\t19", + ) + + result = _run_generated_history_check( + tmp_path, + fake_compose=fake_compose, + PROFILE="ci", + CHECK_LIVE_SEAM="1", + ) + + assert result.returncode != 0 + assert "per-event фактура меняется на стыке: 18/19" in result.stderr + assert "непарные live-строки" not in result.stderr + + +def test_generated_history_check_fails_when_dm_views_query_fails(tmp_path): + """Ошибка запроса DM-витрин не превращается в пустой успешный цикл.""" + fake_compose = _fake_compose(tmp_path, views_mode="fail") + + result = _run_generated_history_check( + tmp_path, + fake_compose=fake_compose, + PROFILE="ci", + CHECK_LIVE_SEAM="0", + ) + + assert result.returncode != 0 + assert "не удалось прочитать основные DM-витрины" in result.stderr + + +def test_clean_generated_history_run_explicitly_skips_live_seam(): + """Чистый backfill без live-продолжения отключает проверку стыка явно.""" + script = (REPO_ROOT / "scripts" / "run_generated_history_analytics.sh").read_text( + encoding="utf-8" + ) + + assert "CHECK_LIVE_SEAM=0" in script + assert script.index("CHECK_LIVE_SEAM=0") < script.index('bash "${SCRIPT_DIR}/check_generated_analytics.sh"') + + +def test_runtime_gate_has_bounded_daily_wave_live_seam_path(): + """Runtime gate issue 17 не использует полный daily-wave на 2 суток.""" + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + script = (REPO_ROOT / "scripts" / "run_generated_history_runtime_check.sh").read_text( + encoding="utf-8" + ) + + assert "generated-history-runtime-check" in makefile + assert 'PROFILE="${PROFILE:-daily-wave}"' in script + assert 'GEN_HISTORY_DURATION="${GEN_HISTORY_DURATION:-1h}"' in script + assert 'LIVE_SECONDS="${LIVE_SECONDS:-25}"' in script + assert "stg_crossing_visits()" in script + assert "crossing_visits=\"$(stg_crossing_visits)\"" in script + assert "crossing_visits > 0" in script + assert "t_end - INTERVAL 30 MINUTE" in script + assert "t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE" in script + assert "HAVING first_ts < t_end AND last_ts >= t_end" in script + assert ( + "parseDateTime64BestEffort('${GEN_MODEL_T_END}', 6, 'UTC') AS t_end" + in script + ) + assert "JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'" in script + assert "stg_rows_after_live > stg_rows_before_live" not in script + assert "не создало переходящий визит" in script + assert "GEN_RUN_MODE=live" in script + assert "GEN_STATE_RESET=false" in script + assert "up -d --build generator" in script + stop_call_line = _required_runtime_line(script, "stop_live_generator") + step_7_line = _required_runtime_line( + script, + 'echo "Шаг 7: второй batch после live"', + ) + assert stop_call_line < step_7_line + assert "CHECK_LIVE_SEAM=1" in script + assert "REQUIRE_SUPERSET=0" in script + + +def test_runtime_gate_contract_rejects_removed_stop_call(): + """Определение функции не скрывает удалённый вызов перед вторым batch.""" + script = ( + REPO_ROOT / "scripts" / "run_generated_history_runtime_check.sh" + ).read_text(encoding="utf-8") + script_without_call = script.replace("\nstop_live_generator\n", "\n", 1) + + assert "stop_live_generator" in script_without_call + with pytest.raises(AssertionError, match="отдельной командой"): + _required_runtime_line(script_without_call, "stop_live_generator") + + +def test_generator_shutdown_grace_covers_slow_batch_and_is_documented(): + """Compose даёт текущему batch минуту на завершение после SIGTERM.""" + compose = COMPOSE_FILE.read_text(encoding="utf-8") + operations = OPERATIONS_DOC.read_text(encoding="utf-8") + generator_service = compose.split("\n generator:\n", 1)[1].split( + "\n kafka-exporter:\n", + 1, + )[0] + + assert "stop_grace_period: 1m" in generator_service + assert "stop_grace_period: 1m" in operations + + +def test_generated_history_check_rejects_empty_ods_context(): + """ODS-блок стыка падает, если в ODS нет строк для переходящих визитов.""" + script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text( + encoding="utf-8" + ) + + assert "ods_device_rows" in script + assert "ods_geo_rows" in script + assert "ODS device пустой на стыке" in script + assert "ODS geo пустой на стыке" in script + + +def test_boundary_chain_has_separate_batch_check_and_keeps_live_gate_unchanged(): + """Цепочка boundaries проверяется отдельной целью, не режимом live-гейта.""" + chain = CHAIN_CHECK_SCRIPT.read_text(encoding="utf-8") + live = CHECK_SCRIPT.read_text(encoding="utf-8") + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "generated-history-chain-check" in makefile + assert "check_generated_history_chain.sh" in makefile + assert "kafka-boundaries" in chain + assert "boundaries" in chain + assert "unpaired_location_rows" in chain + assert "unpaired_device_rows" in chain + assert "unpaired_geo_rows" in chain + assert "browser_names" in chain + assert "referer_urls" in chain + assert "utm_sources" in chain + assert "per_event_homogeneous_visits" in chain + assert "toDateTime64('${boundary}', 6, 'UTC') AS boundary" in chain + assert "index < boundary_count - 1" in chain + assert "kafka-boundaries" not in live + + +def _fake_chain_compose(tmp_path): + fake = tmp_path / "chain-compose" + fake.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + + args="$*" + query="" + previous="" + for arg in "$@"; do + if [[ "$previous" == "--query" ]]; then + query="$arg" + break + fi + previous="$arg" + done + + if [[ "$args" == *"kafka-boundaries"* ]]; then + printf '%b' "${CHAIN_BOUNDARIES}" + exit 0 + fi + + if [[ "$args" == *"kafka-topic-rows"* ]]; then + printf '%s\n' "${CHAIN_MANIFEST_TOPIC_ROWS:-10 10 10 10}" + exit 0 + fi + + if [[ "$args" == *"clickhouse-client"* ]]; then + printf '%s\n' "$query" >> "${CHAIN_QUERY_LOG}" + if [[ "$query" == *"data_tail_rows"* ]]; then + printf '%s\n' "${CHAIN_TAIL_ROWS:-0}" + elif [[ "$query" == *"actual_topic_rows"* ]]; then + printf '%s\n' "${CHAIN_ACTUAL_TOPIC_ROWS:-10 10 10 10}" + elif [[ "$query" == *"FROM dm.v_events_enriched"* ]]; then + printf '%s\n' "${CHAIN_SEGMENT_ROWS:-1}" + elif [[ "$query" == *"unpaired_location_rows"* ]]; then + printf '%s\n' "${CHAIN_PAIRING:-1 0 0 0}" + elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then + printf '%s\n' "${CHAIN_CONTEXT:-1 1}" + else + printf '%s\n' '1' + fi + exit 0 + fi + + echo "unexpected compose call: $args" >&2 + exit 92 + """ + ), + encoding="utf-8", + ) + fake.chmod(0o755) + return fake + + +def _run_chain_check(tmp_path, **overrides): + query_log = tmp_path / "queries.log" + env = os.environ.copy() + env.update( + { + "COMPOSE_BIN": str(_fake_chain_compose(tmp_path)), + "CHAIN_BOUNDARIES": ( + "2026-01-01T00:00:00+00:00\\n" + "2026-01-02T00:00:00+00:00\\n" + ), + "CHAIN_QUERY_LOG": str(query_log), + } + ) + env.update(overrides) + result = subprocess.run( + ["bash", str(CHAIN_CHECK_SCRIPT)], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + check=False, + ) + return result, query_log.read_text(encoding="utf-8") + + +def test_boundary_chain_rejects_data_tail_after_manifest_end(tmp_path): + """Сообщения после последней границы означают незавершённый next-day.""" + result, queries = _run_chain_check(tmp_path, CHAIN_TAIL_ROWS="1") + + assert result.returncode != 0 + assert "хвост" in result.stderr + assert "FROM stg.browser_raw" in queries + assert ">= toDateTime64('2026-01-02 00:00:00.000000', 6, 'UTC')" in queries + + +@pytest.mark.parametrize( + ("actual_rows", "topic"), + [ + ("10\t11\t10\t10", "location_events"), + ("10\t10\t11\t10", "device_events"), + ("10\t10\t10\t11", "geo_events"), + ], +) +def test_boundary_chain_rejects_non_browser_topic_tail( + tmp_path, + actual_rows, + topic, +): + """Хвост отдельной Kafka-темы обнаруживается без browser-события.""" + result, queries = _run_chain_check( + tmp_path, + CHAIN_ACTUAL_TOPIC_ROWS=actual_rows, + ) + + assert result.returncode != 0 + assert "хвост" in result.stderr + assert topic in result.stderr + assert "FROM stg.location_raw" in queries + assert "FROM stg.device_raw" in queries + assert "FROM stg.geo_raw" in queries + + +def test_boundary_chain_reports_boundary_without_crossing_visits(tmp_path): + """Естественный нулевой стык проходит с явным статусом без однородности.""" + result, _ = _run_chain_check( + tmp_path, + CHAIN_BOUNDARIES=( + "2026-01-01T00:00:00+00:00\\n" + "2026-01-02T00:00:00+00:00\\n" + "2026-01-03T00:00:00+00:00\\n" + ), + CHAIN_PAIRING="0\t0\t0\t0", + CHAIN_CONTEXT="0\t0", + ) + + assert result.returncode == 0, result.stderr + assert ( + "boundary 1/3: crossing_visits=0 — однородность неприменима" + in result.stdout + ) + + +def test_boundary_chain_still_rejects_unpaired_rows_without_crossing_visits( + tmp_path, +): + """Нулевой стык не отключает обязательную проверку пар сообщений.""" + result, _ = _run_chain_check( + tmp_path, + CHAIN_BOUNDARIES=( + "2026-01-01T00:00:00+00:00\\n" + "2026-01-02T00:00:00+00:00\\n" + "2026-01-03T00:00:00+00:00\\n" + ), + CHAIN_PAIRING="0\t1\t0\t0", + CHAIN_CONTEXT="0\t0", + ) + + assert result.returncode != 0 + assert "непарные строки" in result.stderr + + +def test_boundary_chain_still_rejects_empty_segment(tmp_path): + """Отсутствие переходящих визитов не разрешает пустую порцию дня.""" + result, _ = _run_chain_check(tmp_path, CHAIN_SEGMENT_ROWS="0") + + assert result.returncode != 0 + assert "порция" in result.stderr + assert "пуста" in result.stderr + + +def test_boundary_chain_normalizes_offsets_and_uses_explicit_utc(tmp_path): + """Граница с +03:00 проверяет тот же UTC-интервал, а не местные часы.""" + result, queries = _run_chain_check( + tmp_path, + CHAIN_BOUNDARIES=( + "2026-01-01T03:00:00+03:00\\n" + "2026-01-02T03:00:00+03:00\\n" + "2026-01-03T03:00:00+03:00\\n" + ), + ) + + assert result.returncode == 0, result.stderr + assert "toDateTime64('2026-01-01 00:00:00.000000', 6, 'UTC')" in queries + assert "toDateTime64('2026-01-02 00:00:00.000000', 6, 'UTC')" in queries + assert "toDateTime64('2026-01-03 00:00:00.000000', 6, 'UTC')" in queries + literals = re.findall(r"toDateTime64\([^)]*\)", queries) + assert len(literals) == 7 + assert all(", 6, 'UTC')" in literal for literal in literals) + + +def test_boundary_chain_preserves_fractional_boundary_seconds(tmp_path): + """Приведение к UTC сохраняет микросекунды полуоткрытой границы.""" + result, queries = _run_chain_check( + tmp_path, + CHAIN_BOUNDARIES=( + "2026-01-01T00:00:00.500000+00:00\\n" + "2026-01-02T00:00:00.500000+00:00\\n" + ), + ) + + assert result.returncode == 0, result.stderr + assert "toDateTime64('2026-01-01 00:00:00.500000', 6, 'UTC')" in queries + assert "toDateTime64('2026-01-02 00:00:00.500000', 6, 'UTC')" in queries diff --git a/tests/test_superset_dashboard_config.py b/tests/test_superset_dashboard_config.py new file mode 100644 index 0000000..e39fe43 --- /dev/null +++ b/tests/test_superset_dashboard_config.py @@ -0,0 +1,217 @@ +import importlib.util +import json +from pathlib import Path + + +def load_dashboard_module(): + module_path = Path(__file__).resolve().parents[1] / "superset" / "create_dashboard.py" + spec = importlib.util.spec_from_file_location("create_dashboard", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def chart_config(module, slice_name): + return next(chart for chart in module.CHARTS_CONFIG if chart["slice_name"] == slice_name) + + +def exported_chart(exported_dashboard, slice_name): + return next( + chart["__Slice__"] + for chart in exported_dashboard["charts"] + if chart["__Slice__"]["slice_name"] == slice_name + ) + + +def load_exported_dashboard(): + export_path = Path(__file__).resolve().parents[1] / "superset" / "dashboards" / "ecommerce_analytics.zip.json" + return json.loads(export_path.read_text()) + + +def expected_export_params(chart, actual_params): + return { + **chart["params"], + "datasource": actual_params["datasource"], + "viz_type": chart["viz_type"], + } + + +class FakeDashboard: + def __init__(self, slug): + self.slug = slug + + +class FakeChart: + def __init__(self, chart_id, slice_name, datasource_id=1, dashboard_slugs=None): + self.id = chart_id + self.slice_name = slice_name + self.datasource_id = datasource_id + self.dashboards = [ + FakeDashboard(slug) + for slug in (dashboard_slugs or ["ecommerce-analytics"]) + ] + + +def test_geo_chart_uses_readable_top_countries_bar_config(): + module = load_dashboard_module() + + geo_chart = chart_config(module, "🌍 Top Countries by Events") + + assert geo_chart["viz_type"] == "echarts_timeseries_bar" + assert geo_chart["previous_slice_names"] == ["🌍 Geography Map"] + + params = geo_chart["params"] + assert params["x_axis"] == "geo_country" + assert params["metrics"] == [ + { + "expressionType": "SQL", + "sqlExpression": "COUNT(*)", + "label": "Events, pcs", + } + ] + assert params["row_limit"] == 15 + assert params["order_desc"] is True + assert params["sort_series_type"] == "sum" + assert params["show_legend"] is True + assert params["rich_tooltip"] is True + assert params["y_axis_title"] == "Events, pcs" + assert params["x_axis_title"] == "Country" + assert params["y_axis_format"] == ",d" + + +def test_exported_dashboard_uses_same_readable_geo_chart(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + expected_chart = chart_config(module, "🌍 Top Countries by Events") + geo_chart = exported_chart(exported_dashboard, "🌍 Top Countries by Events") + + assert geo_chart["viz_type"] == "echarts_timeseries_bar" + params = json.loads(geo_chart["params"]) + assert params == expected_export_params(expected_chart, params) + assert params["viz_type"] == "echarts_timeseries_bar" + assert params["x_axis"] == "geo_country" + assert params["metrics"][0]["label"] == "Events, pcs" + assert params["x_axis_sort"] == "Events, pcs" + assert params["x_axis_sort_asc"] is False + assert params["show_legend"] is True + assert params["rich_tooltip"] is True + assert params["y_axis_title"] == "Events, pcs" + + position_json = json.loads(exported_dashboard["dashboards"][0]["__Dashboard__"]["position_json"]) + assert position_json["CHART-7"]["meta"]["sliceName"] == "🌍 Top Countries by Events" + + +def test_exported_dashboard_chart_params_match_current_config(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + expected_charts = { + chart["slice_name"]: chart + for chart in module.CHARTS_CONFIG + } + + for exported in exported_dashboard["charts"]: + actual_chart = exported["__Slice__"] + actual_params = json.loads(actual_chart["params"]) + expected_chart = expected_charts[actual_chart["slice_name"]] + + assert actual_chart["viz_type"] == expected_chart["viz_type"] + assert actual_params == expected_export_params(expected_chart, actual_params) + + +def test_exported_dashboard_chart_names_match_current_config(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + expected_names = [chart["slice_name"] for chart in module.CHARTS_CONFIG] + actual_names = [chart["__Slice__"]["slice_name"] for chart in exported_dashboard["charts"]] + + assert actual_names == expected_names + + +def test_exported_dashboard_layout_matches_current_rows(): + module = load_dashboard_module() + exported_dashboard = load_exported_dashboard() + + position_json = json.loads(exported_dashboard["dashboards"][0]["__Dashboard__"]["position_json"]) + chart_titles_by_id = { + component_id: component["meta"]["sliceName"] + for component_id, component in position_json.items() + if isinstance(component, dict) and component.get("type") == "CHART" + } + + actual_rows = [] + for row_id in position_json["GRID_ID"]["children"]: + row = [] + for chart_component_id in position_json[row_id]["children"]: + chart_component = position_json[chart_component_id] + row.append( + ( + chart_titles_by_id[chart_component_id], + chart_component["meta"]["width"], + ) + ) + actual_rows.append(row) + + assert actual_rows == module.DASHBOARD_ROWS + + +def test_choose_chart_to_sync_prefers_current_name_and_marks_old_name_duplicate(): + module = load_dashboard_module() + old_chart = FakeChart(7, "🌍 Geography Map", datasource_id=42) + current_chart = FakeChart(11, "🌍 Top Countries by Events", datasource_id=42) + + selected, duplicates = module.choose_chart_to_sync( + [old_chart, current_chart], + "🌍 Top Countries by Events", + dataset_id=42, + ) + + assert selected is current_chart + assert duplicates == [old_chart] + + +def test_choose_chart_to_sync_ignores_same_name_from_other_dataset(): + module = load_dashboard_module() + other_dataset_chart = FakeChart(7, "🌍 Top Countries by Events", datasource_id=7) + target_dataset_chart = FakeChart(11, "🌍 Geography Map", datasource_id=42) + + selected, duplicates = module.choose_chart_to_sync( + [other_dataset_chart, target_dataset_chart], + "🌍 Top Countries by Events", + dataset_id=42, + ) + + assert selected is target_dataset_chart + assert duplicates == [] + + +def test_dashboard_sync_ignores_same_name_same_dataset_from_other_dashboard(): + module = load_dashboard_module() + other_dashboard_chart = FakeChart( + 7, + "🌍 Top Countries by Events", + datasource_id=42, + dashboard_slugs=["mentee-dashboard"], + ) + target_dashboard_chart = FakeChart( + 11, + "🌍 Geography Map", + datasource_id=42, + dashboard_slugs=["ecommerce-analytics"], + ) + + owned_charts = module.dashboard_owned_charts( + [other_dashboard_chart, target_dashboard_chart], + dashboard_slug="ecommerce-analytics", + ) + selected, duplicates = module.choose_chart_to_sync( + owned_charts, + "🌍 Top Countries by Events", + dataset_id=42, + ) + + assert owned_charts == [target_dashboard_chart] + assert selected is target_dashboard_chart + assert duplicates == []