Merge pull request #19 from dementev-dev/feature/mentee-path
Редизайн пути менти: мир из артефакта и две ветки роста
This commit is contained in:
88 files changed
+1667
-5220
No files matched your search
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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` оставлен тонким
|
|
||||||
фасадом и точкой входа для совместимости.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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` снова трактуется
|
|
||||||
как бюджет событий, а не как число рождений визитов.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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 теперь создаются через единый
|
|
||||||
ГПСЧ генератора, а кулдаун пользователя начинается от запланированного времени
|
|
||||||
последнего события визита, а не от времени позднего тика.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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` — без замечаний.
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Модельное время и стартовая история генератора
|
|
||||||
|
|
||||||
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, если учебные материалы требуют нетривиальной переделки.
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
# Журнал 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`.
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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`
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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`
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Восстановление state v2 от модельной точки
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Привести восстановление state v2 к модельному времени. Один и тот же слепок
|
|
||||||
должен уметь восстанавливаться в двух разных случаях: после сбоя, где время
|
|
||||||
действительно прошло, и при старте из стартовой истории, где продолжение идёт
|
|
||||||
от `T_end` без искусственного разрыва.
|
|
||||||
|
|
||||||
Срез должен доказать поведение не только локальными тестами состояния, но и
|
|
||||||
данными в ClickHouse: активные визиты продолжаются или закрываются по правилам
|
|
||||||
модельного времени.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] State сохраняет достаточно данных, чтобы после сбоя вычислить модельную
|
|
||||||
точку возобновления при ×K.
|
|
||||||
- [x] Восстановление после короткого модельного сбоя продолжает активные визиты
|
|
||||||
и досылает созревшие события с исходными модельными метками.
|
|
||||||
- [x] Восстановление после долгого модельного сбоя закрывает сильно просроченные
|
|
||||||
активные визиты без досылки остатка.
|
|
||||||
- [x] Восстановление из стартовой истории использует `T_end` как точку
|
|
||||||
возобновления и не обрывает активные визиты из-за настенного простоя.
|
|
||||||
- [x] Визит, переживший восстановление, остаётся однородным: события до и после
|
|
||||||
восстановления не меняют контекст визита и не создают второй путь генерации
|
|
||||||
внутри одного `click_id`.
|
|
||||||
- [x] Повреждённый или несовместимый state не валит сервис: генератор стартует
|
|
||||||
с чистого листа и пишет предупреждение.
|
|
||||||
- [x] ClickHouse-проверка подтверждает, что на стыке восстановления нет дублей
|
|
||||||
событий и нет разрыва `click_id` внутри продолжающегося визита.
|
|
||||||
- [x] После реализации выполнено саморевью worker-а и отдельное reviewer-ревью,
|
|
||||||
потому что задача меняет state/serialization и сервисное восстановление.
|
|
||||||
- [x] Если исправление после ревью меняет формат state или способ восстановления,
|
|
||||||
выполнен повторный reviewer-круг.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md`
|
|
||||||
- Review gate из `PRD.md`: сквозной инвариант времени после задачи 3
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
- State v2 теперь хранит `model_timestamp`, `wall_timestamp`,
|
|
||||||
`model_time_speed`, `model_timezone`, `model_t0` и `gen_seed`.
|
|
||||||
- Старый v2 без этих полей считается несовместимым: `from_dict_safe` возвращает
|
|
||||||
`None`, сервис пишет предупреждение и стартует с чистого состояния.
|
|
||||||
- Live-восстановление считает точку продолжения по формуле
|
|
||||||
`model_timestamp + max(0, wall_now_utc - wall_timestamp) * model_time_speed`.
|
|
||||||
- Перед live-восстановлением сервис сверяет state с текущим `Config` по
|
|
||||||
`gen_seed`, `model_t0`, `model_timezone` и `model_time_speed`. Несовпадение
|
|
||||||
считается несовместимым state: сервис пишет warning и стартует с чистого
|
|
||||||
состояния.
|
|
||||||
- Восстановление из стартовой истории вынесено в минимальный метод
|
|
||||||
`GeneratorService.restore_from_startup_history(state, model_t_end)`: он берёт
|
|
||||||
переданный `T_end` напрямую и не применяет wall-дельту.
|
|
||||||
- Сохранение state после успешного тика пишет модельную точку следующего тика,
|
|
||||||
а не настенное время. Это убирает повторный старт с уже обработанной модельной
|
|
||||||
точки.
|
|
||||||
|
|
||||||
## Проверки
|
|
||||||
|
|
||||||
- Точечный прогон после проверки reviewer-а:
|
|
||||||
`docker run --rm -v ... generator:test pytest tests/test_state.py::TestGeneratorState tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_state_on_valid tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_none_on_invalid_gen_seed tests/test_service.py::TestGeneratorServiceStateV2 -v`
|
|
||||||
— 15 passed.
|
|
||||||
- `make generator-test` — 126 passed.
|
|
||||||
- `uv run pytest ...` и `uv run python -m pytest ...` не запускались до тестов:
|
|
||||||
в текущем uv-окружении нет `pytest`.
|
|
||||||
- `make lint` недоступен: в `Makefile` нет такой цели.
|
|
||||||
|
|
||||||
## ClickHouse-проверка
|
|
||||||
|
|
||||||
Выполнены два чистых стендовых сценария. Оба начинались со сброса ClickHouse,
|
|
||||||
Kafka и state генератора.
|
|
||||||
|
|
||||||
1. Короткое восстановление, `GEN_MODEL_TIME_SPEED=1`.
|
|
||||||
Первый запуск с `GEN_STATE_RESET=true` записал один тик, второй запуск с
|
|
||||||
`GEN_STATE_RESET=false` восстановился из state и продолжил активные визиты.
|
|
||||||
Проверка ClickHouse:
|
|
||||||
- `dds.event`: `rows=332`, `uniq_events=332`, `duplicate_events=0`.
|
|
||||||
- `continued_from_first_tick=67` для визитов, начатых на
|
|
||||||
`2026-01-01 10:00:00`.
|
|
||||||
- Для этих `click_id` нет смены `user_domain_id`, `device_type`, `os_name`,
|
|
||||||
`geo_country`, `geo_region_name` и `geo_timezone`.
|
|
||||||
2. Долгое восстановление, `GEN_MODEL_TIME_SPEED=3600`.
|
|
||||||
Первый запуск с `GEN_STATE_RESET=true` записал один тик на
|
|
||||||
`2026-01-01 10:00:00`, второй запуск с `GEN_STATE_RESET=false`
|
|
||||||
восстановился на модельной точке `2026-01-03T22:51:51.552000+00:00`.
|
|
||||||
Проверка ClickHouse:
|
|
||||||
- `dds.event`: `rows=200`, `uniq_events=200`, `duplicate_events=0`.
|
|
||||||
- диапазон `event_ts`: от `2026-01-01 10:00:00.000000` до
|
|
||||||
`2026-01-03 22:51:51.552000`.
|
|
||||||
- `continued_old_clicks=0`: старые `click_id` с первого тика не продолжились
|
|
||||||
после долгой модельной паузы.
|
|
||||||
|
|
||||||
## Риски
|
|
||||||
|
|
||||||
- Старые записи state v2 без модельной связки больше не восстанавливаются. Это
|
|
||||||
намеренно: без этих полей нельзя корректно посчитать точку продолжения.
|
|
||||||
- Полноценный манифест и backfill не реализованы: добавлен только минимальный
|
|
||||||
интерфейс восстановления от переданного `T_end`.
|
|
||||||
- `restore_from_startup_history` пока принимает state и `T_end` напрямую, без
|
|
||||||
проверки manifest. Это риск issue 05, где появится полноценная стартовая
|
|
||||||
история и запрет смешивания артефактов.
|
|
||||||
- `GeneratorState.__post_init__` оставляет in-memory совместимость для прямого
|
|
||||||
создания state в тестах и коде. JSON/Kafka путь остаётся строгим: отсутствие
|
|
||||||
новых полей не маскируется и ведёт к fresh start.
|
|
||||||
- После reviewer-fix формат state снова изменился (`gen_seed`). Повторный
|
|
||||||
reviewer-круг выполнен, новых блокирующих находок нет.
|
|
||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Стартовая история до ClickHouse
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Сделать промотку прошлого: генератор быстро проходит от `T0` до `T_end`, создаёт
|
|
||||||
события за этот отрезок, сохраняет слепок состояния и манифест стартовой
|
|
||||||
истории. Эту историю нужно загрузить в ClickHouse и доказать, что живой поток
|
|
||||||
может продолжить её с `T_end`.
|
|
||||||
|
|
||||||
Это главный срез стартовой истории. Он должен проверять не только факт наличия
|
|
||||||
данных, но и форму данных: пирамиду, возвраты, длину визита, воронку и стык
|
|
||||||
между прошлым и живым продолжением.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Промотка прошлого создаёт события за `[T0, T_end)`, слепок состояния и
|
|
||||||
манифест с контрольными данными.
|
|
||||||
- [x] При одинаковых `GEN_SEED`, `T0`, `T_end` и настройках артефакт промотки
|
|
||||||
прошлого повторяем точно; проверки в ClickHouse следуют правилам допуска из
|
|
||||||
контракта задачи 1.
|
|
||||||
- [x] История загружается в ClickHouse штатной или явно описанной командой.
|
|
||||||
- [x] SQL-проверка показывает здоровую пирамиду: пользователей меньше, чем
|
|
||||||
визитов, визитов меньше, чем событий.
|
|
||||||
- [x] SQL-проверка показывает возвраты: у части пользователей больше одного
|
|
||||||
визита.
|
|
||||||
- [x] SQL-проверка длины визита проверяет форму, а не только среднее: долю
|
|
||||||
коротких визитов, медиану и долю срезов о потолок.
|
|
||||||
- [x] SQL-проверка воронки `/home -> товары -> /cart -> /payment ->
|
|
||||||
/confirmation` монотонно убывает, а доля дошедших до `/confirmation` в
|
|
||||||
согласованном коридоре.
|
|
||||||
- [x] Живое продолжение после `T_end` не создаёт дублей на границе и не выглядит
|
|
||||||
как независимый второй мир.
|
|
||||||
- [x] Визиты, которые переходят через `T_end`, остаются однородными: контекст
|
|
||||||
визита не меняется на стыке стартовой истории и живого продолжения.
|
|
||||||
- [x] Подготовлены данные, команды и SQL-проверки, достаточные для внешнего
|
|
||||||
review gate по распределениям и двум путям генерации из `PRD.md`.
|
|
||||||
- [x] Worker даёт промежуточный статус, если промотка, загрузка в ClickHouse или
|
|
||||||
распределительные проверки занимают заметное время.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md`
|
|
||||||
|
|
||||||
## Фактический прогон worker-а
|
|
||||||
|
|
||||||
Дата проверки: 2026-06-14.
|
|
||||||
|
|
||||||
Настройки стенда:
|
|
||||||
|
|
||||||
- `GEN_SEED=4242`
|
|
||||||
- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`
|
|
||||||
- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`
|
|
||||||
- `GEN_MODEL_TIMEZONE=UTC`
|
|
||||||
- `GEN_MODEL_TIME_SPEED=1`
|
|
||||||
- `GEN_TICK_SECONDS=60`
|
|
||||||
- `GEN_LAMBDA_BASE_PER_MIN=120`
|
|
||||||
- `GEN_JITTER_PCT=0`
|
|
||||||
- `GEN_MIN_EVENTS_PER_TICK=1`
|
|
||||||
- `GEN_MAX_EVENTS_PER_TICK=1000`
|
|
||||||
|
|
||||||
Команды проверки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make clean
|
|
||||||
docker compose up -d clickhouse kafka
|
|
||||||
make ddl
|
|
||||||
docker compose build generator
|
|
||||||
|
|
||||||
docker compose run --rm --no-deps \
|
|
||||||
-e GEN_RUN_MODE=backfill \
|
|
||||||
-e GEN_STATE_RESET=true \
|
|
||||||
-e GEN_SEED=4242 \
|
|
||||||
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
|
|
||||||
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
|
|
||||||
-e GEN_MODEL_TIMEZONE=UTC \
|
|
||||||
-e GEN_MODEL_TIME_SPEED=1 \
|
|
||||||
-e GEN_TICK_SECONDS=60 \
|
|
||||||
-e GEN_LAMBDA_BASE_PER_MIN=120 \
|
|
||||||
-e GEN_JITTER_PCT=0 \
|
|
||||||
-e GEN_MIN_EVENTS_PER_TICK=1 \
|
|
||||||
-e GEN_MAX_EVENTS_PER_TICK=1000 \
|
|
||||||
generator
|
|
||||||
|
|
||||||
sleep 10
|
|
||||||
bash scripts/run_batch.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Повторяемость проверена двумя чистыми прогонами с полным сбросом
|
|
||||||
ClickHouse/Kafka/state через `make clean`. В обоих прогонах manifest и ClickHouse
|
|
||||||
дали одинаковые контрольные числа:
|
|
||||||
|
|
||||||
- manifest `browser_events.checksum_sha256`:
|
|
||||||
`3b1946bd7fd3d669e2461fcbae88d8aed6980bb5f126779fb4e7480ce15a3269`
|
|
||||||
- manifest `location_events.checksum_sha256`:
|
|
||||||
`370816c8d0b0311592282b9b75372e18862db9ef4984f7032885e935f9436522`
|
|
||||||
- manifest `device_events.checksum_sha256`:
|
|
||||||
`37e157ac7d5a3203c1c27b2a11abc1b9cb9fc7f74f79c77645faa09412e08b33`
|
|
||||||
- manifest `geo_events.checksum_sha256`:
|
|
||||||
`ec0e36e4613e05b9a5e72490c7565cf7e5f767d39a7a58b383e56e2eb191fc20`
|
|
||||||
- ClickHouse digest:
|
|
||||||
`CC1A73E65E897D1F1FC982CFA4237A07`
|
|
||||||
|
|
||||||
Backfill в ClickHouse:
|
|
||||||
|
|
||||||
- `events=31825`
|
|
||||||
- `unique_events=31825`
|
|
||||||
- `visits=3020`
|
|
||||||
- `users=1048`
|
|
||||||
- `min_event_ts=2026-01-01 00:00:00.000000`
|
|
||||||
- `max_event_ts=2026-01-01 05:59:59.521599`
|
|
||||||
- `pyramid_ok=1`
|
|
||||||
- `half_open_ok=1`
|
|
||||||
|
|
||||||
Проверка возвратов:
|
|
||||||
|
|
||||||
- `users=1048`
|
|
||||||
- `returning_users=708`
|
|
||||||
- `returning_share=0.6755725190839694`
|
|
||||||
- `max_visits_per_user=11`
|
|
||||||
|
|
||||||
Форма длины визита:
|
|
||||||
|
|
||||||
- `visits=3020`
|
|
||||||
- `short_visit_share=0.16490066225165562`
|
|
||||||
- `median_events_per_visit=8`
|
|
||||||
- `avg_events_per_visit=10.538079470198676`
|
|
||||||
- `capped_visit_share=0.0619205298013245`
|
|
||||||
- `median_duration_sec=202`
|
|
||||||
- `p95_duration_sec=823`
|
|
||||||
- `max_events_per_visit=30`
|
|
||||||
|
|
||||||
Воронка:
|
|
||||||
|
|
||||||
- строгая упорядоченная:
|
|
||||||
`home=2743`, `products=1626`, `cart=995`, `payment=673`,
|
|
||||||
`confirmation=394`, `monotonic_ok=1`,
|
|
||||||
`ordered_confirmation_share=0.14363835216915785`;
|
|
||||||
- калибровочная по наличию страницы в визите:
|
|
||||||
`home=2743`, `products=2706`, `cart=2003`, `payment=1368`,
|
|
||||||
`confirmation=824`, `monotonic_ok=1`,
|
|
||||||
`confirmation_share_all_visits=0.2728476821192053`,
|
|
||||||
`confirmation_share_from_home=0.3004010207801677`.
|
|
||||||
|
|
||||||
Live-продолжение:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose run -d --name issue05-live --no-deps \
|
|
||||||
-e GEN_RUN_MODE=live \
|
|
||||||
-e GEN_STATE_RESET=false \
|
|
||||||
-e GEN_SEED=4242 \
|
|
||||||
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
|
|
||||||
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
|
|
||||||
-e GEN_MODEL_TIMEZONE=UTC \
|
|
||||||
-e GEN_MODEL_TIME_SPEED=1 \
|
|
||||||
-e GEN_TICK_SECONDS=60 \
|
|
||||||
-e GEN_LAMBDA_BASE_PER_MIN=120 \
|
|
||||||
-e GEN_JITTER_PCT=0 \
|
|
||||||
-e GEN_MIN_EVENTS_PER_TICK=1 \
|
|
||||||
-e GEN_MAX_EVENTS_PER_TICK=1000 \
|
|
||||||
generator
|
|
||||||
```
|
|
||||||
|
|
||||||
Лог live-запуска подтвердил восстановление из стартовой истории:
|
|
||||||
`model_time=2026-01-01T06:00:00+00:00`, затем прошли тики `361`-`364`.
|
|
||||||
|
|
||||||
После live и повторного `bash scripts/run_batch.sh`:
|
|
||||||
|
|
||||||
- `users=1079`
|
|
||||||
- `visits=3069`
|
|
||||||
- `events=32145`
|
|
||||||
- `min_event_ts=2026-01-01 00:00:00.000000`
|
|
||||||
- `max_event_ts=2026-01-01 06:03:00.000000`
|
|
||||||
- `history_events=31825`
|
|
||||||
- `live_events=320`
|
|
||||||
- `duplicate_events=0`
|
|
||||||
- `boundary_events=13`
|
|
||||||
|
|
||||||
`boundary_events=13` — это live-события ровно на `T_end`; они не входили в
|
|
||||||
backfill `[T0, T_end)`.
|
|
||||||
|
|
||||||
Визиты через `T_end`:
|
|
||||||
|
|
||||||
- `crossing_visits=36`
|
|
||||||
- `visits_with_both_sides=36`
|
|
||||||
- `homogeneous_visits=36`
|
|
||||||
- `context_ok=1`
|
|
||||||
- `min_before_events=2`
|
|
||||||
- `min_after_events=1`
|
|
||||||
- `max_after_events=10`
|
|
||||||
|
|
||||||
## Риски для review gate
|
|
||||||
|
|
||||||
- Manifest, state и события связаны метаданными (`GEN_SEED`, `T0`, `T_end`,
|
|
||||||
настройки генерации, `last_batch_id`) и checksum событий по топикам. Полной
|
|
||||||
криптографической связки `state + events + manifest` пока нет.
|
|
||||||
- Для воронки есть две SQL-формы. Строгая ordered-форма проверяет порядок
|
|
||||||
`/home -> товары -> /cart -> /payment -> /confirmation`. Калибровочная
|
|
||||||
contains-форма проверяет, была ли страница в визите. Для review gate
|
|
||||||
`confirmation_share` сравнивается с коридором мат-модели по contains-форме.
|
|
||||||
|
|
||||||
## Review gate
|
|
||||||
|
|
||||||
- Саморевью worker-а нашло блокирующий риск: backfill не должен сохранять
|
|
||||||
state/manifest при частичной публикации. Исправлено fail-fast поведением и
|
|
||||||
тестом.
|
|
||||||
- Первый reviewer gate нашёл два блокера: startup-history state мог остаться без
|
|
||||||
manifest и восстановиться как live-state, а startup-history restore не сверял
|
|
||||||
поля самого state. Оба пункта исправлены до коммита.
|
|
||||||
- Повторный reviewer gate: `gate pass`, новых блокирующих находок нет.
|
|
||||||
- Проверки после исправлений: `make generator-test` — 134 passed.
|
|
||||||
- Остаточный риск процесса: reviewer был со свежим контекстом, но той же
|
|
||||||
родословной, а не внешней моделью.
|
|
||||||
-227
@@ -1,227 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Стартовая история как источник аналитики
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Перевести штатный путь стенда на стартовую историю как источник аналитики.
|
|
||||||
Чистый стенд должен получать данные из генерации: стартовая история загружается,
|
|
||||||
STG→ODS→DDS→DM строится на ней, а Superset работает с этими витринами. Архивный
|
|
||||||
статический сид остаётся только временной кладовкой фактуры для генератора, а не
|
|
||||||
источником аналитического контура.
|
|
||||||
|
|
||||||
Срез не требует финальной ручной оценки красоты дашбордов, но должен дать
|
|
||||||
техническое доказательство: витрины и датасеты не пустые, контрольные числа
|
|
||||||
берутся из генерации.
|
|
||||||
|
|
||||||
Границы среза: штатный путь стенда и документы запуска. Переписывание уроков,
|
|
||||||
новые панели и улучшение формы дашбордов остаются follow-up, если по ходу не
|
|
||||||
окажутся маленькой обязательной правкой для запуска.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Штатная команда запуска чистого стенда создаёт или загружает стартовую
|
|
||||||
историю и прогоняет её до DM-витрин.
|
|
||||||
- [x] `kafka_load_dag` или заменяющий его путь больше не использует архивный
|
|
||||||
`data/*.jsonl` как источник аналитики.
|
|
||||||
- [x] ClickHouse-проверки из задачи 5 доступны как повторяемая команда для
|
|
||||||
координатора или CI.
|
|
||||||
- [x] Основные DM-витрины, на которых стоят дашборды, непустые и показывают
|
|
||||||
данные генерации.
|
|
||||||
- [x] Superset-датасеты и дашборды технически открываются на данных генерации;
|
|
||||||
ручная оценка формы графиков остаётся финальной HITL-приёмкой.
|
|
||||||
- [x] `README.md`, `docs/OPERATIONS.md` и `generator/README.md` больше не
|
|
||||||
описывают архивный сид как основной источник аналитики.
|
|
||||||
- [x] Если уроки или дашборды требуют нетривиальной переделки, создан follow-up
|
|
||||||
issue вместо расширения этой задачи.
|
|
||||||
- [x] Если учебные материалы ещё описывают архивный сид как основной источник,
|
|
||||||
создан отдельный follow-up на миграцию уроков по ADR-0006.
|
|
||||||
- [x] Описан повторный чистый прогон: какие данные очищаются и какие команды
|
|
||||||
выполняются, чтобы координатор мог надёжно перепроверить результат.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
|
|
||||||
- Review gate из `PRD.md`: распределения и два пути генерации после задачи 5
|
|
||||||
|
|
||||||
## Фактический прогон worker-а
|
|
||||||
|
|
||||||
Дата проверки: 2026-06-14.
|
|
||||||
|
|
||||||
Штатная команда:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make generated-history-analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
Команда выполняет чистый прогон: `docker compose down -v --remove-orphans`,
|
|
||||||
запуск ClickHouse и Kafka, DDL, `GEN_RUN_MODE=backfill`, batch
|
|
||||||
STG -> ODS -> DDS -> DM, инициализацию Superset и итоговую проверку
|
|
||||||
`make generated-history-check`.
|
|
||||||
|
|
||||||
Быстрая команда issue 06 проверяет путь backfill -> DM -> Superset. Стык
|
|
||||||
backfill/live, отсутствие дублей на границе и однородность визитов через
|
|
||||||
`T_end` проверены в issue 05 и не входят в быстрый штатный check issue 06.
|
|
||||||
|
|
||||||
Быстрый проверочный профиль по умолчанию:
|
|
||||||
|
|
||||||
- `GEN_SEED=4242`
|
|
||||||
- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`
|
|
||||||
- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`
|
|
||||||
- `GEN_MODEL_TIMEZONE=UTC`
|
|
||||||
- `GEN_MODEL_TIME_SPEED=1`
|
|
||||||
- `GEN_TICK_SECONDS=60`
|
|
||||||
- `GEN_LAMBDA_BASE_PER_MIN=60`
|
|
||||||
- `GEN_JITTER_PCT=0`
|
|
||||||
- `GEN_MIN_EVENTS_PER_TICK=1`
|
|
||||||
- `GEN_MAX_EVENTS_PER_TICK=1000`
|
|
||||||
|
|
||||||
Суточный профиль остаётся доступен явно:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
Backfill завершился:
|
|
||||||
|
|
||||||
- `events=16054`
|
|
||||||
- `visits=1516`
|
|
||||||
- `users=445`
|
|
||||||
|
|
||||||
Batch STG -> ODS -> DDS -> DM:
|
|
||||||
|
|
||||||
- STG, суммарно по 4 потокам: `64216`
|
|
||||||
- `ods.browser_event=16054`
|
|
||||||
- `ods.location_event=16054`
|
|
||||||
- `ods.device_by_click=1516`
|
|
||||||
- `ods.geo_by_click=1516`
|
|
||||||
- `dds.event=16054`
|
|
||||||
- `dds.click=1516`
|
|
||||||
- `dds.event_without_click=0`
|
|
||||||
- `dm.v_events_enriched` в `dm.dq_summary`: `16054`
|
|
||||||
|
|
||||||
Повторная команда проверки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make generated-history-check
|
|
||||||
```
|
|
||||||
|
|
||||||
Результат ClickHouse:
|
|
||||||
|
|
||||||
- `events=16054`
|
|
||||||
- `visits=1516`
|
|
||||||
- `users=445`
|
|
||||||
- `min_event_ts=2026-01-01 00:00:00.000000`
|
|
||||||
- `max_event_ts=2026-01-01 05:59:59.353407`
|
|
||||||
- `digest=B7E183DD1835E6593CC4B33C7F5B2817`
|
|
||||||
|
|
||||||
Возвраты:
|
|
||||||
|
|
||||||
- `users=445`
|
|
||||||
- `returning_users=351`
|
|
||||||
- `returning_share=0.7887640449438202`
|
|
||||||
- `max_visits_per_user=8`
|
|
||||||
|
|
||||||
Форма длины визита:
|
|
||||||
|
|
||||||
- `visits=1516`
|
|
||||||
- `short_visit_share=0.17678100263852242`
|
|
||||||
- `median_events_per_visit=8`
|
|
||||||
- `avg_events_per_visit=10.589709762532982`
|
|
||||||
- `capped_visit_share=0.06398416886543536`
|
|
||||||
- `median_duration_sec=204`
|
|
||||||
- `p95_duration_sec=835`
|
|
||||||
- `max_events_per_visit=30`
|
|
||||||
|
|
||||||
Ordered funnel:
|
|
||||||
|
|
||||||
- `home=1387`
|
|
||||||
- `products=849`
|
|
||||||
- `cart=511`
|
|
||||||
- `payment=332`
|
|
||||||
- `confirmation=182`
|
|
||||||
- `monotonic_ok=1`
|
|
||||||
- `confirmation_share=0.13121845710165825`
|
|
||||||
|
|
||||||
Contains funnel:
|
|
||||||
|
|
||||||
- `home=1387`
|
|
||||||
- `products=1254`
|
|
||||||
- `cart=880`
|
|
||||||
- `payment=568`
|
|
||||||
- `confirmation=324`
|
|
||||||
- `monotonic_ok=1`
|
|
||||||
- `confirmation_share=0.2335976928622927`
|
|
||||||
|
|
||||||
Основные DM-витрины:
|
|
||||||
|
|
||||||
- `dm.v_events_enriched=16054`
|
|
||||||
- `dm.v_daily_traffic=797`
|
|
||||||
- `dm.v_top_pages_daily=6`
|
|
||||||
- `dm.v_utm_effectiveness=33`
|
|
||||||
- `dm.v_session_overview=1516`
|
|
||||||
- `dm.dq_summary=20`
|
|
||||||
|
|
||||||
Superset technical check:
|
|
||||||
|
|
||||||
- `superset_datasets=6`
|
|
||||||
- `superset_dashboards=1`
|
|
||||||
- `superset_dashboard_charts=10`
|
|
||||||
- `dashboard_url=http://localhost:8088/superset/dashboard/ecommerce-analytics/`
|
|
||||||
- `login_api_status=200`
|
|
||||||
- `dashboard_api_status=200`
|
|
||||||
- `dashboard_title=🛒 E-commerce Analytics Dashboard`
|
|
||||||
- зарегистрированная Superset database `clickhouse_dwh` читает
|
|
||||||
`dm.v_events_enriched` через SQLAlchemy engine:
|
|
||||||
`superset_clickhouse_events=16054`.
|
|
||||||
|
|
||||||
Проверки:
|
|
||||||
|
|
||||||
- `bash -n scripts/run_generated_history_analytics.sh` — ok.
|
|
||||||
- `bash -n scripts/check_generated_analytics.sh` — ok.
|
|
||||||
- `make generated-history-analytics` — ok.
|
|
||||||
- `make generated-history-check` — ok.
|
|
||||||
- `make generator-test` — 134 passed.
|
|
||||||
|
|
||||||
Документы запуска обновлены:
|
|
||||||
|
|
||||||
- `README.md`
|
|
||||||
- `docs/OPERATIONS.md`
|
|
||||||
- `generator/README.md`
|
|
||||||
- `docs/REPO_MAP.md`
|
|
||||||
- `docs/SUPERSET_DASHBOARD.md`
|
|
||||||
|
|
||||||
Follow-up:
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md`
|
|
||||||
— миграция учебных материалов с архивного сида на генерацию.
|
|
||||||
|
|
||||||
## Риски и что не проверено
|
|
||||||
|
|
||||||
- Ручная оценка формы dashboard глазами не выполнялась: это финальная HITL-приёмка.
|
|
||||||
- Суточный backfill не прогонялся до конца в этом срезе: он доступен через env,
|
|
||||||
но для координатора выбран быстрый 6-часовой профиль.
|
|
||||||
- `kafka_load_dag.py` физически оставлен как архивный ручной путь; штатные
|
|
||||||
документы больше не ведут через него как основной источник аналитики.
|
|
||||||
- `scripts/check_generated_analytics.sh` рассчитан на штатный UTC-профиль
|
|
||||||
(`GEN_MODEL_T0/T_END` с `+00:00`). Для произвольного timezone offset в этих
|
|
||||||
переменных нужна отдельная нормализация границ.
|
|
||||||
- Проверка `no_2022_rows` доказывает отсутствие архивного сида внутри быстрого
|
|
||||||
чистого модельного диапазона. Она не доказывает отсутствие старых строк на
|
|
||||||
грязном стенде вне этого диапазона; штатная команда закрывает это через
|
|
||||||
`docker compose down -v`.
|
|
||||||
|
|
||||||
## Review gate
|
|
||||||
|
|
||||||
- Саморевью worker-а нашло слабые доказательства по проверкам issue 05 и Superset:
|
|
||||||
добавлены возвраты, форма длины визита, ordered/contains funnel, Superset login
|
|
||||||
API, dashboard API и чтение ClickHouse через Superset database.
|
|
||||||
- Независимый reviewer gate: `gate pass`, блокирующих находок нет.
|
|
||||||
- Minor-находка reviewer-а по старой подсказке `make data` в `scripts/run_batch.sh`
|
|
||||||
исправлена до коммита.
|
|
||||||
- Проверки после исправлений: `make generated-history-check` — ok,
|
|
||||||
`bash -n scripts/*.sh` — ok.
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Портативный артефакт стартовой истории и runbook по стенду
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Сейчас стартовая история персистится только в Kafka compact-топиках
|
|
||||||
(`generator_state`, `generator_startup_history_manifest`) и в ClickHouse. Чистая
|
|
||||||
пересборка стенда (`make generated-history-analytics` делает `down -v`) каждый раз
|
|
||||||
**заново генерирует** backfill. Портативного файла-артефакта, который можно
|
|
||||||
сгенерировать один раз и быстро восстановить на чистом стенде без запуска
|
|
||||||
генератора, нет.
|
|
||||||
|
|
||||||
Из-за этого неудобно: мгновенно сбросить стенд, держать длинную стартовую
|
|
||||||
историю (2+ суток, чтобы суточная волна повторялась на графике) без повторной
|
|
||||||
генерации. Сейчас «дёшево» только live-возобновление из слепка и перезапуск без
|
|
||||||
`down -v`; полный сброс требует регенерации.
|
|
||||||
|
|
||||||
Этот пункт работает на главную цель: если стенд поднимается одной командой и есть
|
|
||||||
короткий runbook, генератор остаётся скрытой инфраструктурой и менти не нужно
|
|
||||||
знать его устройство. Развилка курса «урок про генератор vs скрытая
|
|
||||||
инфраструктура» уже закрыта в пользу скрытой инфраструктуры
|
|
||||||
(`docs/course/PRD.md` §7, 2026-07-04) — эта задача обеспечивает решению опору.
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
- **Экспорт** стартовой истории в портативный файл-артефакт: события (в формате
|
|
||||||
сообщений топиков) плюс слепок состояния плюс манифест — один связный набор,
|
|
||||||
чтобы не смешать `GEN_SEED`, `T0`, `T_end` и настройки генерации.
|
|
||||||
- **Импорт** (решение 2026-07-04): воспроизвести события артефакта в Kafka-топики
|
|
||||||
и вернуть слепок с манифестом в служебные compact-топики. **Напрямую в
|
|
||||||
ClickHouse импорт не пишет ничего**: стенд наполняется штатным путём
|
|
||||||
(Kafka engine + MV -> STG, батч-ETL -> витрины). Так не появляется обходного
|
|
||||||
пути данных, каждый импорт заодно прогоняет весь пайплайн, а менти видит,
|
|
||||||
как пустой стенд наполняется изучаемыми механизмами. Live продолжает с `T_end`.
|
|
||||||
- **Громкий отказ при несовместимом state** (решение 2026-07-04, пересмотр
|
|
||||||
правила спеки). Различать два случая. Нет состояния или оно повреждено ->
|
|
||||||
чистый старт с предупреждением (как сейчас, оставить). Состояние есть и
|
|
||||||
читается, но настройки несовместимы при `GEN_STATE_RESET=false` (оператор
|
|
||||||
намерен продолжить) -> жёсткое падение с указанием разошедшихся полей
|
|
||||||
(`seed`/`T0`/`timezone`/`speed`) и подсказкой выставить `GEN_STATE_RESET=true`,
|
|
||||||
если новый мир нужен осознанно. Текущее тихое поведение — `service.py:217-220`.
|
|
||||||
Правка идёт вместе с обновлением
|
|
||||||
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`.
|
|
||||||
- **Runbook «как пользоваться стендом на генерации»**: как сгенерировать,
|
|
||||||
сохранить, восстановить, выбрать длительность стартовой истории; что дёшево
|
|
||||||
(live-возобновление, перезапуск без чистки), а что требует регенерации.
|
|
||||||
- Новые команды экспорта/импорта делать в стиле глаголов (явное действие одной
|
|
||||||
командой), а не новыми комбинациями env-переменных.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Есть команда экспорта: стартовая история -> портативный файл-артефакт
|
|
||||||
(события + слепок + манифест) одним связным набором.
|
|
||||||
- [x] Есть команда импорта: на чистом стенде артефакт воспроизводится в Kafka
|
|
||||||
(события + служебные compact-топики) **без запуска генерации**; напрямую в
|
|
||||||
ClickHouse импорт не пишет. После штатного ETL контрольные числа в ClickHouse
|
|
||||||
совпадают с манифестом и исходной генерацией.
|
|
||||||
- [x] После импорта live продолжает с `T_end`: без дублей на границе и без
|
|
||||||
смешения миров.
|
|
||||||
- [x] Сохранено антисмешивание: импорт отвергает артефакт, несовместимый по
|
|
||||||
манифесту (`GEN_SEED`, `T0`, `T_end`, настройки генерации, версия state).
|
|
||||||
- [x] Громкий отказ: живое читаемое состояние + несовместимые настройки при
|
|
||||||
намерении продолжить -> падение с перечислением разошедшихся полей и
|
|
||||||
подсказкой; нет состояния или повреждено -> чистый старт с предупреждением
|
|
||||||
(как сейчас). Спека обновлена в этом же изменении.
|
|
||||||
- [x] Runbook описывает генерацию один раз, дешёвое восстановление, выбор
|
|
||||||
длительности и то, что переживает перезапуск, а что требует регенерации.
|
|
||||||
- [x] Runbook — про **использование**, устройство генератора в нём не
|
|
||||||
объясняется; за конструкцией он отсылает к `generator/README.md` и
|
|
||||||
`docs/specs/`.
|
|
||||||
- [x] Документы запуска (`README.md`, `docs/OPERATIONS.md`,
|
|
||||||
`generator/README.md`) ссылаются на runbook.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Опирается на спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`,
|
|
||||||
разделы «Манифест стартовой истории» и «Повторяемая проверка в ClickHouse».
|
|
||||||
- Спека уже упоминала будущий runbook «проверка генератора на стенде» — этот
|
|
||||||
issue его и закрывает, расширяя до полного цикла «генерация — сохранение —
|
|
||||||
восстановление».
|
|
||||||
- Откуда экспорту брать события — решить при реализации и зафиксировать в
|
|
||||||
спеке/runbook. Кандидаты: писать файл артефакта прямо при backfill (вторая
|
|
||||||
копия рядом с публикацией в Kafka), вычитать топики событий (учесть retention)
|
|
||||||
или выгрузить из STG (следить за точностью формата сообщений). Критерий
|
|
||||||
выбора: артефакт должен байт в байт воспроизводить сообщения топиков.
|
|
||||||
- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация,
|
|
||||||
формат данных, правка спеки — всё из порогов риска).
|
|
||||||
- Связано с `08-migrate-course-from-archive-seed.md`: миграция уроков идёт после
|
|
||||||
этой задачи и будет ссылаться на runbook отсюда (номера отражают порядок,
|
|
||||||
переставлены 2026-07-04).
|
|
||||||
|
|
||||||
## Идеи интерфейса — решения (2026-07-04)
|
|
||||||
|
|
||||||
Бывший раздел «на будущее, не решено» разобран с пользователем. Судьба идей:
|
|
||||||
|
|
||||||
- **Громкий отказ при несовместимом state** — включён в эту задачу
|
|
||||||
(см. What to build и критерии).
|
|
||||||
- **Глаголы / длительность / профили** — отдельная задача
|
|
||||||
`11-generator-launch-verbs-and-profiles.md`, делать **после этой и до
|
|
||||||
DAG-пульта**: чистый интерфейс «под капотом» делает DAG тонкой обёрткой с
|
|
||||||
простыми и понятными параметрами.
|
|
||||||
- **Airflow-DAG как пульт генератора** — приоритет поднят (пользователь,
|
|
||||||
2026-07-04): это будущий основной человеческий интерфейс стенда — «слишком
|
|
||||||
сложно» лечится формой в веб-UI, а не только runbook'ом. Отдельная задача
|
|
||||||
`12-generator-control-dag.md`, делать после задачи 11.
|
|
||||||
- **Доливка прошлого кусочком** — отдельная задача
|
|
||||||
`13-backfill-top-up-from-snapshot.md`, без приоритета.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md`
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Миграция учебных материалов с архивного сида на генерацию
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
После перевода штатного аналитического контура на стартовую историю генератора
|
|
||||||
часть учебных материалов всё ещё описывает `data/*.jsonl` как основной источник
|
|
||||||
данных стенда. Это нельзя править внутри issue 06: потребуется пройти уроки и
|
|
||||||
сохранить понятный учебный путь.
|
|
||||||
|
|
||||||
Развилка «отдельный урок про генератор vs скрытая инфраструктура» закрыта
|
|
||||||
(2026-07-04, `docs/course/PRD.md` §7): **отдельного урока не будет, генератор —
|
|
||||||
скрытая инфраструктура**. Устройство генератора — это разработка бэкенда и
|
|
||||||
математика, не инженерия данных; в цели курса не попадает. Уроки адаптируем,
|
|
||||||
не вводя марковские цепи и сложный Python в путь менти.
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Обновить курс и связанные материалы (тест-план) так, чтобы основной путь был:
|
|
||||||
|
|
||||||
```text
|
|
||||||
startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset
|
|
||||||
```
|
|
||||||
|
|
||||||
Архивный `data/*.jsonl` оставить только как временную фактуру генератора.
|
|
||||||
Генератор в уроках подаётся как готовый источник данных стенда («откуда берутся
|
|
||||||
данные»), без погружения в его устройство.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] `docs/course/` больше не ведёт ученика через `make data` или `kafka_load`
|
|
||||||
как основной путь получения аналитических данных.
|
|
||||||
- [x] Уроки явно объясняют, что `data/*.jsonl` пока остаётся кладовкой значений
|
|
||||||
для генератора, а не источником аналитического контура.
|
|
||||||
- [x] Уроки не вводят устройство генератора (марковская модель, внутренний
|
|
||||||
Python) в путь менти: генератор упоминается только как готовый источник данных.
|
|
||||||
- [x] Тест-план согласован с новым штатным путём запуска.
|
|
||||||
- [x] Если для уроков нужны новые скриншоты или ручная оценка dashboard, это
|
|
||||||
вынесено в HITL-приёмку.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
Реальный объём больше, чем кажется: `make data` вплетён во **все** уроки 00–05,
|
|
||||||
а не только в урок 06 (проверка 2026-07-04, `grep -rn "make data" docs/course/`):
|
|
||||||
|
|
||||||
- `docs/course/README.md` — быстрый старт через `LIMIT=50 make data`;
|
|
||||||
- `lessons/00_kafka_intro.md` — `make data` играет роль трекера, на нём построен
|
|
||||||
весь разбор партиций и offset'ов;
|
|
||||||
- `lessons/01–05` — `LIMIT=50 make data` в запуске, «полном сбросе» и таблицах
|
|
||||||
проверок;
|
|
||||||
- `docs/TEST_PLAN.md` — сценарии на старом пути.
|
|
||||||
|
|
||||||
Тонкое место — урок 00: там ручная заливка используется как учебный приём
|
|
||||||
(наглядно видно сообщения в топике). Решить при миграции, чем её заменить, не
|
|
||||||
потеряв наглядность; критерий приёмки запрещает `make data` как **основной путь
|
|
||||||
аналитики**, а не как локальный демонстрационный приём внутри урока.
|
|
||||||
|
|
||||||
Схема потока в `docs/course/PRD.md` §1 уже обновлена (поправка 2026-07-04) — PRD
|
|
||||||
дополнительно править не нужно.
|
|
||||||
|
|
||||||
Демо-материалов в скоупе нет: демо-употребление стенда устарело (демо выросло в
|
|
||||||
отдельный проект), шпаргалки удалены из репозитория 2026-07-04 (см. поправку в
|
|
||||||
шапке `docs/course/PRD.md`).
|
|
||||||
|
|
||||||
Рекомендуемый режим ревью по coordinator-loop: обычный (документы и уроки, кода
|
|
||||||
генератора и state задача не трогает); человеческая приёмка — проход по урокам
|
|
||||||
через пульт (HITL).
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- Жёстких блокеров нет: штатный путь (`make generated-history-analytics`) уже
|
|
||||||
существует, уроки можно вести через него.
|
|
||||||
- Мягкая зависимость от
|
|
||||||
`07-startup-history-portable-artifact-and-usage-docs.md`: runbook из issue 07
|
|
||||||
изменит команды запуска и восстановления стенда, а уроки будут на него
|
|
||||||
ссылаться. Делать эту задачу после него, иначе уроки придётся править дважды.
|
|
||||||
- Мягкая зависимость от `12-generator-control-dag.md` (решение 2026-07-04):
|
|
||||||
человеческая приёмка миграции — это проход по урокам 00–05, и вести её удобнее
|
|
||||||
через DAG-пульт, а не через консоль. Поэтому эта задача идёт последней в
|
|
||||||
очереди — после 12. Порядок всей очереди — в PRD, раздел «Задачи».
|
|
||||||
- Мягкая зависимость от `14-fast-teaching-profile.md` и
|
|
||||||
`09-seam-browser-fixture-not-preserved.md` (дополнено 2026-07-04): обе меняют
|
|
||||||
мир — 14 переводит профиль `daily-wave` на быстрый темп, 09 меняет схему
|
|
||||||
state. Артефакт стартовой истории для уроков должен родиться **после** них,
|
|
||||||
иначе он протухнет по антисмешиванию и проход по урокам придётся повторять.
|
|
||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Браузерная фактура не сохраняется на стыке восстановления визита
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Что нашли
|
|
||||||
|
|
||||||
Визит, переживший стык backfill->live (активен на `T_end` и продолжается живьём),
|
|
||||||
меняет браузерную фактуру в live-продолжении (фактура — конкретные значения полей
|
|
||||||
события: какой `browser_name`, user agent, язык стоят в строках). Внутри одного `click_id` человек как
|
|
||||||
будто «пересел» с одного браузера на другой посреди сессии.
|
|
||||||
|
|
||||||
Эмпирика. Чистый стенд, 2 суток backfill + 6 live-тиков за `T_end`,
|
|
||||||
`GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`,
|
|
||||||
`GEN_MODEL_T_END=2026-01-03T00:00:00+00:00`, `speed=1`:
|
|
||||||
|
|
||||||
- `crossing_visits=22`; `browser_name` отличается у `17/22`, `browser_language`
|
|
||||||
у `22/22`; каждая сторона внутренне постоянна (`22/22`).
|
|
||||||
- Чисто-исторические визиты (>=3 событий, целиком до `T_end`): браузер постоянен
|
|
||||||
у `0/14556`.
|
|
||||||
- device / os / geo на стыке НЕ расходятся: `0/22` конфликтов на уровне ODS.
|
|
||||||
- `duplicate_events=0`: дублей на границе нет.
|
|
||||||
- Пример `click_id`: в истории `Chrome / or_IN`, в live `Firefox / el_CY`.
|
|
||||||
|
|
||||||
## Почему это дефект
|
|
||||||
|
|
||||||
Нарушает заявленный критерий задач 04 и 05: «визит, переживший восстановление,
|
|
||||||
остаётся однородным; контекст визита не меняется на стыке и не создаёт второй путь
|
|
||||||
генерации внутри одного `click_id`». Браузер — часть контекста визита.
|
|
||||||
|
|
||||||
Критерий был помечен выполненным, но проверялся неполно: саморевью и ревьюер
|
|
||||||
смотрели только поля `dds.click` (user / device / geo), которые по одной строке на
|
|
||||||
`click_id` и потому однородны структурно. Per-event браузерные поля в `dds.event` не
|
|
||||||
проверялись. Это ровно слепая зона «двух путей генерации», под которую PRD требовал
|
|
||||||
внешний review gate другой родословной после задачи 5 — а он был выполнен той же
|
|
||||||
родословной, и дефект прошёл.
|
|
||||||
|
|
||||||
## Причина (подтверждена чтением кода, 2026-07-04)
|
|
||||||
|
|
||||||
Первоначальная гипотеза «рассинхрон `event_index`» не подтвердилась: восстановление
|
|
||||||
перебирает визит с нуля, индекс события выровнен правильно (точнее, берётся
|
|
||||||
`event_index % len` — по модулю длины списка, см. `runtime.py:332`). Разошёлся сам
|
|
||||||
**источник** фактуры:
|
|
||||||
|
|
||||||
- При рождении визита (`generation.py`, `generate_batch`) браузерные строки берутся
|
|
||||||
у **случайного** click_id из словаря — «донора» (`rng.choice(visit_candidates)`,
|
|
||||||
затем `browser_by_click_id[base_click_id]`). Кто донор — нигде не сохраняется,
|
|
||||||
он живёт только в памяти процесса.
|
|
||||||
- При восстановлении из state (`runtime.py`, `_compact_visit_batch`) фактура
|
|
||||||
пересобирается из **другого** click_id — того, к которому привязан пользователь
|
|
||||||
(`browser_by_click_id[user.seed_click_id]`).
|
|
||||||
|
|
||||||
Донор и seed — разные click_id, отсюда разные браузеры. Внутри одного click_id
|
|
||||||
сида браузер практически постоянен (реальный клик одного человека), поэтому
|
|
||||||
«каждая сторона внутренне постоянна». `browser_language` разошёлся у 22/22
|
|
||||||
(случайные click_id почти всегда с разными языками), `browser_name` — у 17/22
|
|
||||||
(популярные браузеры иногда совпадают случайно). device и geo не расходятся,
|
|
||||||
потому что оба пути берут их из `UserProfile`.
|
|
||||||
|
|
||||||
Расхождение шире, чем браузер (уточнение по ревью 2026-07-04). Шаблон location
|
|
||||||
ищется по event_id браузерной строки (`location_by_event_id[...]` в обоих путях),
|
|
||||||
то есть тоже зависит от того, чей click_id взят источником. Из state
|
|
||||||
восстанавливаются только `page_url` / `page_url_path`; остальные per-event поля
|
|
||||||
location — `referer_url`, `referer_medium`, `utm_*` — приходят от источника и на
|
|
||||||
стыке расходятся так же, как браузер. Эмпирика выше их просто не замеряла.
|
|
||||||
|
|
||||||
Тот же `restore_state` -> `_visit_from_state` -> `_compact_visit_batch` работает и
|
|
||||||
при восстановлении после сбоя (задача 04) — дефект касается и этого пути, не
|
|
||||||
только стыка backfill->live. Стык и рестарт на уровне генератора — один и тот же
|
|
||||||
код, различие только в отбрасывании просроченных визитов.
|
|
||||||
|
|
||||||
## Масштаб и важность
|
|
||||||
|
|
||||||
- Затрагивает только визиты, активные ровно на `T_end` (здесь 22 из 17397 визитов,
|
|
||||||
~0.13%). Чистый backfill и чистый live — без дефекта.
|
|
||||||
- Тот же механизм (фактура пересобирается при restore) вероятно затрагивает и
|
|
||||||
восстановление после сбоя (задача 04), не только backfill->live; проверка задачи 04
|
|
||||||
его не ловила, потому что смотрела только `dds.click`.
|
|
||||||
- Для учебного демо урон низкий, но это реальное нарушение стыковой однородности и
|
|
||||||
сигнал, что браузерная фактура не входит в восстановимое состояние визита.
|
|
||||||
|
|
||||||
## Решение по направлению фикса (пользователь, 2026-07-04)
|
|
||||||
|
|
||||||
**Хранить донора в state.** В сериализацию визита (`_visit_to_state`) добавить поле
|
|
||||||
с click_id донора фактуры (`base_click_id`), рядом с `page_url_paths` /
|
|
||||||
`timestamps`. При восстановлении `_compact_visit_batch` берёт строки из
|
|
||||||
`browser_by_click_id[base_click_id]` — по уже существующему абсолютному индексу
|
|
||||||
восстановленная часть точно продолжает выпущенную.
|
|
||||||
|
|
||||||
Рассмотренные альтернативы (отклонены):
|
|
||||||
|
|
||||||
- Брать браузер от `seed_click_id` пользователя уже при рождении визита — schema
|
|
||||||
state не меняется и семантика красивее («один человек — один браузер»), но
|
|
||||||
меняется весь выпуск генератора: контрольные суммы, распределение браузеров,
|
|
||||||
артефакты пришлось бы перегенерировать. Это уже не багфикс, а смена поведения;
|
|
||||||
если захочется — отдельная задача.
|
|
||||||
- Сериализовать браузерные строки визита целиком — надёжно, но раздувает state и
|
|
||||||
дублирует словарь. Перебор: донор + абсолютный индекс дают тот же результат.
|
|
||||||
|
|
||||||
Детали для исполнителя:
|
|
||||||
|
|
||||||
- В основной ветке рождения донор покрывает визит целиком: кандидаты фильтруются
|
|
||||||
по `len(browser_events) >= len(visit_path)` (`generation.py:170`), так что
|
|
||||||
индекс не выйдет за список строк донора и `% len` становится безобидным
|
|
||||||
холостым ходом (оставить или убрать — на усмотрение при фиксе).
|
|
||||||
- **Запасная ветка рождения** (`generation.py:182-185`): если кандидатов нет,
|
|
||||||
берётся одна случайная браузерная строка и повторяется на весь визит — тогда
|
|
||||||
«восстановить по донору и индексу» даст другие строки. С реальным сидом ветка,
|
|
||||||
судя по фильтру, не срабатывает — проверить при фиксе и либо превратить её в
|
|
||||||
громкую ошибку, либо сериализовать так, чтобы восстановление её воспроизводило.
|
|
||||||
Молча оставить как есть нельзя.
|
|
||||||
- Неизвестный `base_click_id` при восстановлении — ошибка (по образцу проверки
|
|
||||||
`seed_click_id` в `_user_from_state`). Сейчас в `_compact_visit_batch` два
|
|
||||||
тихих fallback'а (`.get(...)` со словарём целиком для браузера и
|
|
||||||
`location_events[0]` для location) — для донора их заменить на ошибку, не
|
|
||||||
копировать.
|
|
||||||
- **Известное расхождение вне скоупа**: event_id при рождении — случайный uuid4
|
|
||||||
(`generation.py`, `_new_uuid`), при восстановлении — детерминированный uuid5 от
|
|
||||||
`click_id:index` (`runtime.py:140-141`). Фикс это не трогает: коллизий нет,
|
|
||||||
дублей не создаёт. Тест поэтому сравнивает события по всем полям, **кроме
|
|
||||||
`event_id`** — иначе он падает не из-за нашего бага.
|
|
||||||
- Расширение схемы state — поднять `STATE_VERSION` (`state.py`) и валидацию
|
|
||||||
нового поля. Старые state становятся несовместимы: поведение при этом — по
|
|
||||||
действующему правилу (сейчас — предупреждение и чистый старт; громкий отказ
|
|
||||||
при намерении продолжить делает задача 07, здесь его не реализовывать).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state
|
|
||||||
посреди визита, восстановить и сверить оставшиеся события с продолжением без
|
|
||||||
рестарта — по **всем** per-event полям, кроме `event_id` (браузерные и
|
|
||||||
location: referer, utm — не только browser_name; см. «Расхождение шире» выше).
|
|
||||||
Это один код восстановления для стыка backfill->live и crash-recovery
|
|
||||||
(задача 04) — одного теста на него достаточно.
|
|
||||||
- [x] Проверка стыка на стенде расширена per-event полями: на сценарии из «Что
|
|
||||||
нашли» (2 суток backfill + live за `T_end`, `GEN_SEED=4242`) визиты через стык
|
|
||||||
однородны в `dds.event` по браузерным полям (`browser_name`,
|
|
||||||
`browser_language`) **и** полям источника перехода (referer, utm) — 0
|
|
||||||
расхождений из `crossing_visits`. Запрос/скрипт проверки сохранён как
|
|
||||||
повторяемый, а не разовый.
|
|
||||||
- [x] Без регрессий на том же сценарии: `duplicate_events=0`, конфликтов
|
|
||||||
device / os / geo на уровне ODS по-прежнему 0.
|
|
||||||
- [x] `STATE_VERSION` поднята, новое поле валидируется; несовместимый старый
|
|
||||||
state обрабатывается по действующему правилу (см. детали выше).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация —
|
|
||||||
из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз:
|
|
||||||
критерий задач 04/05 проверяли только по `dds.click`.
|
|
||||||
- Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть
|
|
||||||
исправлен до того, как на него навешивать доливку.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- Нет. Это bug по результатам независимой проверки задач 05 и 06.
|
|
||||||
-89
@@ -1,89 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Гео-карта на дашборде нечитаема
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md` (раздел «Финальная ручная
|
|
||||||
приёмка»: если панелей не хватает для просмотра — отдельная задача на панель).
|
|
||||||
|
|
||||||
## Что нашли
|
|
||||||
|
|
||||||
На финальной ручной приёмке (шаг 2 спеки) виджет «🌍 Geography Map» показывает
|
|
||||||
что-то, но прочитать нельзя:
|
|
||||||
|
|
||||||
- нет всплывающих подсказок (tooltip) — навёл на страну, значения не видно;
|
|
||||||
- нет легенды и подписи шкалы — непонятно, что кодирует цвет и в каких единицах;
|
|
||||||
- цветовая шкала неочевидна — подсвечено мало стран, остальное пусто.
|
|
||||||
|
|
||||||
График рисуется, но непонятно, что именно он показывает.
|
|
||||||
|
|
||||||
## Две разные причины (не путать)
|
|
||||||
|
|
||||||
1. **Читаемость визуализации (эта задача).** Сделать так, чтобы у графика были
|
|
||||||
tooltip, легенда и подпись метрики/единиц и понятная цветовая шкала — либо
|
|
||||||
настройкой текущего чарта, либо заменой карты (choropleth — карта, где страна
|
|
||||||
закрашена по значению) на более читаемую форму, например топ-N стран
|
|
||||||
столбцами, потому что распределение сильно разрежено. Важно: сейчас это
|
|
||||||
legacy-виз `world_map` (`superset/create_dashboard.py:149-164`), и у него,
|
|
||||||
судя по параметрам, легенда и tooltip вообще не настраиваются — скорее всего,
|
|
||||||
основной путь именно смена типа визуализации, а не подкрутка текущего.
|
|
||||||
Возможности виз-типов уточнить по актуальной документации Superset через
|
|
||||||
Context7 (правило AGENTS.md) и зафиксировать выбор.
|
|
||||||
2. **Перекос гео-данных (отдельно).** Сама гео-фактура (значения стран в
|
|
||||||
событиях) берётся из статического сида (`geo_by_click_id`), и на приёмке
|
|
||||||
распределение стран оказалось сильно перекошенным — закрашено мало стран.
|
|
||||||
Опора на чужой сид — осознанно временная (ADR-0006: своя гео-фактура —
|
|
||||||
отдельный будущий шаг); лечится это генерацией гео, а не настройкой чарта.
|
|
||||||
Здесь не решаем, только отмечаем как смежную причину.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] У гео-графика есть tooltip со значением по стране (на выбранном типе
|
|
||||||
визуализации — карте или замене).
|
|
||||||
- [x] Есть легенда и подпись: какая метрика и в каких единицах кодируется
|
|
||||||
(для текущей метрики `COUNT(*)` честный ответ — «событий, штук»).
|
|
||||||
- [x] Выбранный тип визуализации читаем на текущем (разреженном) распределении;
|
|
||||||
выбор типа (оставить карту или заменить) зафиксирован с коротким «почему».
|
|
||||||
- [x] Зафиксировано, что перекошенное распределение стран — свойство гео-фактуры
|
|
||||||
из сида (своя генерация гео — отдельный шаг по ADR-0006), а не настройки
|
|
||||||
чарта.
|
|
||||||
- [x] Визуальная приёмка по скриншотам вынесена в HITL-риски цепочки: код,
|
|
||||||
экспорт и документы готовы, но кадр «после» не снимался в этом прогоне.
|
|
||||||
|
|
||||||
## Как принимать (дописано 2026-07-04)
|
|
||||||
|
|
||||||
Проверка визуальная, через скриншоты playwright-cli (так уже делали в спеке
|
|
||||||
редизайна дашборда `docs/specs/2026-06-06-superset-dashboard-redesign.md`):
|
|
||||||
|
|
||||||
- Скриншоты «до» и «после»: кадр виджета целиком, плюс кадр с наведением курсора
|
|
||||||
на закрашенную страну / столбец (виден tooltip).
|
|
||||||
- «Читаемо» значит: по кадру виджета целиком (без обращения к SQL и коду чарта)
|
|
||||||
можно ответить на три вопроса — какая метрика показана, в каких единицах, у
|
|
||||||
какой страны значение больше. Если по кадру ответить нельзя — не принято.
|
|
||||||
- Tooltip — по отдельному кадру с наведением: видно название страны и значение
|
|
||||||
метрики. Кадр с tooltip в headless-прогоне может стабильно не ловиться
|
|
||||||
(tooltip следует за курсором); тогда допустим другой способ показать tooltip
|
|
||||||
(например, короткая запись экрана) — согласовать с человеком, молча не
|
|
||||||
ослаблять.
|
|
||||||
|
|
||||||
Состояние стенда: стенд не трогали с 2026-06-14 и он мог умереть. Это нормально:
|
|
||||||
полная пересборка (`make generated-history-analytics`) заново генерирует backfill.
|
|
||||||
По умолчанию она даёт **6 часов** модельной истории
|
|
||||||
(`GEN_MODEL_T_END` в `scripts/run_generated_history_analytics.sh`) — для приёмки
|
|
||||||
читаемости этого достаточно. Если нужен вид как на исходной находке (2 суток),
|
|
||||||
переопределить `GEN_MODEL_T_END` на `T0`+2 суток при запуске; профиль «2 суток
|
|
||||||
одной командой» — это ещё не сделанная задача 11, здесь её не делать.
|
|
||||||
Портативный артефакт (задача 07) для этой задачи не нужен и не блокирует её.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Всплыло на просмотре дашборда на данных генерации (2 суток backfill).
|
|
||||||
- Смежные документы: `superset/create_dashboard.py` (определение чарта),
|
|
||||||
`docs/SUPERSET_DASHBOARD.md` — при смене типа визуализации обновить в этом же
|
|
||||||
изменении.
|
|
||||||
- Возможно, стоит расширить до общего прохода по читаемости дашборда (легенды и
|
|
||||||
подсказки у других виджетов), но базово задача — про гео-карту. Если проход
|
|
||||||
делается, он не должен раздувать задачу: заметил — запиши отдельным issue.
|
|
||||||
- Рекомендуемый режим ревью по coordinator-loop: обычный (риска для state и
|
|
||||||
данных нет, правка ограничена определением чарта и документацией).
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
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 и команды
|
|
||||||
экспорта/импорта появляются там; эта задача переводит их на единый стиль.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Airflow-DAG — пульт управления генератором
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Даже с runbook и глаголами управление генератором остаётся консольным. Идея
|
|
||||||
(пользователь, 2026-06-14; приоритет поднят 2026-07-04): параметризованный DAG
|
|
||||||
в Airflow как основной человеческий интерфейс стенда — форма в веб-UI, где
|
|
||||||
выбираются операция, профиль, длительность. Плюсы: валидация настроек против
|
|
||||||
манифеста ещё до запуска, наглядный статус, понятные ошибки.
|
|
||||||
|
|
||||||
Учебный бонус: такой DAG сам по себе учебный материал — живой пример
|
|
||||||
параметризованной оркестрации (тема урока 4), что ближе к цели курса, чем
|
|
||||||
устройство генератора.
|
|
||||||
|
|
||||||
## Решения (дооформление 2026-07-04)
|
|
||||||
|
|
||||||
- **Без Docker-доступа из Airflow.** Docker socket в контейнеры Airflow не
|
|
||||||
прокидываем. Конечные операции — это обычный Python-код генератора, которому
|
|
||||||
нужны только настройки и Kafka; таски DAG выполняют его сами, в worker'е
|
|
||||||
Airflow. Урок из проекта `airflow-greenplum-solution` (пользователь): DAG
|
|
||||||
говорит с управляемой системой по сетевому протоколу, жизненный цикл
|
|
||||||
контейнеров — только хост/Makefile.
|
|
||||||
- **Границы пульта.** `make up`, `make clean` (полный сброс) и старт/стоп
|
|
||||||
live-сервиса — консоль; операции `continue` в пульте нет намеренно.
|
|
||||||
Backfill и import работают только на чистом стенде (пустые топики данных);
|
|
||||||
грязный стенд лечится `make clean` с консоли — пульт при отказе прямо
|
|
||||||
подсказывает это.
|
|
||||||
- **Генератор не автостартует.** Сейчас `make up` поднимает live-генератор
|
|
||||||
вместе со стендом (`docker-compose.yml`, `restart: unless-stopped`) — с
|
|
||||||
пультом это неверно: любой backfill был бы сразу отбит предпроверкой, а
|
|
||||||
выключить live из UI нельзя. Live становится явным действием
|
|
||||||
(`make generator-continue`); механизм — например, compose-профиль для
|
|
||||||
сервиса `generator`, выбрать при реализации и поправить make-цели.
|
|
||||||
- **Три операции, а не четыре.** `backfill` | `import` | `check`. Отдельного
|
|
||||||
`export` нет: артефакт в коде пишется только по ходу backfill
|
|
||||||
(`StartupHistoryArtifactBuilder` внутри `_run_backfill`), «выгрузки задним
|
|
||||||
числом» не существует — поэтому у backfill есть поле `artifact_path`
|
|
||||||
(«сохранить артефакт», пусто — не сохранять).
|
|
||||||
- **Один DAG** с параметром «операция», а не несколько DAG по операциям.
|
|
||||||
- **После backfill/import DAG сам запускает ETL** (`TriggerDagRunOperator` на
|
|
||||||
существующий ETL-DAG) и **дожидается его завершения** перед check:
|
|
||||||
`wait_for_completion` в Airflow 2.10.5 по умолчанию `False` (сверено по
|
|
||||||
Context7, ревью Codex 2026-07-04) — простой trigger запустил бы check
|
|
||||||
раньше конца ETL. Менти видит цепочку «мир → пайплайн → витрины».
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
- **Код генератора доступен таскам Airflow.** Варианты: смонтировать
|
|
||||||
`generator/src` томом (как уже смонтирован `./sql`) и добавить в
|
|
||||||
`PYTHONPATH`, либо ставить пакет в `Dockerfile.airflow`. Монтировать нужно
|
|
||||||
во все Airflow-сервисы (scheduler, webserver, worker): список профилей в
|
|
||||||
форме читается из `PROFILES` при разборе DAG. Критерий выбора — правка
|
|
||||||
генератора не должна требовать лишних пересборок.
|
|
||||||
- **Выравнивание зависимостей:** версии сейчас расходятся — kafka-python
|
|
||||||
2.0.5 у генератора против 2.0.6 в `airflow/requirements.txt`; привести к
|
|
||||||
одной. `prometheus-client` в образе Airflow нет — либо добавить, либо (лучше)
|
|
||||||
точка входа backfill не поднимает HTTP-сервер метрик (`service.py:65-66`
|
|
||||||
вызывается безусловно — понадобится небольшая склейка; это единственное
|
|
||||||
место, где честно появляется новый код, зафиксировать его в PR).
|
|
||||||
- **DAG `generator_control`** в `airflow/dags/`: `schedule=None`,
|
|
||||||
`max_active_runs=1`, форма запуска на `Param`:
|
|
||||||
- `operation`: `backfill` | `import` | `check` (выпадающий список);
|
|
||||||
- `profile`: из `PROFILES` (`launch.py`), список брать динамически;
|
|
||||||
- `duration`: строка вида `6h`/`2d`, пусто — из профиля;
|
|
||||||
- переопределения мира для backfill (минимум скорость и seed) — через
|
|
||||||
механизм `overrides` в `build_launch_env`;
|
|
||||||
- `artifact_path`: для backfill — куда сохранить артефакт (пусто — не
|
|
||||||
сохранять), для import — что импортировать; дефолт в общем томе `./data`.
|
|
||||||
- **Ветвление по операции** — `BranchPythonOperator`, в стиле существующих
|
|
||||||
DAG стенда.
|
|
||||||
- **Каждая операция начинается с валидации, падение — до любых записей:**
|
|
||||||
- backfill и import: топики данных пусты (переиспользовать
|
|
||||||
`KafkaTopicInspector.assert_data_topics_empty`) **и STG-таблицы
|
|
||||||
ClickHouse пусты** — батч-ETL пересобирает ODS из всего STG
|
|
||||||
(`sql/ods/20_stg_to_ods.sql`), поэтому пустых топиков мало: старый мир в
|
|
||||||
STG смешался бы с новым. При отказе в логе — подсказка про `make clean`;
|
|
||||||
- import дополнительно: артефакт читается, манифест совместим с выбранными
|
|
||||||
настройками (готовые проверки `startup_history_artifact.py`), в лог —
|
|
||||||
перечень разошедшихся полей;
|
|
||||||
- вспомогательно: проверка «live не работает» по метрикам
|
|
||||||
`generator:9109` внутри сети compose (см. Notes об ограничениях).
|
|
||||||
- **Тела операций — вызовы существующего кода:** `build_launch_env` + прогон
|
|
||||||
генерации до `T_end` (backfill), функции `startup_history_artifact`
|
|
||||||
(import), сверка контрольных чисел с манифестом (check). Источник манифеста
|
|
||||||
для check — compact-топик (он есть и после backfill, и после import);
|
|
||||||
артефакт — запасной вариант.
|
|
||||||
- `retries=0` у содержательных тасков: оператор должен сразу видеть ошибку
|
|
||||||
(проверенный приём из greenplum-проекта).
|
|
||||||
- **Документация:** в runbook `docs/runbooks/startup-history.md` — раздел
|
|
||||||
«Пульт в Airflow» как основной путь и явные границы пульта (что остаётся
|
|
||||||
консолью и почему, включая «continue в пульте нет намеренно»); тонкость
|
|
||||||
нестандартного мира: `make generator-continue` для мира с переопределёнными
|
|
||||||
настройками требует тех же настроек, громкий отказ подскажет разошедшиеся
|
|
||||||
поля. Ссылки из `docs/OPERATIONS.md` и `README.md`; правки compose и
|
|
||||||
Makefile описать в том же PR (правило репозитория).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] После `make up` (генератор не автостартует) стартовую историю выбранного
|
|
||||||
профиля/длительности можно создать из веб-UI Airflow, не открывая консоль и
|
|
||||||
не выставляя переменных окружения; следом ETL запускается из той же цепочки
|
|
||||||
и `check` зелёный.
|
|
||||||
- [x] Импорт артефакта из веб-UI: несовместимый артефакт отклоняется **до**
|
|
||||||
записи в топики, в логе таска — перечень разошедшихся полей.
|
|
||||||
- [x] Backfill/import на непустом стенде отклоняются предпроверкой с
|
|
||||||
подсказкой про `make clean`; миры не смешиваются.
|
|
||||||
- [x] Артефакт, сохранённый при backfill из веб-UI, пригоден для консольного
|
|
||||||
импорта (формат один и тот же), и наоборот.
|
|
||||||
- [x] Операция check сверяет контрольные числа ClickHouse с манифестом и
|
|
||||||
падает при расхождении.
|
|
||||||
- [x] Docker недоступен из контейнеров Airflow (socket не монтируется) — это
|
|
||||||
граница решения, а не упущение.
|
|
||||||
- [x] Консольные глаголы работают как раньше; `scripts/*` и DAG сходятся в
|
|
||||||
одном `launch.py`, дублирования логики запуска нет.
|
|
||||||
- [x] `make up` больше не запускает live; `make generator-continue` запускает
|
|
||||||
его явно; существующие сценарии (`generated-history-analytics`, CI) не
|
|
||||||
сломаны.
|
|
||||||
- [x] Runbook и `docs/OPERATIONS.md` описывают пульт как основной путь и его
|
|
||||||
границы.
|
|
||||||
- [x] DAG остаётся читаемым менти: витрина параметризованной оркестрации,
|
|
||||||
сложность живёт в коде генератора.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Перед реализацией сверить API формы (`Param`, enum, описания полей) и
|
|
||||||
`TriggerDagRunOperator` для Airflow 2.10 через MCP Context7 — правило
|
|
||||||
репозитория.
|
|
||||||
- `Config` генератора читает env процесса при создании: в таске собирать
|
|
||||||
окружение через `build_launch_env` и применять к процессу таска (executor —
|
|
||||||
LocalExecutor, таск живёт в своём процессе). Не забыть `GEN_DATA_DIR`:
|
|
||||||
`build_launch_env` его не задаёт, дефолт `/data`, а в контейнерах Airflow
|
|
||||||
данные смонтированы в `/opt/airflow/data` — без явной установки `Config()`
|
|
||||||
упадёт, словари `*.jsonl` не найдутся.
|
|
||||||
- Права на файлы: артефакт из worker'а пишется uid'ом Airflow (50000); в
|
|
||||||
контейнере генератора `./data` смонтирован **read-only**, консольные
|
|
||||||
скрипты монтируют каталог артефактов отдельно. Чтение работает везде,
|
|
||||||
перезапись чужого файла — не всегда; одну строку об этом — в runbook.
|
|
||||||
- Проверка `generator:9109` — вспомогательная эвристика, не защита: ошибка
|
|
||||||
DNS означает «сервис не поднят» (не ошибку таска); при крашлупе после
|
|
||||||
громкого отказа порт мигает (HTTP-сервер стартует до валидации state);
|
|
||||||
эфемерные контейнеры `docker compose run` под этим именем не видны. Основная
|
|
||||||
защита от смешивания — пустота топиков данных.
|
|
||||||
- Backfill — блокирующий таск на минуты; при `max_active_runs=1` для ручного
|
|
||||||
стенда это нормально.
|
|
||||||
- Грабли greenplum-проекта, применимые здесь: DAG создаются на паузе — в
|
|
||||||
инструкции приёмки не забыть unpause перед trigger; автоматические прогоны
|
|
||||||
гонять через REST API, а не CLI внутри контейнера.
|
|
||||||
- Возможное развитие (не в скоупе): операция «очистить данные» из пульта
|
|
||||||
(прецедент удаления/пересоздания топиков из DAG уже есть в
|
|
||||||
`airflow/dags/utils/kafka_helpers.py`) — сняла бы требование `make clean`
|
|
||||||
перед новым миром.
|
|
||||||
- Артефакты `daily-wave`, сделанные до задачи 14, протухнут после неё
|
|
||||||
(антисмешивание отработает громко) — при пересечении работ это ожидаемо.
|
|
||||||
- Рекомендуемый режим ревью по coordinator-loop: обычный — state и
|
|
||||||
сериализацию задача не трогает, код генератора переиспользуется как есть.
|
|
||||||
|
|
||||||
## Зависимости (проверенные предпосылки)
|
|
||||||
|
|
||||||
- `07-startup-history-portable-artifact-and-usage-docs.md` — сделана.
|
|
||||||
- `11-generator-launch-verbs-and-profiles.md` — сделана.
|
|
||||||
- Мягкая связь с `14-fast-teaching-profile.md`: быстрый профиль появится в
|
|
||||||
выпадашке сам, если список профилей брать из `PROFILES` динамически;
|
|
||||||
блокером не является.
|
|
||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
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 отдельно проверяет стык, где
|
|
||||||
переходящий визит гарантирован сценарием.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Быстрый учебный профиль: суточная волна за минуты занятия
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Мир по умолчанию живёт со скоростью настенных часов (`speed=1`): чтобы
|
|
||||||
наблюдать суточную волну вживую, стенд должен непрерывно работать сутки.
|
|
||||||
Столько стенды включёнными не живут, и люди не вытерпят (пользователь,
|
|
||||||
2026-07-04). Стартовая история закрывает только половину проблемы: она даёт
|
|
||||||
закономерности **в статике**, но правый край графика ползёт 1:1 — на занятии
|
|
||||||
live выглядит мёртвой картинкой.
|
|
||||||
|
|
||||||
Решение пользователя (2026-07-04): учебному миру нужна скорость 60 — модельный
|
|
||||||
час за настенную минуту, полная суточная волна проживается за ~24 минуты
|
|
||||||
занятия.
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
- Перевести профиль `daily-wave` (`launch.py`) на `GEN_MODEL_TIME_SPEED=60`
|
|
||||||
при `GEN_TICK_SECONDS=1`. Модельный тик остаётся прежним: 1 настенная
|
|
||||||
секунда × 60 = 60 модельных секунд, поэтому фактура потока (крупность пачек,
|
|
||||||
бюджет тика до 72 событий в пике: 60/мин × 1.2) не отличается от
|
|
||||||
сегодняшней, и колпак `GEN_MAX_EVENTS_PER_TICK` трогать не нужно.
|
|
||||||
- Наивный вариант «speed=60 при тике 60 с» отвергнут при разборе
|
|
||||||
(2026-07-04): модельный тик стал бы часом; первые события всех визитов тика
|
|
||||||
получают один таймстемп — старт тика (`runtime.py:433`,
|
|
||||||
`generation.py:198`) — история слиплась бы в почасовые гребёнки, а пиковый
|
|
||||||
тик (4320 событий) упёрся бы в колпак 1000 и срезал бы верхушку волны.
|
|
||||||
- **Оценить цену тика в 1 Гц.** Сейчас каждый live-тик пишет state в
|
|
||||||
compact-топик, запись в историю пачек и несколько INFO-строк
|
|
||||||
(`service.py:566, 584-585, 591-600`): на тике 1 с это 86 400 сохранений и
|
|
||||||
~полмиллиона строк лога за настенные сутки против 1 440 сегодня. Пер-тиковые
|
|
||||||
INFO приглушить до DEBUG (или логировать раз в N тиков); частоту сохранения
|
|
||||||
state менять только с оглядкой на спеку — она входит в контракт
|
|
||||||
возобновления после сбоя.
|
|
||||||
- Профиль `ci` не трогать: это дефолт CI, его мир и контрольные суммы должны
|
|
||||||
остаться прежними.
|
|
||||||
- Обновить документы, где живут предположения о старом `daily-wave`:
|
|
||||||
runbook `docs/runbooks/startup-history.md` (таблица профилей; продолжение
|
|
||||||
мира — `PROFILE=daily-wave make generator-continue`; старые артефакты
|
|
||||||
профиля становятся несовместимыми — антисмешивание отработает громко, это
|
|
||||||
ожидаемо), `docs/OPERATIONS.md` (smoke-последовательность со `sleep 130` —
|
|
||||||
ожидание двух минутных тиков — на тике 1 с теряет смысл), `README.md`,
|
|
||||||
`generator/README.md`.
|
|
||||||
- Спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`
|
|
||||||
дополнить: выбранная пара tick/speed и почему именно она.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] После backfill/импорта `daily-wave` и `PROFILE=daily-wave make
|
|
||||||
generator-continue` модельное время идёт ~в 60 раз быстрее настенного; на
|
|
||||||
дашборде суточная волна проживается за ~24 настенные минуты.
|
|
||||||
- [x] Пиковые тики не упираются в `GEN_MAX_EVENTS_PER_TICK` — волна не
|
|
||||||
срезана.
|
|
||||||
- [x] Стык «история → live» бесшовный: без дублей и дыр, антисмешивание
|
|
||||||
работает как раньше.
|
|
||||||
- [x] Многочасовой live на тике 1 с не заливает журналы и compact-топики:
|
|
||||||
пер-тиковые INFO приглушены или их объём обоснован в PR; объём
|
|
||||||
state-записей (86 400/сутки) измерен и обоснован — либо частота сохранения
|
|
||||||
state осознанно изменена вместе с правкой спеки (контракт возобновления).
|
|
||||||
- [x] Профиль `ci`, его контрольные суммы и поведение по умолчанию не
|
|
||||||
изменились; существующие тесты генератора проходят.
|
|
||||||
- [x] Runbook, `docs/OPERATIONS.md`, README'и и спека обновлены.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Нагрузка по событиям: ~42–72 события в секунду (ночь–пик) — незаметно для
|
|
||||||
Kafka и ClickHouse; backfill не дорожает (число тиков на 2 суток то же, что
|
|
||||||
сейчас: модельный шаг тика не изменился).
|
|
||||||
- Если генерация тика займёт больше 1 настенной секунды, тики пойдут реже и
|
|
||||||
фактическая скорость окажется чуть ниже 60 (`service.py:623-627`) — это
|
|
||||||
допустимо, точная скорость не обещается.
|
|
||||||
- Несовместимость со старыми мирами ловится по-разному: импорт артефакта и
|
|
||||||
continue от стартовой истории сверяют полный набор настроек (включая
|
|
||||||
`tick_seconds` в манифесте), а обычный live-continue — только
|
|
||||||
seed/T0/timezone/speed (`service.py:213-230`); для этой правки хватает и
|
|
||||||
его — speed изменился.
|
|
||||||
- Альтернатива «третий профиль fast-live» отвергнута: у медленного
|
|
||||||
`daily-wave` не остаётся сценария использования, а лишний профиль усложняет
|
|
||||||
выпадашку пульта (задача 12).
|
|
||||||
- Рекомендуемый режим ревью по coordinator-loop: обычный (значения профиля и
|
|
||||||
логирование). Исключение: если по ходу решат менять частоту сохранения
|
|
||||||
state — это уже контракт возобновления, эскалировать в **гейт**.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
Нет: профили из задачи 11 сделаны. Мягкие связи: задача 12 (профиль появится
|
|
||||||
в форме пульта автоматически) и задача 08 (артефакт курса рождается из этого
|
|
||||||
профиля — эту задачу лучше сделать до него).
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Граница миров после кросс-ревью
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Закрыть семейство находок кросс-линейного ревью про «смешение миров»: после
|
|
||||||
обновления кода, сброса стенда или консольного запуска старое состояние и старый
|
|
||||||
live-генератор не должны тихо писать события в новый мир данных.
|
|
||||||
|
|
||||||
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
|
|
||||||
разделы `Issue 12/14` и `Issue 09`.
|
|
||||||
|
|
||||||
Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`,
|
|
||||||
`12-generator-control-dag.md`, частично `14-fast-teaching-profile.md`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Известная старая версия state при `GEN_STATE_RESET=false` даёт громкий
|
|
||||||
отказ с понятным сообщением, а не тихий чистый старт поверх существующей
|
|
||||||
истории.
|
|
||||||
- [x] Сброс и остановка стенда убирают ручной live-генератор из compose-профиля;
|
|
||||||
он не переживает `down`/`clean` и не воскресает после перезагрузки хоста.
|
|
||||||
- [x] Консольные пути backfill/import/continue используют ту же идею
|
|
||||||
предпроверки чистого стенда, что Airflow-пульт, но реализованы способом,
|
|
||||||
пригодным для запуска с хоста.
|
|
||||||
- [x] Документация объясняет миграционный случай: почему старый state может
|
|
||||||
теперь дать громкий отказ и что делать оператору или менти.
|
|
||||||
- [x] Есть проверка, которая доказывает каждый из трёх путей смешения миров:
|
|
||||||
старая версия state, compose-профиль live-генератора, консольный запуск на
|
|
||||||
грязном стенде.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
Нет — можно начинать сразу.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Рекомендуемый режим `coordinator-loop`: гейт. Worker `high`, ревью задачи
|
|
||||||
`high`, ревью кода `high`.
|
|
||||||
- Не чинить здесь ложнозелёные проверки и курс целиком: это отдельные задачи
|
|
||||||
17 и 18. Здесь только граница миров и доказательства именно этой границы.
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Свежий стенд и Airflow-пульт без догадок
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Сделать так, чтобы человек на свежем клоне мог пройти быстрый старт до
|
|
||||||
`generator_control` без неявных шагов, сырых ошибок и зависаний Airflow.
|
|
||||||
|
|
||||||
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
|
|
||||||
разделы `Issue 12/14` и `Issue 08`.
|
|
||||||
|
|
||||||
Связанные старые issue: `12-generator-control-dag.md`,
|
|
||||||
`14-fast-teaching-profile.md`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Quick start и курс явно называют `uv` и остальные обязательные
|
|
||||||
пререквизиты до первой команды, которая их требует.
|
|
||||||
- [x] Быстрый старт на чистом стенде подготавливает DDL до проверок генератора;
|
|
||||||
отсутствие таблиц не падает сырым `UNKNOWN_TABLE`.
|
|
||||||
- [x] После обновления репозитория Airflow получает новые зависимости и DAG-и
|
|
||||||
без ручных догадок: через пересборку, явную команду или ясно описанный шаг.
|
|
||||||
- [x] `generator_control` не висит навсегда, если зависимый `etl_pipeline`
|
|
||||||
стоит на паузе: оператор получает понятный отказ или инструкцию до запуска
|
|
||||||
долгого ожидания.
|
|
||||||
- [x] Решение по Airflow API, проверенное через Context7 для текущей версии,
|
|
||||||
зафиксировано в коде или документации кратко и рядом с местом выбора.
|
|
||||||
- [x] Runbook предупреждает, что долгий простой `daily-wave` на скорости 60
|
|
||||||
создаёт большую дыру в модельном времени, и советует reset или повторный
|
|
||||||
импорт стартовой истории.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- `.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, ревью
|
|
||||||
кода `high`. Если правка уйдёт в устройство DAG-зависимостей шире
|
|
||||||
предпроверки паузы, эскалировать до гейта.
|
|
||||||
- Формальная зависимость от 15 нужна из-за общего участка предпроверки чистого
|
|
||||||
стенда: свежий стенд должен опираться на уже выбранный способ отказа и
|
|
||||||
диагностики грязного мира.
|
|
||||||
- Не включать сюда учебные тексты уроков 00/01/05, кроме пререквизитов и
|
|
||||||
quick start: основной проход курса закрывает задача 18.
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Проверки startup-history и Superset без ложного зелёного
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
Сделать проверки доверенными: зелёный результат должен означать, что проверен
|
|
||||||
фактический стык стартовой истории и живого продолжения, а Superset export
|
|
||||||
соответствует ожидаемому контракту чарта.
|
|
||||||
|
|
||||||
Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`,
|
|
||||||
разделы `Issue 09`, `Issue 10` и `Issue 08`.
|
|
||||||
|
|
||||||
Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`,
|
|
||||||
`10-dashboard-geo-map-readability.md`, частично `14-fast-teaching-profile.md`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Проверка startup-history берёт границу стыка из манифеста фактического
|
|
||||||
прогона, а не из значения окружения по умолчанию.
|
|
||||||
- [x] ODS-часть проверки не проходит на пустом ODS: отсутствие строк считается
|
|
||||||
ошибкой проверки, а не успешным результатом.
|
|
||||||
- [x] Профиль запуска доходит до проверки: сценарий с `daily-wave` проверяет
|
|
||||||
именно мир `daily-wave`, а не диапазон `ci`.
|
|
||||||
- [x] Ожидание live-строк доказывает, что живое продолжение реально успело
|
|
||||||
записать данные; короткое ожидание не должно превращать проверку стыка в
|
|
||||||
пропущенную проверку.
|
|
||||||
- [x] Тест Superset сравнивает полный существенный контракт `params` между
|
|
||||||
конфигурацией и export, а не только небольшой набор полей.
|
|
||||||
- [x] Сортировка top countries задана явно по числу событий, чтобы порядок не
|
|
||||||
зависел от случайной формы ответа после pivot.
|
|
||||||
- [x] Повторная синхронизация Superset не удаляет одноимённые чарты вне целевого
|
|
||||||
источника данных или dashboard; деструктивность sync задокументирована.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
Нет — можно начинать сразу.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`,
|
|
||||||
ревью кода `high`.
|
|
||||||
- Не смешивать с правками курса: задача 18 должна пользоваться уже доверенными
|
|
||||||
проверками, но текст уроков не является частью этой задачи.
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
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 требует нового поддержанного режима
|
|
||||||
перезаливки, не прятать это в тексте урока: оформить как явное решение и
|
|
||||||
покрыть проверкой.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
Status: done
|
|
||||||
|
|
||||||
# Runtime-проверка стыка нестабильна: фактура меняется через раз
|
|
||||||
|
|
||||||
## Parent
|
|
||||||
|
|
||||||
`.scratch/generator-model-time-startup-history/PRD.md`
|
|
||||||
|
|
||||||
## Что нашли
|
|
||||||
|
|
||||||
Два подряд прогона `make generated-history-runtime-check` на одном и том же
|
|
||||||
стенде (2026-07-07, проверка пути менти) дали разный результат:
|
|
||||||
|
|
||||||
- Прогон 1 (20:18–20:20): **красный** —
|
|
||||||
`Ошибка: per-event фактура меняется на стыке: 8/19`
|
|
||||||
(у 8 из 19 визитов, переживших границу backfill->live, поменялась
|
|
||||||
per-event фактура). При этом `duplicate_events=0`, events=2776.
|
|
||||||
- Прогон 2 (21:5x, та же команда, без изменений кода): **зелёный** —
|
|
||||||
«runtime-проверка startup-history/live seam прошла», конфликтов 0,
|
|
||||||
events=2815.
|
|
||||||
|
|
||||||
Конфигурация проверки: `GEN_LAUNCH_PROFILE=daily-wave`,
|
|
||||||
`GEN_HISTORY_DURATION=1h`, `GEN_MODEL_T_END=2026-01-01T01:00:00+00:00`
|
|
||||||
(зашита в `scripts/run_generated_history_runtime_check.sh`).
|
|
||||||
|
|
||||||
## Почему это важно
|
|
||||||
|
|
||||||
- Это гейт, которому мы доверяем стык backfill->live (задача 17); флаки-гейт
|
|
||||||
ничего не гарантирует: красный пугает зря, зелёный ничего не доказывает.
|
|
||||||
- Смена фактуры на стыке — класс дефекта задачи 09, который считается
|
|
||||||
закрытым (донор фактуры сохранён в state, тихие fallback'и заменены на
|
|
||||||
ошибки). Либо фикс неполон, либо есть второй источник расхождения.
|
|
||||||
- Задача 13 (глагол next-day) навешивает на этот же механизм цепочку границ —
|
|
||||||
ей нужен доверенный, стабильный гейт.
|
|
||||||
|
|
||||||
## Диагноз (Codex, 2026-07-07; ключевой факт перепроверен координатором)
|
|
||||||
|
|
||||||
**Корень: гонка остановки live-генератора, а не смена фактуры.** Цепочка:
|
|
||||||
`run_generated_history_runtime_check.sh` останавливает live сразу после первых
|
|
||||||
новых STG-строк -> генератор публикует batch по четырём топикам
|
|
||||||
последовательно, без атомарности -> `docker compose stop generator` может
|
|
||||||
оборвать процесс между топиками -> часть browser-событий остаётся без парных
|
|
||||||
location/device/geo -> DDS строит `dds.event` через `LEFT JOIN location`,
|
|
||||||
и такие строки получают `NULL` в referer/utm -> seam-SQL считает это «сменой
|
|
||||||
фактуры внутри click_id».
|
|
||||||
|
|
||||||
Эмпирика: в красном прогоне `browser_raw=2776`, но `location_raw=2736` —
|
|
||||||
40 live-событий без пары; в зелёном `browser_raw=location_raw=2815`, поэтому
|
|
||||||
проверка прошла (хотя device/geo и там отстали: 2781).
|
|
||||||
|
|
||||||
Судьба гипотез:
|
|
||||||
|
|
||||||
1. Запасная ветка рождения — **опровергнута**: ветка уже хранит донора
|
|
||||||
(`generation.py:196-209`, комментарий «Запасная ветка тоже восстановима»)
|
|
||||||
и громко падает на неполных locations. Перепроверено координатором по коду.
|
|
||||||
2. Недетерминизм live — **подтверждена частично**: решает не сид, а настенный
|
|
||||||
момент остановки процесса и какие топики успели дописаться.
|
|
||||||
3. Проверочный SQL — **опровергнута в формулировке**: поля не «легитимно
|
|
||||||
различаются», SQL маскирует пропущенный location под «смену фактуры».
|
|
||||||
|
|
||||||
Уточнение механизма обрыва (адверсарное ревью постановки, 2026-07-12;
|
|
||||||
перепроверено координатором по коду):
|
|
||||||
|
|
||||||
- Генератор не обрабатывает SIGTERM: ловится только `KeyboardInterrupt`
|
|
||||||
(`service.py:175`), а `docker compose stop` шлёт именно SIGTERM. В compose
|
|
||||||
у сервиса generator нет `init:`/`stop_signal:`, python работает PID 1 —
|
|
||||||
SIGTERM игнорируется, и через grace-период прилетает SIGKILL: жёсткий
|
|
||||||
обрыв посреди batch, без `finally` и без flush.
|
|
||||||
- Почему отставание device/geo не валит гейт, а location валит: device/geo
|
|
||||||
привязаны к `click_id` (одно значение на визит) — при пропуске весь визит
|
|
||||||
однородно NULL, массив уникальных значений длины 1, проверка проходит;
|
|
||||||
location привязан к `event_id` — частичный пропуск даёт смешанный массив
|
|
||||||
(реальное значение + NULL) и «смену фактуры». Догонять device/geo фикс
|
|
||||||
не обязан — важна граница batch.
|
|
||||||
|
|
||||||
## Направление фикса (развилка решена после ревью постановки 2026-07-12)
|
|
||||||
|
|
||||||
1. **Основное: корректная обработка SIGTERM в генераторе.** Минимальный
|
|
||||||
хендлер: по SIGTERM выставить `_running = False`, дать текущему tick'у
|
|
||||||
дописаться (все четыре топика + flush + запись batch history), затем
|
|
||||||
штатный `finally`/`stop()`. Runtime-check после `docker compose stop`
|
|
||||||
дожидается фактического завершения контейнера. Так генератор завершается
|
|
||||||
только на границе batch — гонка снята по построению, а не вероятностно.
|
|
||||||
- **Отклонено: «ожидание записи `generator_batch_history` перед stop»
|
|
||||||
как самостоятельный фикс** — запись history подтверждает только
|
|
||||||
ПРОШЕДШИЙ batch (`service.py:586-603`, пишется после цикла публикации);
|
|
||||||
следующий batch к моменту stop уже может быть в полёте, и SIGKILL
|
|
||||||
оборвёт его так же. Довод — ревью постановки, перепроверен по коду.
|
|
||||||
- Bounded live mode (генератор сам останавливается по лимиту) — более
|
|
||||||
тяжёлая альтернатива; в скоуп не входит, возвращаться к ней только
|
|
||||||
если SIGTERM-хендлера окажется недостаточно (с доводом в задаче).
|
|
||||||
2. **Дополнительно:** precheck в seam-check на непарные
|
|
||||||
browser/location/device/geo live-строки — чтобы ошибка называла реальную
|
|
||||||
причину, а не «фактура поменялась». Precheck не подменяет основную
|
|
||||||
проверку: настоящая смена фактуры внутри `click_id` обязана падать
|
|
||||||
как и раньше.
|
|
||||||
3. **Отклонены любые вероятностные смягчения**, а не только «увеличить
|
|
||||||
sleep»: settle-sleep перед прогоном, рост `WAIT_LIVE_ROWS`/`LIVE_SECONDS`,
|
|
||||||
сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить
|
|
||||||
вероятность — гонку они не убирают и фиксом не считаются.
|
|
||||||
|
|
||||||
## Принятый остаточный риск (решение координатора, 2026-07-12)
|
|
||||||
|
|
||||||
Ревью отметило: `stop_grace_period: 1m` не ограничивает публикацию по
|
|
||||||
времени — при зависании Kafka дольше минуты SIGKILL всё ещё оборвёт batch.
|
|
||||||
Риск принят: штатный тик публикуется за секунды (минута — многократный
|
|
||||||
запас), а ограничивать публикацию таймером значило бы рвать batch уже по
|
|
||||||
построению. Ключевое отличие от исходного флаки: такой обрыв больше не
|
|
||||||
маскируется под «смену фактуры» — precheck непарных строк назовёт его
|
|
||||||
явно, гейт упадёт громко и честно.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Причина расхождения 8/19 найдена и названа (код, не догадка) —
|
|
||||||
см. «Диагноз» выше.
|
|
||||||
- [x] Генератор корректно завершается по SIGTERM: текущий batch дописывается
|
|
||||||
во все четыре топика целиком (flush + запись history), потом процесс
|
|
||||||
выходит; runtime-check дожидается фактической остановки контейнера
|
|
||||||
(направление фикса, пункт 1). Плюс `stop_grace_period: 1m` в compose.
|
|
||||||
- [x] Seam-check различает «непарные live-строки» и «смена фактуры»:
|
|
||||||
precheck называет реальную причину (пункт 2).
|
|
||||||
- [x] Красный сценарий по-прежнему ловится: контролируемое искажение
|
|
||||||
`referer_url` у события пересекающего визита в `dds.event` уронило гейт
|
|
||||||
с прежним сообщением «per-event фактура меняется на стыке: 18/19» при
|
|
||||||
нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12).
|
|
||||||
- [x] Стабильность обоснована структурно (SIGTERM ставит флаг, тик
|
|
||||||
дописывает все четыре топика + flush + history и выходит на границе
|
|
||||||
batch — тест `test_sigterm_during_publish_finishes_current_batch`);
|
|
||||||
дымовая проверка поверх довода: 3 подряд зелёных
|
|
||||||
`make generated-history-runtime-check` (2026-07-12, прогоны 19:25,
|
|
||||||
19:28, 19:30).
|
|
||||||
|
|
||||||
## Находки стендовой приёмки (2026-07-12, исправлены в этой же задаче)
|
|
||||||
|
|
||||||
Ревью по чтению кода их поймать не могло — вскрылись только прогонами:
|
|
||||||
|
|
||||||
1. **Гейт жил на побочном эффекте бага.** Пересекающие визиты для проверки
|
|
||||||
стыка появлялись только потому, что генератор игнорировал SIGTERM и
|
|
||||||
дописывал ~10 секунд данных до SIGKILL. После фикса live-хвост стал
|
|
||||||
коротким и пересекающих визитов могло не быть вовсе. Фикс: шаг 6
|
|
||||||
runtime-check ждёт не «любую новую STG-строку», а появления
|
|
||||||
пересекающего визита в STG (детерминированное предусловие проверки,
|
|
||||||
то же окно, что у seam-SQL).
|
|
||||||
2. **Часовой пояс в STG-запросах.** Сырые `event_timestamp` наивные и
|
|
||||||
означают UTC, сервер ClickHouse — Europe/Moscow: сравнение с границей,
|
|
||||||
заданной с `+00:00`, уезжало на 3 часа, precheck был зелёным вакуумно
|
|
||||||
(не видел ни одной live-строки). Фикс: явный `'UTC'` в
|
|
||||||
`parseDateTime64BestEffort*` с обеих сторон сравнения (4 места),
|
|
||||||
закреплено контрактными тестами.
|
|
||||||
|
|
||||||
## Blocked by
|
|
||||||
|
|
||||||
- Нет. Диагноз можно начинать сразу; стенд воспроизводит через раз.
|
|
||||||
|
|
||||||
Связано: `09-seam-browser-fixture-not-preserved.md` (класс дефекта и решение
|
|
||||||
про донора), `13-backfill-top-up-from-snapshot.md` (нуждается в доверенном
|
|
||||||
гейте на цепочке границ), `17-trusted-checks-startup-history-superset.md`
|
|
||||||
(появление этого гейта).
|
|
||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
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`.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Handoff: спеки генератора готовы — передача на детальный план и реализацию
|
|
||||||
|
|
||||||
Дата: 2026-06-10
|
|
||||||
Ветка: `feature/data-generator`
|
|
||||||
Жанр: одноразовые леса́ (ADR-0003) — durable-рассуждение в спеках/ADR/CONTEXT, не здесь.
|
|
||||||
|
|
||||||
> Место: `.scratch/handoffs/` по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md)
|
|
||||||
> (перекрывает generic-дефолт скилла «temp dir»: мультимашинность + worktrees).
|
|
||||||
> Предыдущий handoff (2026-06-09-generator-rework.md) отработал и удалён.
|
|
||||||
|
|
||||||
## Где остановились
|
|
||||||
|
|
||||||
Дизайн-этап переработки генератора **завершён**. Спека математической модели
|
|
||||||
написана, прошла **два** адверсариальных ревью свежими агентами (второе ревью
|
|
||||||
ловило ошибки правок первого — практика себя оправдала), все находки закрыты,
|
|
||||||
ключевые цифры перепроверены замерами по полному сиду. Кода по-прежнему
|
|
||||||
не трогали.
|
|
||||||
|
|
||||||
Разделение ролей (см. память проекта): дизайн/спеки — Fable, **детальный план
|
|
||||||
и реализация — Codex 5.5**. Следующая сессия — скорее всего, подготовка задачи
|
|
||||||
для Кодекса или сама реализация.
|
|
||||||
|
|
||||||
## Документы (источники истины, не пересказываю)
|
|
||||||
|
|
||||||
- **Мат-модель (главный документ для исполнителя):**
|
|
||||||
`docs/specs/2026-06-10-generator-math-model.md` — марковская цепочка по
|
|
||||||
страницам, формула «популяция ↔ интенсивность ↔ пауза», кулдаун, правило
|
|
||||||
30 минут на рестарт, критерии приёмки.
|
|
||||||
- **Форма доработки:** `docs/specs/2026-06-09-generator-rework-hierarchical.md`
|
|
||||||
(Open questions закрыты ссылкой на мат-спеку).
|
|
||||||
- **Почему генератор, а не реплей:** `docs/adr/0004-steady-stream-synthetic-generator.md`.
|
|
||||||
- **Профиль сид-датасета (опора калибровки):** `CONTEXT.md`, раздел
|
|
||||||
«Профиль сид-датасета» — измерено по полным файлам 2026-06-10.
|
|
||||||
- **Диагноз дефекта старого кода:** `generator/KNOWN_ISSUES.md`.
|
|
||||||
|
|
||||||
## Ловушки (одной строкой; детали — в спеках)
|
|
||||||
|
|
||||||
- Ранний «факт» **«1..7 событий на визит» был неверен** (срез файла); реально
|
|
||||||
1..27, медиана 10. Если встретишь «1..7» где-то ещё в доках/коде — это
|
|
||||||
остатки ошибки, чинить по профилю сида.
|
|
||||||
- **Формула паузы в мат-спеке обязательна** при смене λ/популяции; кулдаун в
|
|
||||||
неё не прибавляется (уже учтён балансом). Вторая ревизия исправляла именно
|
|
||||||
двойной счёт — не откатить случайно.
|
|
||||||
- **device/geo публикуются на каждое событие** (как в сиде) — это контракт с
|
|
||||||
ETL, не деталь.
|
|
||||||
- `event_timestamp` событий — **запланированное** время, не момент отправки
|
|
||||||
тика (иначе метки прилипают к сетке тиков).
|
|
||||||
- Модель интенсивности (`_calculate_events_count`) сохраняем; дефолт
|
|
||||||
`GEN_LAMBDA_BASE_PER_MIN` меняется 200 → 30.
|
|
||||||
|
|
||||||
## Следующий шаг
|
|
||||||
|
|
||||||
Передать пару спек Кодексу: детальный план реализации → код. Уровень спек
|
|
||||||
сознательно «решения и инварианты, без алгоритмов» — конкретику исполнитель
|
|
||||||
достраивает сам. Возможная подготовка: оформить задачу в `.scratch/<feature>/`
|
|
||||||
по `docs/agents/issue-tracker.md` (скилл `to-issues`, если план дробить).
|
|
||||||
|
|
||||||
## Suggested skills (для следующей сессии)
|
|
||||||
|
|
||||||
- **`to-issues`** — если решим дробить реализацию на задачи в локальном трекере.
|
|
||||||
- **`tdd`** — для этапа реализации (pytest-набор `generator/tests/` существует,
|
|
||||||
но писался под старую модель — пересмотр под новую неизбежен).
|
|
||||||
- **`adversarial-review`** / **`code-review`** — ревью реализации против
|
|
||||||
мат-спеки перед вливанием.
|
|
||||||
- **`conventional-commits`** — коммиты по правилам репозитория.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 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` — при фиксации следующих изменений.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# 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`». Координатор классифицировал это как `чинить до
|
|
||||||
коммита`: финальный интеграционный тест должен доказывать именно уход от старой
|
|
||||||
плоской модели, а не только факт публикации.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Handoff: ADR-0006 (источник аналитики) + спека модельного времени
|
|
||||||
|
|
||||||
Дата: 2026-06-14
|
|
||||||
Ветка: `feature/data-generator`
|
|
||||||
Режим работы: дизайн (brainstorm-with-docs), не реализация кода.
|
|
||||||
|
|
||||||
## Что сделано в этой сессии (закоммичено)
|
|
||||||
|
|
||||||
Содержание не дублирую — смотри сами файлы:
|
|
||||||
|
|
||||||
- **ADR-0006** `docs/adr/0006-generation-as-sole-analytics-source.md` — генерация
|
|
||||||
становится единственным источником аналитики; статический сид → архивный;
|
|
||||||
перевод процесса на новый (сгенерированный) сид — цель работы. Коммит `49512b1`.
|
|
||||||
- **Спека** `docs/specs/2026-06-14-generator-model-time-and-startup-history.md` —
|
|
||||||
модельное время на практике: точка отсчёта `T0`, повторяемость, заливка
|
|
||||||
прошлого, стартовая история, сохранение состояния, скорость ×K, проверка в два
|
|
||||||
шага. Коммит `49512b1`.
|
|
||||||
- **Указатели вперёд** в ADR-0004 и ADR-0005 на ADR-0006 и спеку. Коммит `49512b1`.
|
|
||||||
- **AGENTS.md** — ужесточено правило про русский язык + добавлено правило про
|
|
||||||
понятность и краткость документов. Коммит `c4cc01e`.
|
|
||||||
|
|
||||||
## Контекст и решения, которых нет в артефактах (важны для продолжения)
|
|
||||||
|
|
||||||
- **Роль сида = «кладовка значений» (палитра атрибутов).** Генератор сам придумывает
|
|
||||||
структуру (иерархию, время, воронку), но фактуру (браузер/гео/устройство/utm)
|
|
||||||
копирует из строк сида: `generator/src/clickstream_generator/generation.py`
|
|
||||||
(строки ~202–228, `{**base_browser, …}`), индексы в `dictionary.py`. Профиль
|
|
||||||
популяции хранится ссылкой на сид-сессию (мат-спека, §«Профиль пользователя»).
|
|
||||||
Поэтому сид нельзя удалить, пока генератор не научится фактуре сам.
|
|
||||||
- **Сид полностью чист** — проверено: 1000 записей × 4 файла, 0 падений в DQ
|
|
||||||
(`ods.*_errors`). Значит вывод сида не ломает учебный путь про грязные данные.
|
|
||||||
- **Двухшаговая проверка** (раздел «Проверка» спеки): шаг 1 — агент на чистом
|
|
||||||
стенде сверяет числа в ClickHouse (пирамида/воронка/возвраты), детерминированно;
|
|
||||||
шаг 2 — человек смотрит дашборды (распределение похоже на задуманное + стенд
|
|
||||||
«дышит» на ×K). Вскрытый пробел: дашборда с распределением сгенерированных
|
|
||||||
данных нет (Superset на сиде, Grafana показывает пропускную способность).
|
|
||||||
- **Известные расхождения калибровки** (из прошлого ревью, не в этих артефактах):
|
|
||||||
медиана длины визита ~8 против сидовых 10, всплеск на длине 2, нет
|
|
||||||
bounce-визитов длины 1, доля `/confirmation` ~27%. Когда генерация станет
|
|
||||||
источником, это станет «правдой» стенда — учесть при заполнении коридоров шага 1
|
|
||||||
и в будущей задаче калибровки/фактуры.
|
|
||||||
|
|
||||||
## Очередь (следующие шаги)
|
|
||||||
|
|
||||||
1. Выровнять `CONTEXT.md` (раздел «три значения слова сид») под ADR-0006:
|
|
||||||
статический сид → архивная кладовка значений, цель — полный вывод.
|
|
||||||
2. Пометить в мат-спеке `docs/specs/2026-06-10-generator-math-model.md` разделы
|
|
||||||
«Персистентность через рестарты» и «Воспроизводимость» как переописанные в
|
|
||||||
модельном времени новой спекой (ссылкой, без дублирования).
|
|
||||||
3. (Опционально) Этап 1 вживую: поднять стенд на текущем генераторе, снять
|
|
||||||
реальные числа в ClickHouse, заполнить коридоры в разделе «Проверка». Поднимает
|
|
||||||
docker-стек (меняет состояние) — только с согласия владельца.
|
|
||||||
4. Черновик спеки **синтеза фактуры** — то, что позволит удалить `data/*.jsonl`.
|
|
||||||
5. Черновик спеки **перестройки процесса** (загрузка `kafka_load_dag` → новый сид,
|
|
||||||
Superset/витрины, уроки) — на этапе реализации, по ADR-0006.
|
|
||||||
6. Реализация механизма (модельные часы/×K/стартовая история) — это код, зона Codex.
|
|
||||||
|
|
||||||
## Ограничения (продолжать соблюдать)
|
|
||||||
|
|
||||||
- Роль: Claude — ADR/спеки/ревью; Codex — код. Намерение, против которого
|
|
||||||
сверяем, живёт в ADR/спеках Claude.
|
|
||||||
- Язык: ясный русский без транслитераций (теперь зафиксировано в `AGENTS.md`).
|
|
||||||
- Коммиты: только по просьбе; формат conventional-commits; трейлер
|
|
||||||
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
|
||||||
- Git строго аддитивно: на ветке может параллельно писать Codex — не делать
|
|
||||||
amend/rebase/reset чужих коммитов. В `main` — merge `--no-ff`, не тащить
|
|
||||||
`.scratch/`.
|
|
||||||
- Context7 для спорных/меняющихся API (особенно Airflow).
|
|
||||||
- Публичный репозиторий — без персональных данных менти.
|
|
||||||
|
|
||||||
## Suggested skills
|
|
||||||
|
|
||||||
- **brainstorm-with-docs** — для продолжения дизайна (пункты 1, 4, 5 очереди:
|
|
||||||
`CONTEXT.md`, спека фактуры, спека перестройки процесса).
|
|
||||||
- **conventional-commits** — для коммитов.
|
|
||||||
- **Context7 (MCP)** — при касании API.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# Кросс-линейное ревью цепочки генератора 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-теста зелёные.
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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` — коммиты.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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"]`) и про формат артефакта.
|
|
||||||
File renamed without changes.
@@ -41,11 +41,11 @@
|
|||||||
|
|
||||||
### Issue tracker
|
### Issue tracker
|
||||||
|
|
||||||
Локальный markdown: задачи и PRD живут файлами в `.scratch/<feature>/` (коммитятся, `.scratch/` не в `.gitignore`). GitHub Issues не используются. См. `docs/agents/issue-tracker.md`.
|
GitHub Issues (через CLI `gh`). Спека фичи — файлом в `docs/specs/` (источник истины), корневой issue — тонкий, со ссылкой на спеку и чек-листом дочерних issues. См. `docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
### Triage labels
|
### Triage labels
|
||||||
|
|
||||||
Пять канонических ролей, строки совпадают с именами (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`.
|
Пять канонических ролей как метки GitHub, имена совпадают (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
### Domain docs
|
### Domain docs
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -87,9 +87,31 @@ user_domain_id (пользователь, постоянный)
|
|||||||
это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` +
|
это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` +
|
||||||
заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006
|
заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006
|
||||||
она **несущая**: именно с неё свежий стенд получает историю с первой минуты.
|
она **несущая**: именно с неё свежий стенд получает историю с первой минуты.
|
||||||
Механизм реализован через Airflow DAG `generator_control` и служебный чистый
|
Механизм реализован через Airflow DAG `world_init` и служебный чистый
|
||||||
путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md).
|
путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md).
|
||||||
|
|
||||||
|
### Мир (стенда)
|
||||||
|
|
||||||
|
Совокупное состояние данных стенда: события в Kafka, слои ClickHouse,
|
||||||
|
состояние генератора (модельные часы, живая популяция) и manifest с
|
||||||
|
контрольными числами. «Вырастить мир» — добавить в него модельное время
|
||||||
|
(`next-day` или живой поток), «восстановить мир» — залить его из артефакта.
|
||||||
|
|
||||||
|
### Эталонный мир (reference world)
|
||||||
|
|
||||||
|
Канонический стартовый мир менти: 3 модельных дня на профиле `daily-wave`,
|
||||||
|
собран мейнтейнером один раз, хранится сжатым артефактом в git. Все менти
|
||||||
|
импортируют один и тот же файл — числа в лабах воспроизводимы
|
||||||
|
число-в-число. Решение — [спека редизайна пути менти](./docs/specs/2026-07-19-mentee-path-redesign.md).
|
||||||
|
|
||||||
|
### Три режима менти
|
||||||
|
|
||||||
|
`import` (база: загрузить эталонный мир) и две ветки от восстановленного
|
||||||
|
состояния: `next-day` (пакетно добавить модельный день) и `continue`
|
||||||
|
(живой поток от той же границы). Это разные педагогики и разные лабы;
|
||||||
|
`backfill` — не режим менти, а инструмент мейнтейнера для сборки
|
||||||
|
эталонного мира. См. ту же спеку.
|
||||||
|
|
||||||
### Модельное время и масштаб (×K)
|
### Модельное время и масштаб (×K)
|
||||||
|
|
||||||
**Модельное время стенда** отвязано от настенных часов: генератор крутит
|
**Модельное время стенда** отвязано от настенных часов: генератор крутит
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
contract-test config-test test lint
|
contract-test config-test test lint
|
||||||
|
|
||||||
COMPOSE ?= docker compose
|
COMPOSE ?= docker compose
|
||||||
PROFILE ?= ci
|
STARTUP_HISTORY_EXPORT_ARTIFACT ?= $(or $(ARTIFACT),/tmp/clickstream-startup-history.json)
|
||||||
|
STARTUP_HISTORY_IMPORT_ARTIFACT ?= $(or $(ARTIFACT),data/startup_history/reference-world.json.xz)
|
||||||
|
PROFILE ?= daily-wave
|
||||||
PYTEST_ARGS ?= -q -p no:cacheprovider
|
PYTEST_ARGS ?= -q -p no:cacheprovider
|
||||||
PYTHON_CHECK_PATHS := airflow/dags configs generator scripts superset tests
|
PYTHON_CHECK_PATHS := airflow/dags configs generator scripts superset tests
|
||||||
BASH_SCRIPTS := $(shell find scripts -type f -name '*.sh' | sort)
|
BASH_SCRIPTS := $(shell find scripts -type f -name '*.sh' | sort)
|
||||||
@@ -63,11 +65,11 @@ generated-history-chain-check:
|
|||||||
|
|
||||||
# Сгенерировать стартовую историю и сохранить портативный артефакт
|
# Сгенерировать стартовую историю и сохранить портативный артефакт
|
||||||
startup-history-export:
|
startup-history-export:
|
||||||
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/export_startup_history_artifact.sh
|
ARTIFACT="$(STARTUP_HISTORY_EXPORT_ARTIFACT)" COMPOSE_BIN="$(COMPOSE)" bash ./scripts/export_startup_history_artifact.sh
|
||||||
|
|
||||||
# Воспроизвести портативный артефакт стартовой истории в Kafka
|
# Воспроизвести портативный артефакт стартовой истории в Kafka
|
||||||
startup-history-import:
|
startup-history-import:
|
||||||
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/import_startup_history_artifact.sh
|
ARTIFACT="$(STARTUP_HISTORY_IMPORT_ARTIFACT)" COMPOSE_BIN="$(COMPOSE)" bash ./scripts/import_startup_history_artifact.sh
|
||||||
|
|
||||||
# Сверить ClickHouse после импорта с manifest артефакта
|
# Сверить ClickHouse после импорта с manifest артефакта
|
||||||
startup-history-check:
|
startup-history-check:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
витринами.
|
витринами.
|
||||||
|
|
||||||
Поток данных коротко:
|
Поток данных коротко:
|
||||||
- **стартовая история**: `generator backfill → Kafka → ClickHouse (STG) →
|
- **стартовая история**: `world_init → Kafka → ClickHouse (STG) →
|
||||||
batch STG → ODS → DDS → DM → Superset`.
|
batch STG → ODS → DDS → DM → Superset`.
|
||||||
- **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL
|
- **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL
|
||||||
→ Superset`.
|
→ Superset`.
|
||||||
@@ -31,20 +31,29 @@
|
|||||||
Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`,
|
Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`,
|
||||||
`git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки.
|
`git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки.
|
||||||
|
|
||||||
Для ручной работы поднимите стенд и создайте стартовую историю через Airflow:
|
Для ручной работы поднимите стенд:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
make ddl
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Откройте Airflow: `http://localhost:8080` (`admin/admin`). Сначала снимите
|
Дальше всё делается в Airflow: `http://localhost:8080` (`admin/admin`).
|
||||||
паузу с DAG `etl_pipeline` (переключатель слева от имени): на свежем стенде он
|
Список DAG'ов читается лесенкой сверху вниз; на свежем стенде все DAG'и
|
||||||
создаётся на паузе, и `backfill` откажется стартовать. Затем запустите
|
создаются на паузе, поэтому перед запуском снимайте паузу переключателем
|
||||||
`generator_control` с операцией `backfill`: DAG создаст стартовую историю,
|
слева от имени.
|
||||||
запустит ETL и выполнит `check`. `make up` не запускает live-генератор; live
|
|
||||||
включается отдельно командой `make generator-continue`.
|
1. `ddl_init` — снимите паузу и запустите: DAG создаст схему ClickHouse
|
||||||
|
(отдельная команда в терминале не нужна).
|
||||||
|
2. `etl_pipeline` — только снимите паузу: его запустит следующий шаг.
|
||||||
|
3. `world_init` — снимите паузу и запустите с пустой формой: DAG импортирует
|
||||||
|
эталонный мир, запустит ETL и сверит витрины.
|
||||||
|
4. `world_next_day` — когда захотите добавить ровно один модельный день,
|
||||||
|
запустите его с пустой формой. Расписание задано каждые 30 минут, но по
|
||||||
|
умолчанию DAG стоит на паузе.
|
||||||
|
|
||||||
|
`make up` не запускает live-генератор; live включается отдельно командой
|
||||||
|
`make generator-continue`.
|
||||||
|
|
||||||
После обновления репозитория снова выполните `make up`: команда пересобирает
|
После обновления репозитория снова выполните `make up`: команда пересобирает
|
||||||
Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд
|
Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд
|
||||||
@@ -53,20 +62,16 @@ Airflow-образ и подтягивает новые зависимости
|
|||||||
|
|
||||||
Для полностью автоматического чистого прогона из консоли есть команда — это
|
Для полностью автоматического чистого прогона из консоли есть команда — это
|
||||||
тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов
|
тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов
|
||||||
(DDL она применяет сама, отдельный `make ddl` не нужен):
|
(схему ClickHouse она применяет сама):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make generated-history-analytics
|
make generated-history-analytics
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию это быстрый профиль `ci`: 6 часов модельного времени. Историю на
|
По умолчанию используется учебный профиль `daily-wave`: 3 суток с суточной
|
||||||
2 суток с суточной волной можно прогнать одной командой. В live-продолжении
|
волной. В live-продолжении он идёт с ×60: модельные сутки проходят примерно
|
||||||
`daily-wave` идёт с ×60: модельные сутки проходят примерно за 24 настенные
|
за 24 настенные минуты. Плоский профиль `ci` на 6 часов остаётся служебным
|
||||||
минуты.
|
для автоматических тестов.
|
||||||
|
|
||||||
```bash
|
|
||||||
PROFILE=daily-wave make generated-history-analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
Разовую длительность можно задать без ручного расчёта правой границы:
|
Разовую длительность можно задать без ручного расчёта правой границы:
|
||||||
|
|
||||||
@@ -166,8 +171,10 @@ flowchart LR
|
|||||||
- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов,
|
- [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов,
|
||||||
мониторинг, частые проблемы.
|
мониторинг, частые проблемы.
|
||||||
- [Runbook стартовой истории](./docs/runbooks/startup-history.md) — экспорт,
|
- [Runbook стартовой истории](./docs/runbooks/startup-history.md) — экспорт,
|
||||||
импорт и live-продолжение из готового артефакта.
|
импорт эталонного мира из Git по умолчанию и live-продолжение.
|
||||||
- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять.
|
- [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять.
|
||||||
|
- [Реализм генератора](./docs/generator-realism.md) — что в потоке как в бою,
|
||||||
|
а что учебная условность.
|
||||||
- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом
|
- [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом
|
||||||
стенде.
|
стенде.
|
||||||
- [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд.
|
- [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Общие задачи Airflow для операций над миром стенда."""
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.models.dag import DagModel
|
||||||
|
from airflow.utils.session import provide_session
|
||||||
|
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
||||||
|
|
||||||
|
from clickstream_generator.airflow_control import (
|
||||||
|
assert_clickhouse_matches_manifest,
|
||||||
|
assert_live_generator_not_running_for_next_day,
|
||||||
|
assert_next_day_snapshot,
|
||||||
|
build_next_day_env,
|
||||||
|
load_manifest_from_kafka,
|
||||||
|
load_state_from_kafka,
|
||||||
|
run_next_day,
|
||||||
|
target_dag_trigger_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
@@ -10,36 +10,28 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from airflow import DAG
|
from airflow import DAG
|
||||||
from airflow.exceptions import AirflowException
|
|
||||||
from airflow.models.dag import DagModel
|
|
||||||
from airflow.models.param import Param
|
from airflow.models.param import Param
|
||||||
from airflow.operators.empty import EmptyOperator
|
from airflow.operators.empty import EmptyOperator
|
||||||
from airflow.operators.python import BranchPythonOperator, PythonOperator
|
from airflow.operators.python import BranchPythonOperator, PythonOperator
|
||||||
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
|
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
from airflow.utils.session import provide_session
|
|
||||||
from airflow.utils.trigger_rule import TriggerRule
|
from airflow.utils.trigger_rule import TriggerRule
|
||||||
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
|
||||||
|
|
||||||
from clickstream_generator.airflow_control import (
|
from clickstream_generator.airflow_control import (
|
||||||
KAFKA_BOOTSTRAP_SERVERS,
|
KAFKA_BOOTSTRAP_SERVERS,
|
||||||
assert_clickhouse_matches_manifest,
|
|
||||||
assert_expected_t_end,
|
|
||||||
assert_live_generator_not_running,
|
assert_live_generator_not_running,
|
||||||
assert_live_generator_not_running_for_next_day,
|
|
||||||
assert_next_day_snapshot,
|
|
||||||
assert_stand_clean,
|
assert_stand_clean,
|
||||||
build_control_env,
|
build_control_env,
|
||||||
build_next_day_env,
|
|
||||||
default_artifact_path,
|
default_artifact_path,
|
||||||
load_manifest_from_kafka,
|
|
||||||
load_state_from_kafka,
|
|
||||||
run_backfill,
|
run_backfill,
|
||||||
run_import,
|
run_import,
|
||||||
run_next_day,
|
|
||||||
target_dag_trigger_error,
|
|
||||||
validate_import_artifact,
|
validate_import_artifact,
|
||||||
)
|
)
|
||||||
from clickstream_generator.launch import PROFILES
|
from clickstream_generator.launch import PROFILES
|
||||||
|
from utils.startup_history_tasks import (
|
||||||
|
assert_target_dag_not_paused,
|
||||||
|
check_manifest_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
default_args = {
|
default_args = {
|
||||||
@@ -107,8 +99,6 @@ def choose_operation(**context) -> str:
|
|||||||
return "check_etl_not_paused_before_backfill"
|
return "check_etl_not_paused_before_backfill"
|
||||||
if operation == "import":
|
if operation == "import":
|
||||||
return "check_etl_not_paused_before_import"
|
return "check_etl_not_paused_before_import"
|
||||||
if operation == "next-day":
|
|
||||||
return "check_etl_not_paused_before_next_day"
|
|
||||||
if operation == "check":
|
if operation == "check":
|
||||||
return "check_only"
|
return "check_only"
|
||||||
raise ValueError(f"Неизвестная операция: {operation}")
|
raise ValueError(f"Неизвестная операция: {operation}")
|
||||||
@@ -151,56 +141,9 @@ def run_import_task(**context) -> None:
|
|||||||
run_import(env, artifact_path)
|
run_import(env, artifact_path)
|
||||||
|
|
||||||
|
|
||||||
def precheck_next_day(**context) -> None:
|
# Context7, Airflow 2.10.5: Param поддерживает enum.
|
||||||
"""Проверяет точку продолжения и готовит неизменные настройки мира."""
|
|
||||||
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(
|
with DAG(
|
||||||
dag_id="generator_control",
|
dag_id="world_init",
|
||||||
description="Пульт стартовой истории генератора",
|
description="Пульт стартовой истории генератора",
|
||||||
default_args=default_args,
|
default_args=default_args,
|
||||||
schedule=None,
|
schedule=None,
|
||||||
@@ -211,17 +154,17 @@ with DAG(
|
|||||||
tags=["generator", "startup-history"],
|
tags=["generator", "startup-history"],
|
||||||
params={
|
params={
|
||||||
"operation": Param(
|
"operation": Param(
|
||||||
"backfill",
|
"import",
|
||||||
type="string",
|
type="string",
|
||||||
enum=["backfill", "import", "next-day", "check"],
|
enum=["backfill", "import", "check"],
|
||||||
title="Операция",
|
title="Операция",
|
||||||
description=(
|
description=(
|
||||||
"Что сделать: создать историю, импортировать артефакт, "
|
"Что сделать: импортировать артефакт, создать историю "
|
||||||
"добавить следующий день или проверить витрины."
|
"или проверить витрины."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"profile": Param(
|
"profile": Param(
|
||||||
sorted(PROFILES)[0],
|
"daily-wave",
|
||||||
type="string",
|
type="string",
|
||||||
enum=sorted(PROFILES),
|
enum=sorted(PROFILES),
|
||||||
title="Профиль",
|
title="Профиль",
|
||||||
@@ -252,16 +195,7 @@ with DAG(
|
|||||||
title="Артефакт",
|
title="Артефакт",
|
||||||
description=(
|
description=(
|
||||||
"Backfill: куда сохранить файл; пусто — не сохранять. "
|
"Backfill: куда сохранить файл; пусто — не сохранять. "
|
||||||
"Import: что читать; пусто — путь по умолчанию в data."
|
"Import: что читать; пусто — эталонный мир из репозитория."
|
||||||
),
|
|
||||||
),
|
|
||||||
"expected_t_end": Param(
|
|
||||||
None,
|
|
||||||
type=["null", "string"],
|
|
||||||
title="Ожидаемая граница next-day",
|
|
||||||
description=(
|
|
||||||
"Необязательный model_t_end до запуска. Защищает от "
|
|
||||||
"повторной доливки того же дня."
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -289,15 +223,6 @@ with DAG(
|
|||||||
python_callable=run_import_task,
|
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(
|
check_etl_not_paused_before_backfill = PythonOperator(
|
||||||
task_id="check_etl_not_paused_before_backfill",
|
task_id="check_etl_not_paused_before_backfill",
|
||||||
python_callable=assert_target_dag_not_paused,
|
python_callable=assert_target_dag_not_paused,
|
||||||
@@ -308,12 +233,6 @@ with DAG(
|
|||||||
python_callable=assert_target_dag_not_paused,
|
python_callable=assert_target_dag_not_paused,
|
||||||
op_kwargs={"dag_id": ETL_DAG_ID},
|
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(
|
trigger_etl = TriggerDagRunOperator(
|
||||||
task_id="trigger_etl",
|
task_id="trigger_etl",
|
||||||
trigger_dag_id=ETL_DAG_ID,
|
trigger_dag_id=ETL_DAG_ID,
|
||||||
@@ -343,14 +262,11 @@ with DAG(
|
|||||||
route >> [
|
route >> [
|
||||||
check_etl_not_paused_before_backfill,
|
check_etl_not_paused_before_backfill,
|
||||||
check_etl_not_paused_before_import,
|
check_etl_not_paused_before_import,
|
||||||
check_etl_not_paused_before_next_day,
|
|
||||||
check_only,
|
check_only,
|
||||||
]
|
]
|
||||||
check_etl_not_paused_before_backfill >> precheck_backfill_task
|
check_etl_not_paused_before_backfill >> precheck_backfill_task
|
||||||
check_etl_not_paused_before_import >> precheck_import_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_backfill_task >> backfill_task >> trigger_etl
|
||||||
precheck_import_task >> import_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
|
trigger_etl >> check_after_etl >> done
|
||||||
check_only >> done
|
check_only >> done
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""DAG добавления одного модельного дня в мир стенда."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow import DAG
|
||||||
|
from airflow.operators.python import PythonOperator
|
||||||
|
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
|
|
||||||
|
from utils.startup_history_tasks import (
|
||||||
|
assert_target_dag_not_paused,
|
||||||
|
check_manifest_task,
|
||||||
|
precheck_next_day,
|
||||||
|
run_next_day_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ETL_DAG_ID = "etl_pipeline"
|
||||||
|
|
||||||
|
default_args = {
|
||||||
|
"owner": "airflow",
|
||||||
|
"depends_on_past": False,
|
||||||
|
"email_on_failure": False,
|
||||||
|
"email_on_retry": False,
|
||||||
|
"retries": 0,
|
||||||
|
"retry_delay": timedelta(minutes=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Context7, Airflow 2.10.5: schedule принимает cron-строку. Расписание задано
|
||||||
|
# заранее, но новый DAG остаётся на паузе до отдельного решения.
|
||||||
|
with DAG(
|
||||||
|
dag_id="world_next_day",
|
||||||
|
description="Добавление одного модельного дня в мир стенда",
|
||||||
|
default_args=default_args,
|
||||||
|
schedule="*/30 * * * *",
|
||||||
|
start_date=datetime(2024, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
is_paused_upon_creation=True,
|
||||||
|
tags=["generator", "startup-history"],
|
||||||
|
) as dag:
|
||||||
|
check_etl_not_paused = PythonOperator(
|
||||||
|
task_id="check_etl_not_paused",
|
||||||
|
python_callable=assert_target_dag_not_paused,
|
||||||
|
op_kwargs={"dag_id": ETL_DAG_ID},
|
||||||
|
)
|
||||||
|
precheck = PythonOperator(
|
||||||
|
task_id="precheck_next_day",
|
||||||
|
python_callable=precheck_next_day,
|
||||||
|
)
|
||||||
|
generate = PythonOperator(
|
||||||
|
task_id="run_next_day",
|
||||||
|
python_callable=run_next_day_task,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
check = PythonOperator(
|
||||||
|
task_id="check_after_etl",
|
||||||
|
python_callable=check_manifest_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
check_etl_not_paused >> precheck >> generate >> trigger_etl >> check
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Эталонный мир
|
||||||
|
|
||||||
|
Здесь хранится `reference-world.json.xz` — готовая история за три модельных дня.
|
||||||
|
Файл собирает сопровождающий проекта по инструкции в
|
||||||
|
[`docs/runbooks/startup-history.md`](../../docs/runbooks/startup-history.md).
|
||||||
|
Ожидаемый размер сжатого файла — около 32 МБ.
|
||||||
Binary file not shown.
+33
-17
@@ -27,8 +27,9 @@
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph AF["Airflow"]
|
subgraph AF["Airflow"]
|
||||||
DAG1["ddl_init"]
|
DAG1["ddl_init"]
|
||||||
DAG2["generator_control"]
|
DAG2["world_init"]
|
||||||
DAG3["etl_pipeline"]
|
DAG3["world_next_day"]
|
||||||
|
DAG4["etl_pipeline"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph GEN["Generator"]
|
subgraph GEN["Generator"]
|
||||||
@@ -57,7 +58,8 @@ flowchart LR
|
|||||||
V[витрины VIEW]
|
V[витрины VIEW]
|
||||||
end
|
end
|
||||||
|
|
||||||
DAG2 -->|startup-history backfill/import| K
|
DAG2 -->|import/backfill| K
|
||||||
|
DAG3 -->|следующий день| K
|
||||||
G -->|live после make generator-continue| K
|
G -->|live после make generator-continue| K
|
||||||
K -->|MV| S
|
K -->|MV| S
|
||||||
S -->|batch| O
|
S -->|batch| O
|
||||||
@@ -66,12 +68,13 @@ flowchart LR
|
|||||||
D1 & D2 -->|VIEW| V
|
D1 & D2 -->|VIEW| V
|
||||||
|
|
||||||
DAG1 -.->|DDL| STG & ODS & DDS & DM
|
DAG1 -.->|DDL| STG & ODS & DDS & DM
|
||||||
DAG3 -.->|batch| ODS & DDS
|
DAG4 -.->|batch| ODS & DDS
|
||||||
```
|
```
|
||||||
|
|
||||||
В учебном стенде предусмотрены два пути загрузки:
|
В учебном стенде предусмотрены два пути загрузки:
|
||||||
|
|
||||||
- `startup-history`: DAG `generator_control` создаёт или импортирует историю, добавляет следующий модельный день, запускает ETL и проверяет витрины;
|
- `startup-history`: `world_init` импортирует или создаёт историю, а
|
||||||
|
`world_next_day` добавляет один модельный день; оба запускают ETL и проверяют витрины;
|
||||||
- `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий.
|
- `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий.
|
||||||
|
|
||||||
### Слои и их назначение
|
### Слои и их назначение
|
||||||
@@ -80,8 +83,9 @@ flowchart LR
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph AF["Airflow"]
|
subgraph AF["Airflow"]
|
||||||
DAG1["ddl_init"]
|
DAG1["ddl_init"]
|
||||||
DAG2["generator_control"]
|
DAG2["world_init"]
|
||||||
DAG3["etl_pipeline"]
|
DAG3["world_next_day"]
|
||||||
|
DAG4["etl_pipeline"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph GEN["Generator"]
|
subgraph GEN["Generator"]
|
||||||
@@ -106,7 +110,8 @@ flowchart LR
|
|||||||
DM_T["VIEW"]
|
DM_T["VIEW"]
|
||||||
end
|
end
|
||||||
|
|
||||||
DAG2 -->|startup-history| KAFKA
|
DAG2 -->|стартовый мир| KAFKA
|
||||||
|
DAG3 -->|следующий день| KAFKA
|
||||||
G -->|live| KAFKA
|
G -->|live| KAFKA
|
||||||
KAFKA -->|MV| STG_T
|
KAFKA -->|MV| STG_T
|
||||||
STG_T -->|batch| ODS_T
|
STG_T -->|batch| ODS_T
|
||||||
@@ -114,7 +119,7 @@ flowchart LR
|
|||||||
ODS_T -.->|ошибки| DQ
|
ODS_T -.->|ошибки| DQ
|
||||||
|
|
||||||
DAG1 -.->|DDL| L1 & L2 & L3 & L4
|
DAG1 -.->|DDL| L1 & L2 & L3 & L4
|
||||||
DAG3 -.->|batch| ODS_T & DDS_T
|
DAG4 -.->|batch| ODS_T & DDS_T
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -387,10 +392,12 @@ sequenceDiagram
|
|||||||
CH-->>User: ✅ Структура БД создана
|
CH-->>User: ✅ Структура БД создана
|
||||||
|
|
||||||
alt Startup-history режим
|
alt Startup-history режим
|
||||||
User->>Airflow: Trigger generator_control (backfill/import)
|
User->>Airflow: Trigger world_init с пустой формой
|
||||||
Airflow->>K: события стартовой истории
|
Airflow->>K: события стартовой истории
|
||||||
Airflow->>Airflow: trigger etl_pipeline + check
|
Airflow->>Airflow: trigger etl_pipeline + check
|
||||||
K-->>User: ✅ История в Kafka и витринах
|
K-->>User: ✅ История в Kafka и витринах
|
||||||
|
User->>Airflow: Trigger world_next_day с пустой формой
|
||||||
|
Airflow->>K: события следующего модельного дня
|
||||||
else Live режим
|
else Live режим
|
||||||
User->>Compose: make generator-continue
|
User->>Compose: make generator-continue
|
||||||
loop каждые 1-10 секунд
|
loop каждые 1-10 секунд
|
||||||
@@ -637,7 +644,8 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# airflow/dags/ddl_init_dag.py — создание баз/таблиц
|
# airflow/dags/ddl_init_dag.py — создание баз/таблиц
|
||||||
# airflow/dags/generator_control_dag.py — backfill/import/next-day/check стартовой истории
|
# airflow/dags/world_init_dag.py — import/backfill/check стартового мира
|
||||||
|
# airflow/dags/world_next_day_dag.py — добавление одного модельного дня
|
||||||
# airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM)
|
# airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM)
|
||||||
# airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур
|
# airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур
|
||||||
|
|
||||||
@@ -645,22 +653,30 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
|
|||||||
# - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator;
|
# - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator;
|
||||||
# - SQL-файлы вызываются по фиксированным путям;
|
# - SQL-файлы вызываются по фиксированным путям;
|
||||||
# - загрузка может идти двумя путями:
|
# - загрузка может идти двумя путями:
|
||||||
# 1) startup-history через DAG `generator_control`;
|
# 1) стартовый мир через `world_init` и рост через `world_next_day`;
|
||||||
# 2) live-поток через явный `make generator-continue`.
|
# 2) live-поток через явный `make generator-continue`.
|
||||||
#
|
#
|
||||||
# Базовый demo-сценарий:
|
# Базовый demo-сценарий:
|
||||||
# ddl_init -> generator_control(backfill/import) -> etl_pipeline -> check
|
# ddl_init -> world_init(import) -> etl_pipeline -> check
|
||||||
# Расширенный учебный сценарий:
|
# Расширенный учебный сценарий:
|
||||||
# make generator-continue + периодический etl_pipeline
|
# make generator-continue + периодический etl_pipeline
|
||||||
```
|
```
|
||||||
|
|
||||||
**DAG `generator_control`**:
|
**DAG `world_init`**:
|
||||||
- `backfill`: создаёт стартовую историю через генератор
|
- `import` по умолчанию импортирует портативный артефакт стартового мира
|
||||||
- `import`: импортирует портативный артефакт стартовой истории
|
- `backfill` создаёт стартовую историю через генератор
|
||||||
- `next-day`: пакетно добавляет следующий модельный день от текущего слепка мира
|
|
||||||
- `check`: сверяет ClickHouse с manifest стартовой истории
|
- `check`: сверяет ClickHouse с manifest стартовой истории
|
||||||
- После `backfill` и `import` запускает `etl_pipeline` с `full_refresh`
|
- После `backfill` и `import` запускает `etl_pipeline` с `full_refresh`
|
||||||
|
|
||||||
|
**DAG `world_next_day`** без параметров пакетно добавляет следующий модельный
|
||||||
|
день, запускает `etl_pipeline` с `full_refresh` и сверяет manifest. У него задано
|
||||||
|
расписание каждые 30 минут, но DAG создаётся на паузе и не выполняет пропущенные
|
||||||
|
интервалы. Счётчики manifest продолжаются из compact-топиков state/manifest и
|
||||||
|
обновляются событиями нового дня; старые data-топики Kafka не перечитываются.
|
||||||
|
Точные множества идентификаторов лежат отдельными неизменяемыми фрагментами, а
|
||||||
|
основная запись manifest хранит только числа и ссылку на их цепочку. Полная
|
||||||
|
пересборка ETL при этом сохраняется намеренно.
|
||||||
|
|
||||||
**Подключение к ClickHouse:**
|
**Подключение к ClickHouse:**
|
||||||
- Connection: `clickhouse_default`
|
- Connection: `clickhouse_default`
|
||||||
- URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin)
|
- URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin)
|
||||||
|
|||||||
+48
-46
@@ -48,44 +48,47 @@ volumes или live-генератором. Для стыка backfill/live от
|
|||||||
|
|
||||||
## Airflow DAGs
|
## Airflow DAGs
|
||||||
|
|
||||||
Штатный ручной путь начинается с `generator_control`: чистый стенд получает
|
Штатный ручной путь начинается с `world_init`: пустая форма импортирует
|
||||||
стартовую историю генератора, затем этот же DAG запускает ETL и проверку.
|
эталонный мир, затем этот же DAG запускает ETL и проверку.
|
||||||
`kafka_load` остаётся для экспериментов и совместимости учебного стенда.
|
`kafka_load` остаётся для экспериментов и совместимости учебного стенда.
|
||||||
|
|
||||||
### `generator_control`
|
### `world_init`
|
||||||
|
|
||||||
- Запуск: ручной (`Trigger DAG`).
|
- Запуск: ручной (`Trigger DAG`).
|
||||||
- Назначение: пульт стартовой истории генератора.
|
- Назначение: импорт или служебная сборка стартового мира.
|
||||||
- Операции:
|
- Операции:
|
||||||
|
- `import` — операция по умолчанию: импортировать портативный артефакт, затем
|
||||||
|
запустить `etl_pipeline` и дождаться `success`;
|
||||||
- `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и
|
- `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и
|
||||||
дождаться `success`;
|
дождаться `success`;
|
||||||
- `import` — импортировать портативный артефакт, затем запустить
|
|
||||||
`etl_pipeline` и дождаться `success`;
|
|
||||||
- `next-day` — восстановить мир из state, добавить 24 модельных часа,
|
|
||||||
затем запустить `etl_pipeline` и дождаться `success`;
|
|
||||||
- `check` — сверить ClickHouse с manifest из Kafka.
|
- `check` — сверить ClickHouse с manifest из Kafka.
|
||||||
- Параметры:
|
- Параметры:
|
||||||
- `operation` (`backfill` / `import` / `next-day` / `check`);
|
- `operation` (`import` / `backfill` / `check`);
|
||||||
- `profile` — список берётся из `PROFILES` генератора;
|
- `profile` — список берётся из `PROFILES` генератора;
|
||||||
- `duration` — `6h`, `2d` и т.п.; пусто означает длительность профиля;
|
- `duration` — `6h`, `2d` и т.п.; пусто означает длительность профиля;
|
||||||
- `seed`, `model_time_speed` — необязательные переопределения мира;
|
- `seed`, `model_time_speed` — необязательные переопределения мира;
|
||||||
- `artifact_path` — для `backfill` путь сохранения, для `import` путь чтения;
|
- `artifact_path` — для `backfill` путь сохранения; для `import` путь чтения.
|
||||||
- `expected_t_end` — необязательная ожидаемая граница перед `next-day`.
|
При пустом поле импортируется эталонный мир из репозитория.
|
||||||
При расхождении запуск показывает ожидаемое и фактическое значения.
|
|
||||||
|
|
||||||
Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые
|
Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые
|
||||||
`stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue`
|
`stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue`
|
||||||
в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через
|
в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через
|
||||||
`make generator-continue`.
|
`make generator-continue`.
|
||||||
|
|
||||||
`next-day` работает на непустом стенде и не использует проверку чистоты.
|
### `world_next_day`
|
||||||
Перед записью пульт требует manifest, state ровно на его `T_end` и остановленный
|
|
||||||
live-генератор. Настройки мира берутся из manifest; поля `profile`, `duration`,
|
- Запуск: вручную с пустой формой.
|
||||||
`seed` и `model_time_speed` формы для этой операции не применяются. Один запуск
|
- Параметров нет.
|
||||||
добавляет полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница
|
- Один запуск восстанавливает мир из state, добавляет 24 модельных часа,
|
||||||
появляется в `boundaries`; старый manifest без поля читается как `[T0, T_end]`.
|
запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest.
|
||||||
Расписание остаётся выключенным (`schedule=None`), а `max_active_runs=1` не даёт
|
- Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`.
|
||||||
двум доливкам выполняться параллельно.
|
- `max_active_runs=1` не даёт двум доливкам выполняться параллельно.
|
||||||
|
|
||||||
|
`world_next_day` работает на непустом стенде и не использует проверку чистоты.
|
||||||
|
Перед записью он требует manifest, state ровно на его `T_end` и остановленный
|
||||||
|
live-генератор. Настройки мира берутся из manifest. Один запуск добавляет
|
||||||
|
полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница появляется в
|
||||||
|
`boundaries`; старый manifest без поля читается как `[T0, T_end]`.
|
||||||
|
|
||||||
После двух доливок проверьте завершённые стыки:
|
После двух доливок проверьте завершённые стыки:
|
||||||
|
|
||||||
@@ -97,18 +100,21 @@ make generated-history-chain-check
|
|||||||
смену browser/referer/utm внутри переходящих визитов. Она не меняет
|
смену browser/referer/utm внутри переходящих визитов. Она не меняет
|
||||||
`make generated-history-runtime-check` для стыка backfill/live.
|
`make generated-history-runtime-check` для стыка backfill/live.
|
||||||
|
|
||||||
Точка фиксации `next-day` — новый manifest. Порядок записи: data-топики, state,
|
Результат запуска `world_next_day` фиксируется новым manifest. Порядок записи:
|
||||||
manifest. Автоматического отката нет. Если запуск упал до публикации manifest,
|
data-топики, state, manifest. Автоматического отката нет. Если запуск упал до
|
||||||
|
публикации manifest,
|
||||||
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
|
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
|
||||||
последний исправный портативный артефакт, затем повторите `next-day`.
|
последний исправный портативный артефакт, затем повторите запуск `world_next_day`.
|
||||||
|
|
||||||
Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей
|
`next-day` не перечитывает старые data-топики Kafka. Основная запись manifest
|
||||||
доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти
|
хранит суммы, катящуюся контрольную сумму и SHA-256-ссылку на цепочку точных
|
||||||
каждой доливки растут вместе с историей. Стандартный срок хранения Kafka тоже
|
множеств. Сами `click_id` и `user_domain_id` разбиты на небольшие неизменяемые
|
||||||
ограничивает долгую работу стенда. Для ручного учебного цикла запускайте
|
фрагменты в отдельном compact-топике. Новый день записывает только новый
|
||||||
соседние дни без долгих пауз. Перед включением расписания отдельная задача
|
фрагмент, поэтому размер одной записи не растёт с возрастом мира, а загрузка
|
||||||
должна выбрать одно из решений: бессрочное хранение data-топиков или новый
|
основного manifest не сканирует всю цепочку.
|
||||||
формат накопительного состояния manifest.
|
State ссылается на основную запись manifest, чтобы две точки продолжения нельзя
|
||||||
|
было случайно смешать. Если локальный state создан старым кодом и раздела в нём
|
||||||
|
нет, повторно запустите `world_init` с операцией `import` на чистом стенде.
|
||||||
|
|
||||||
Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если
|
Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если
|
||||||
стоит, задача падает сразу с подсказкой снять паузу в UI или командой:
|
стоит, задача падает сразу с подсказкой снять паузу в UI или командой:
|
||||||
@@ -204,7 +210,7 @@ make generator-logs
|
|||||||
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
|
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
|
||||||
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
|
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
|
||||||
| `GEN_RUN_MODE` | Режим: `live`, `backfill` или `next-day` | `live` |
|
| `GEN_RUN_MODE` | Режим: `live`, `backfill` или `next-day` | `live` |
|
||||||
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` |
|
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `daily-wave` |
|
||||||
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто |
|
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто |
|
||||||
| `GEN_STATE_ENABLED` | Сохранять state v3 между рестартами | `true` |
|
| `GEN_STATE_ENABLED` | Сохранять state v3 между рестартами | `true` |
|
||||||
| `GEN_STATE_RESET` | Сбросить state при старте | `false` |
|
| `GEN_STATE_RESET` | Сбросить state при старте | `false` |
|
||||||
@@ -252,7 +258,7 @@ CHECK_LIVE_SEAM=1 GEN_LIVE_CHECK_MINUTES=10 make generated-history-check
|
|||||||
```
|
```
|
||||||
|
|
||||||
Для commit gate issue 17 есть короткий runtime-путь без полного `daily-wave` на
|
Для commit gate issue 17 есть короткий runtime-путь без полного `daily-wave` на
|
||||||
2 суток и без Superset UI:
|
3 суток и без Superset UI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make generated-history-runtime-check
|
make generated-history-runtime-check
|
||||||
@@ -270,14 +276,10 @@ make generated-history-runtime-check
|
|||||||
LIVE_SECONDS=45 WAIT_STG_SECONDS=10 make generated-history-runtime-check
|
LIVE_SECONDS=45 WAIT_STG_SECONDS=10 make generated-history-runtime-check
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию команда использует быстрый профиль `ci`: 6 часов модельного
|
По умолчанию команда использует учебный профиль `daily-wave`: 3 суток с
|
||||||
времени. Историю на 2 суток с суточной волной можно получить одной командой.
|
суточной волной. В live-продолжении он идёт с ×60 и тикает раз в секунду,
|
||||||
В live-продолжении `daily-wave` идёт с ×60 и тикает раз в секунду, поэтому
|
поэтому модельные сутки проходят примерно за 24 настенные минуты. Плоский
|
||||||
модельные сутки проходят примерно за 24 настенные минуты:
|
профиль `ci` на 6 часов остаётся служебным для автоматических тестов.
|
||||||
|
|
||||||
```bash
|
|
||||||
PROFILE=daily-wave make generated-history-analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
|
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
|
||||||
|
|
||||||
@@ -293,8 +295,8 @@ GEN_HISTORY_DURATION=2d make generated-history-analytics
|
|||||||
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.
|
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.
|
||||||
|
|
||||||
Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day`
|
Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day`
|
||||||
добавляет одну границу, сдвигает `model_t_end` и пересчитывает накопительные
|
добавляет одну границу, сдвигает `model_t_end` и дополняет накопительные
|
||||||
счётчики и контрольные суммы по всей истории.
|
счётчики только событиями нового дня. Полной перечитки Kafka в этом пути нет.
|
||||||
|
|
||||||
Если историю нужно сохранить в файл и восстановить на чистом стенде без новой
|
Если историю нужно сохранить в файл и восстановить на чистом стенде без новой
|
||||||
генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md).
|
генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md).
|
||||||
@@ -306,7 +308,7 @@ Kafka-топики данных, state и manifest генератора. `make g
|
|||||||
Ручной backfill без всего аналитического контура:
|
Ручной backfill без всего аналитического контура:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PROFILE=daily-wave make generator-backfill
|
make generator-backfill
|
||||||
```
|
```
|
||||||
|
|
||||||
Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые.
|
Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые.
|
||||||
@@ -336,7 +338,7 @@ Live-продолжение стартует с этого state. Использ
|
|||||||
чистом стенде: она тоже проверяет, что Kafka data-топики и STG пустые.
|
чистом стенде: она тоже проверяет, что Kafka data-топики и STG пустые.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PROFILE=daily-wave make generator-continue
|
make generator-continue
|
||||||
|
|
||||||
sleep 5
|
sleep 5
|
||||||
make generator-down
|
make generator-down
|
||||||
@@ -692,8 +694,8 @@ make generated-history-runtime-check
|
|||||||
## Быстрые проверки
|
## Быстрые проверки
|
||||||
|
|
||||||
- Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`.
|
- Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`.
|
||||||
- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `generator_control`,
|
- Airflow UI: `http://localhost:8080` показывает лестницу `ddl_init` →
|
||||||
`kafka_load`, `etl_pipeline`; основной ручной пульт генератора — `generator_control`.
|
`world_init` → `world_next_day`, а также `kafka_load` и `etl_pipeline`.
|
||||||
- BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате.
|
- BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@
|
|||||||
### Airflow (ручной и учебный путь запуска)
|
### Airflow (ручной и учебный путь запуска)
|
||||||
|
|
||||||
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
|
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
|
||||||
- `airflow/dags/generator_control_dag.py` — Airflow-пульт стартовой истории: backfill/import/next-day/check
|
- `airflow/dags/world_init_dag.py` — импорт или служебная сборка стартового мира и проверка витрин
|
||||||
|
- `airflow/dags/world_next_day_dag.py` — беспараметрное добавление одного модельного дня
|
||||||
|
- `airflow/dags/utils/startup_history_tasks.py` — общие задачи DAG для роста и проверки мира
|
||||||
- `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики
|
- `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики
|
||||||
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
|
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
|
||||||
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
|
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
|
||||||
|
|||||||
+4
-4
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
## Общие принципы
|
## Общие принципы
|
||||||
|
|
||||||
- По умолчанию используем быстрый профиль стартовой истории (`PROFILE=ci`).
|
- Для smoke и CI явно задаём служебный профиль `ci`.
|
||||||
- Полный прогон выполняем отдельно через `PROFILE=daily-wave`.
|
- Полный прогон выполняем отдельно через `PROFILE=daily-wave`.
|
||||||
- Основной ручной путь запуска — через Airflow DAG `generator_control`.
|
- Основной ручной путь запуска — через Airflow DAG `world_init` с пустой формой.
|
||||||
- Консольный чистый прогон `make generated-history-analytics` остаётся коротким
|
- Консольный чистый прогон `make generated-history-analytics` остаётся коротким
|
||||||
повторяемым сценарием для smoke и CI.
|
повторяемым сценарием для smoke и CI.
|
||||||
- Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга.
|
- Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга.
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Чистый быстрый прогон: backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset
|
# Чистый быстрый прогон: backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset
|
||||||
make generated-history-analytics
|
PROFILE=ci make generated-history-analytics
|
||||||
|
|
||||||
# Поднять остальные UI-сервисы после чистого прогона
|
# Поднять остальные UI-сервисы после чистого прогона
|
||||||
make up
|
make up
|
||||||
@@ -124,7 +124,7 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie
|
|||||||
### B.1 Полная стартовая история и ETL
|
### B.1 Полная стартовая история и ETL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# История на 2 суток с видимой суточной волной
|
# История на 3 суток с видимой суточной волной
|
||||||
PROFILE=daily-wave make generated-history-analytics
|
PROFILE=daily-wave make generated-history-analytics
|
||||||
make up
|
make up
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,24 +1,63 @@
|
|||||||
# Issue tracker: локальный markdown
|
# Issue tracker: GitHub
|
||||||
|
|
||||||
Задачи и PRD этого репозитория живут markdown-файлами в `.scratch/`. GitHub Issues
|
Задачи этого репозитория живут в GitHub Issues. Все операции — через CLI `gh`;
|
||||||
не используются — не вызывать `gh issue create`. Файлы в `.scratch/` коммитятся
|
репозиторий `gh` определяет сам по `git remote`.
|
||||||
(`.scratch/` не в `.gitignore`).
|
|
||||||
|
|
||||||
## Соглашения
|
- **Создать issue**: `gh issue create --title "..." --body "..."` (многострочное
|
||||||
|
тело — heredoc'ом).
|
||||||
|
- **Прочитать issue**: `gh issue view <номер> --comments`.
|
||||||
|
- **Список**: `gh issue list --state open --json number,title,labels` с нужными
|
||||||
|
фильтрами `--label` / `--state`.
|
||||||
|
- **Комментарий**: `gh issue comment <номер> --body "..."`.
|
||||||
|
- **Метки**: `gh issue edit <номер> --add-label "..."` / `--remove-label "..."`.
|
||||||
|
- **Закрыть**: `gh issue close <номер> --comment "..."`.
|
||||||
|
|
||||||
- Одна фича — один каталог: `.scratch/<feature-slug>/`
|
## Спека — источник истины
|
||||||
- PRD фичи — `.scratch/<feature-slug>/PRD.md` (по сути транзиентная спека: снимок
|
|
||||||
понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`)
|
- Спецификация фичи — файл в `docs/specs/`, версионируется с кодом.
|
||||||
- Задачи реализации — `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, нумерация с `01`
|
- Корневой issue фичи — **тонкий**: ссылка на спеку + чек-лист дочерних issues
|
||||||
- Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`)
|
(`- [ ] #NN`). Содержание спеки в issue не дублируется — истина одна, в git.
|
||||||
- Завершённая задача — `Status: done`: локальный аналог «закрыть issue» в GitHub.
|
- Дочерние issues — полноценные самодостаточные постановки: цель, критерии
|
||||||
Это состояние жизненного цикла трекера, а не роль триажа (в `triage-labels.md` его нет)
|
приёмки чекбоксами, границы («что трогать нельзя»), «сначала прочитать»,
|
||||||
- Комментарии и история — в конец файла под заголовком `## Comments`
|
команды проверки.
|
||||||
|
- Итоговые резолюции и решения — в спеку/ADR тем же PR; issue — рабочая
|
||||||
|
переписка, она не обязана переживать фичу.
|
||||||
|
|
||||||
## Когда скилл говорит «опубликовать в issue tracker»
|
## Когда скилл говорит «опубликовать в issue tracker»
|
||||||
|
|
||||||
Создать файл в `.scratch/<feature-slug>/` (каталог создать при необходимости).
|
Создать GitHub issue.
|
||||||
|
|
||||||
## Когда скилл говорит «достать тикет»
|
## Когда скилл говорит «достать тикет»
|
||||||
|
|
||||||
Прочитать файл по указанному пути. Обычно путь или номер задачи передаёт пользователь.
|
`gh issue view <номер> --comments`.
|
||||||
|
|
||||||
|
## PR как поверхность триажа
|
||||||
|
|
||||||
|
**Нет** — одиночный учебный репозиторий, внешних PR не ждём. (Если включить —
|
||||||
|
`/triage` начнёт гонять PR через те же метки и состояния командами `gh pr ...`.)
|
||||||
|
|
||||||
|
## Wayfinding-операции
|
||||||
|
|
||||||
|
Используются `/wayfinder`. Карта — один issue, тикеты — дочерние issues.
|
||||||
|
|
||||||
|
- **Карта**: issue с меткой `wayfinder:map` (Notes / Decisions-so-far / Fog в теле).
|
||||||
|
- **Дочерний тикет**: sub-issue карты (`gh api` на endpoint sub-issues); если
|
||||||
|
sub-issues недоступны — пункт task-list в теле карты + `Part of #<map>` в
|
||||||
|
начале тела тикета. Метки: `wayfinder:<type>` (`research`/`prototype`/
|
||||||
|
`grilling`/`task`).
|
||||||
|
- **Блокировки**: нативные issue dependencies —
|
||||||
|
`gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<db-id блокера>`
|
||||||
|
(`<db-id>` — числовой database id: `gh api repos/<owner>/<repo>/issues/<n> --jq .id`,
|
||||||
|
не `#номер`). Fallback — строка `Blocked by: #<n>` в начале тела. Тикет
|
||||||
|
разблокирован, когда все блокеры закрыты.
|
||||||
|
- **Фронтир**: открытые дети карты минус заблокированные и назначенные; первый
|
||||||
|
в порядке карты.
|
||||||
|
- **Взять в работу**: `gh issue edit <n> --add-assignee @me`.
|
||||||
|
- **Закрыть**: комментарий с ответом, `gh issue close`, указатель на контекст —
|
||||||
|
в Decisions-so-far карты.
|
||||||
|
|
||||||
|
## Архив
|
||||||
|
|
||||||
|
До 2026-07-19 задачи велись markdown-файлами в `.scratch/<feature>/issues/`
|
||||||
|
(фичи `data-generator` и `generator-model-time-startup-history`, задачи 01–21).
|
||||||
|
Не мигрированы; доступны в истории git — срез `0e312b3`.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Triage labels
|
# Triage labels
|
||||||
|
|
||||||
Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены со
|
Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены с
|
||||||
строками, которые реально используются в этом репозитории (в строке `Status:` файла задачи).
|
метками GitHub Issues этого репозитория.
|
||||||
|
|
||||||
| Роль в mattpocock/skills | Строка у нас | Значение |
|
| Роль в mattpocock/skills | Метка GitHub | Значение |
|
||||||
| ------------------------ | ----------------- | ---------------------------------------------- |
|
| ------------------------ | ----------------- | ---------------------------------------------- |
|
||||||
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить задачу |
|
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить задачу |
|
||||||
| `needs-info` | `needs-info` | Ждём от репортёра дополнительную информацию |
|
| `needs-info` | `needs-info` | Ждём от репортёра дополнительную информацию |
|
||||||
@@ -11,4 +11,5 @@
|
|||||||
| `ready-for-human` | `ready-for-human` | Нужна ручная реализация человеком |
|
| `ready-for-human` | `ready-for-human` | Нужна ручная реализация человеком |
|
||||||
| `wontfix` | `wontfix` | Не будет сделано |
|
| `wontfix` | `wontfix` | Не будет сделано |
|
||||||
|
|
||||||
Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли).
|
Правый столбец можно поменять под свою лексику. Сейчас — дефолт (метка = имя
|
||||||
|
роли); метки созданы в репозитории GitHub.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Насколько генератор похож на настоящий кликстрим
|
||||||
|
|
||||||
|
Стенд генерирует поток событий интернет-магазина. Этот документ честно
|
||||||
|
отвечает: что здесь как в бою, а что — учебная условность. Он нужен,
|
||||||
|
чтобы менти понимал, какой опыт со стенда переносится на реальную
|
||||||
|
работу напрямую, а какой требует поправки на упрощение.
|
||||||
|
|
||||||
|
Дата оценки: 2026-07-22 (по коду `generator/src/clickstream_generator/`).
|
||||||
|
|
||||||
|
## Что близко к бою
|
||||||
|
|
||||||
|
**Схема событий.** Поля узнаваемы для любого продуктового трекера:
|
||||||
|
идентификаторы события и визита (`event_id`, `click_id`), страница и
|
||||||
|
UTM-атрибуция (`utm_source/medium/campaign/content`, `referer_medium`),
|
||||||
|
браузер и user agent, устройство и ОС, гео с координатами и IP,
|
||||||
|
идентификатор пользователя. Разговоры об атрибуции, сессиях и
|
||||||
|
размерностях по этому опыту — честные.
|
||||||
|
|
||||||
|
**Воронка.** Путь `/home → /product → /cart → /payment → /confirmation`
|
||||||
|
задан матрицей переходов с реалистичными недоходами и возвратами:
|
||||||
|
в корзину уходит ~27% с товара, из корзины до оплаты — 42%. Конверсию
|
||||||
|
и отвал по шагам менти считает на данных, которые ведут себя как живые.
|
||||||
|
|
||||||
|
**Сессии.** Визит — в среднем ~10 событий; паузы между событиями
|
||||||
|
случайны (логнормальное распределение, медиана ~20 секунд) с потолком
|
||||||
|
29 минут — фактически классический 30-минутный таймаут сессии из
|
||||||
|
веб-аналитики.
|
||||||
|
|
||||||
|
**Суточная волна.** Интенсивность зависит от часа: рабочие часы ×1.2,
|
||||||
|
ночь ×0.7, поверх — случайный разброс. Сравнение «день к дню» и
|
||||||
|
разговор о сезонности внутри суток — по-настоящему.
|
||||||
|
|
||||||
|
**Инженерная обвязка.** JSON-события в Kafka, слои STG/ODS/DDS/DM,
|
||||||
|
«грязные» записи не валят пайплайн (уходят в таблицы ошибок ODS),
|
||||||
|
manifest с контрольными суммами и воспроизводимый эталонный мир.
|
||||||
|
Это боевые практики, а не учебные упрощения.
|
||||||
|
|
||||||
|
## Что упрощено
|
||||||
|
|
||||||
|
**Четыре топика вместо одного события.** Реальные трекеры (Snowplow,
|
||||||
|
Segment, Amplitude) отдают одно широкое событие со всеми атрибутами.
|
||||||
|
Здесь событие разрезано на четыре потока (browser, location, device,
|
||||||
|
geo), которые надо склеивать по ключам. Как модель доставки кликстрима
|
||||||
|
это условность; как упражнение — честная интеграция нескольких
|
||||||
|
источников, обычная работа дата-инженера.
|
||||||
|
|
||||||
|
**Только просмотры страниц.** Нет событий `add_to_cart` и `purchase`
|
||||||
|
с суммой заказа — воронка выводится из URL. Поэтому нет выручки и
|
||||||
|
GMV-метрик, которые в бою спрашивают в первую очередь.
|
||||||
|
|
||||||
|
**Пользователи клонируются из словаря.** Новые визиты собираются из
|
||||||
|
небольшого набора заготовок: количество разных пользователей, устройств
|
||||||
|
и гео ограничено. У каждого визита есть email пользователя — в бою
|
||||||
|
большинство трафика анонимно, и склейка идентичностей (identity
|
||||||
|
stitching) — отдельная большая задача, которой стенд не показывает.
|
||||||
|
|
||||||
|
**Нет «грязи» реального мира.** Ни ботов, ни дублей событий, ни
|
||||||
|
опоздавших мобильных батчей, ни расхождения часов клиента и коллектора
|
||||||
|
(в бою у события минимум две метки времени). Чистка и дедупликация —
|
||||||
|
только в теории.
|
||||||
|
|
||||||
|
**Масштаб демо.** Объёмы на порядки меньше боевых: вопросы
|
||||||
|
партиционирования под нагрузкой, ретеншена и стоимости хранения стенд
|
||||||
|
не поднимает.
|
||||||
|
|
||||||
|
## Что это значит для обучения
|
||||||
|
|
||||||
|
Можно честно говорить «делал руками»: приём потока из Kafka в
|
||||||
|
ClickHouse, слоистое DWH, сессии и воронка, UTM-атрибуция, сверка
|
||||||
|
контрольных чисел, воспроизводимость мира, инкрементальность против
|
||||||
|
полной перезагрузки.
|
||||||
|
|
||||||
|
Требует теории поверх стенда: формат доставки у реальных трекеров,
|
||||||
|
выручка и GMV, identity stitching, боты и дедупликация, поведение
|
||||||
|
под боевой нагрузкой.
|
||||||
|
|
||||||
|
Развитие стенда в сторону боевого реализма обсуждается в трекере —
|
||||||
|
карта «Боевой реализм стенда».
|
||||||
@@ -29,27 +29,64 @@
|
|||||||
| Профиль | Для чего | Длительность | Live-ход |
|
| Профиль | Для чего | Длительность | Live-ход |
|
||||||
|---------|----------|--------------|----------|
|
|---------|----------|--------------|----------|
|
||||||
| `ci` | Быстрая проверка и CI | `6h` | ×1, тик 60 с |
|
| `ci` | Быстрая проверка и CI | `6h` | ×1, тик 60 с |
|
||||||
| `daily-wave` | История с видимой суточной волной | `2d` | ×60, тик 1 с |
|
| `daily-wave` | История с видимой суточной волной | `3d` | ×60, тик 1 с |
|
||||||
|
|
||||||
Длительность можно переопределить через `GEN_HISTORY_DURATION`, например `2d`.
|
Длительность можно переопределить через `GEN_HISTORY_DURATION`, например `2d`.
|
||||||
Команда сама считает `GEN_MODEL_T_END` от `GEN_MODEL_T0`.
|
Команда сама считает `GEN_MODEL_T_END` от `GEN_MODEL_T0`.
|
||||||
|
|
||||||
|
## Эталонный мир
|
||||||
|
|
||||||
|
В репозитории хранится готовый мир за три модельных дня:
|
||||||
|
`data/startup_history/reference-world.json.xz`. Это обычный JSON-артефакт,
|
||||||
|
сжатый xz. Его добавляют прямо в Git, без Git LFS.
|
||||||
|
|
||||||
|
Сопровождающий проекта собирает файл одним запуском из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ARTIFACT="$PWD/data/startup_history/reference-world.json.xz" \
|
||||||
|
make startup-history-export
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда выполняет backfill с профилем `daily-wave` и сразу пишет xz-файл.
|
||||||
|
Несжатый JSON занимает около 850 МБ, сжатый файл — около 32 МБ. После проверки
|
||||||
|
файл можно добавить обычной командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add data/startup_history/reference-world.json.xz
|
||||||
|
```
|
||||||
|
|
||||||
|
При импорте DAG дважды читает и распаковывает артефакт: во время предпроверки и
|
||||||
|
перед записью в Kafka. Это увеличивает время импорта, но не меняет результат.
|
||||||
|
|
||||||
|
Менти запускает `world_init` с пустой формой. Тогда читается эталонный мир из
|
||||||
|
репозитория. Следующий модельный день добавляет отдельный беспараметрный DAG
|
||||||
|
`world_next_day`. Из консоли тот же импорт запускается без указания пути:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make startup-history-import
|
||||||
|
```
|
||||||
|
|
||||||
## Пульт в Airflow
|
## Пульт в Airflow
|
||||||
|
|
||||||
Основной ручной путь — DAG `generator_control` в Airflow UI:
|
Основной учебный путь в Airflow UI:
|
||||||
|
|
||||||
1. Поднимите стенд: `make up`.
|
1. Поднимите стенд: `make up`.
|
||||||
2. Если DDL ещё не применён, запустите `ddl_init`.
|
2. Если DDL ещё не применён, запустите `ddl_init`.
|
||||||
3. Снимите паузу с `etl_pipeline`, если он ещё paused:
|
3. Снимите паузу с `etl_pipeline`, если он ещё paused:
|
||||||
`docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`.
|
`docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`.
|
||||||
4. Откройте `generator_control` и выберите `operation`.
|
4. Запустите `world_init` с пустой формой. По умолчанию он импортирует эталонный мир.
|
||||||
|
5. Когда нужен ещё один модельный день, запустите `world_next_day` с пустой формой.
|
||||||
|
|
||||||
Операции:
|
`world_next_day` имеет расписание каждые 30 минут, но по умолчанию стоит на паузе.
|
||||||
|
Один запуск добавляет один модельный день; `max_active_runs=1` не допускает
|
||||||
|
параллельных доливок.
|
||||||
|
|
||||||
|
## Операции сопровождающего
|
||||||
|
|
||||||
|
В форме `world_init` сопровождающему дополнительно доступны операции:
|
||||||
|
|
||||||
- `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает
|
- `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает
|
||||||
`etl_pipeline`, ждёт завершения и выполняет `check`.
|
`etl_pipeline`, ждёт завершения и выполняет `check`.
|
||||||
- `import` — прочитать артефакт из `artifact_path`. Несовместимый артефакт
|
|
||||||
отклоняется до записи в Kafka.
|
|
||||||
- `check` — сверить ClickHouse с manifest из Kafka.
|
- `check` — сверить ClickHouse с manifest из Kafka.
|
||||||
|
|
||||||
Поля формы:
|
Поля формы:
|
||||||
@@ -58,7 +95,8 @@
|
|||||||
- `duration` можно оставить пустым, тогда берётся длительность профиля.
|
- `duration` можно оставить пустым, тогда берётся длительность профиля.
|
||||||
- `seed` и `model_time_speed` — необязательные переопределения мира.
|
- `seed` и `model_time_speed` — необязательные переопределения мира.
|
||||||
- `artifact_path`: для `backfill` — куда сохранить файл; пусто — не сохранять.
|
- `artifact_path`: для `backfill` — куда сохранить файл; пусто — не сохранять.
|
||||||
Для `import` — что читать; пусто — `/opt/airflow/data/startup-history-import.json`.
|
Для `import` — что читать; пусто — эталонный мир из репозитория:
|
||||||
|
`/opt/airflow/data/startup_history/reference-world.json.xz`.
|
||||||
|
|
||||||
Backfill и import работают только на чистом стенде. Если Kafka-топики данных или
|
Backfill и import работают только на чистом стенде. Если Kafka-топики данных или
|
||||||
STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Консольные
|
STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Консольные
|
||||||
@@ -79,20 +117,18 @@ STG уже непустые, DAG упадёт до записи и подска
|
|||||||
|
|
||||||
## Экспорт
|
## Экспорт
|
||||||
|
|
||||||
По умолчанию создаётся быстрый 6-часовой артефакт:
|
По умолчанию создаётся трёхсуточный артефакт `daily-wave` с суточной волной:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make startup-history-export
|
make startup-history-export
|
||||||
```
|
```
|
||||||
|
|
||||||
Файл по умолчанию: `/tmp/clickstream-startup-history.json`.
|
Файл по умолчанию: `/tmp/clickstream-startup-history.json`. В live-продолжении
|
||||||
|
профиль `daily-wave` проживает модельные сутки примерно за 24 настенные минуты.
|
||||||
История на 2 суток с суточной волной. В live-продолжении этот профиль проживает
|
Для быстрой автоматической проверки явно задайте служебный профиль `ci`:
|
||||||
модельные сутки примерно за 24 настенные минуты:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ARTIFACT=/tmp/clickstream-startup-history-2d.json \
|
PROFILE=ci ARTIFACT=/tmp/clickstream-startup-history-ci.json \
|
||||||
PROFILE=daily-wave \
|
|
||||||
make startup-history-export
|
make startup-history-export
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,11 +142,11 @@ make clean
|
|||||||
docker compose up -d clickhouse kafka
|
docker compose up -d clickhouse kafka
|
||||||
make ddl
|
make ddl
|
||||||
|
|
||||||
ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-import
|
make startup-history-import
|
||||||
|
|
||||||
sleep 10
|
sleep 10
|
||||||
make transform
|
make transform
|
||||||
ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-check
|
ARTIFACT=data/startup_history/reference-world.json.xz make startup-history-check
|
||||||
CHECK_LIVE_SEAM=0 make generated-history-check
|
CHECK_LIVE_SEAM=0 make generated-history-check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,10 +158,26 @@ CHECK_LIVE_SEAM=0 make generated-history-check
|
|||||||
compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и
|
compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и
|
||||||
Materialized View, затем batch строит ODS, DDS и DM.
|
Materialized View, затем batch строит ODS, DDS и DM.
|
||||||
|
|
||||||
Если артефакт создан не профилем `ci`, импорт запускайте с тем же профилем:
|
Во время импорта генератор за один проход по событиям создаёт локальное
|
||||||
|
накопительное состояние manifest. Эталонный файл не меняется: в нём этого
|
||||||
|
служебного раздела нет. Основная запись manifest в Kafka хранит суммы, катящуюся
|
||||||
|
контрольную сумму и ссылку на цепочку точных множеств. `click_id` и
|
||||||
|
`user_domain_id` лежат небольшими неизменяемыми фрагментами в отдельном
|
||||||
|
compact-топике. State хранит SHA-256-ссылку на основную запись.
|
||||||
|
`backfill` создаёт такое состояние сразу по ходу генерации.
|
||||||
|
|
||||||
|
`world_next_day` восстанавливает эти числа и добавляет только события нового
|
||||||
|
дня, не читая старую историю Kafka и не перезаписывая прежние фрагменты.
|
||||||
|
Контрольная сумма складывает 256-битные
|
||||||
|
SHA-256-отпечатки записей по модулю 2^256, поэтому результат не зависит от
|
||||||
|
границ пакетов и совпадает с полным пересчётом. Если старый локальный state не
|
||||||
|
содержит ссылку на накопительные числа, очистите стенд и повторите `import`.
|
||||||
|
|
||||||
|
Импорт запускайте с тем же профилем, на котором создан артефакт. Для служебного
|
||||||
|
артефакта `ci` профиль нужно задать явно:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PROFILE=daily-wave ARTIFACT=/tmp/clickstream-startup-history-2d.json \
|
PROFILE=ci ARTIFACT=/tmp/clickstream-startup-history-ci.json \
|
||||||
make startup-history-import
|
make startup-history-import
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,10 +194,10 @@ ClickHouse уже успел прочитать частичные сообще
|
|||||||
## Live-продолжение
|
## Live-продолжение
|
||||||
|
|
||||||
После импорта запускайте live с тем же профилем, что был в артефакте. Для
|
После импорта запускайте live с тем же профилем, что был в артефакте. Для
|
||||||
артефакта `daily-wave`:
|
обычного артефакта `daily-wave` достаточно команды:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PROFILE=daily-wave make generator-continue
|
make generator-continue
|
||||||
```
|
```
|
||||||
|
|
||||||
У `daily-wave` скорость ×60. Долгий простой стенда создаёт большую дыру в
|
У `daily-wave` скорость ×60. Долгий простой стенда создаёт большую дыру в
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ ADR-0005 решил отвязать время генератора от реа
|
|||||||
- `reset` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=true`.
|
- `reset` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=true`.
|
||||||
|
|
||||||
Профиль `ci` даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с.
|
Профиль `ci` даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с.
|
||||||
Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна, а в live
|
Профиль `daily-wave` даёт 3 суток, чтобы была видна суточная волна, а в live
|
||||||
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
|
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
|
||||||
проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана,
|
проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана,
|
||||||
чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны
|
чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Редизайн пути менти: мир из артефакта и две ветки роста
|
||||||
|
|
||||||
|
Статус: принято, в работе (ветка `feature/mentee-path`).
|
||||||
|
Источник: ручной HITL-прогон пути менти 2026-07-19 — находки F1–F9
|
||||||
|
(рабочая копия — `.scratch/hitl-findings.md`; постоянный след — история git,
|
||||||
|
срез `0e312b3`). Решения приняты в обсуждении с пользователем 2026-07-19.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Сегодняшний первый шаг менти — `backfill`: генерация истории с нуля.
|
||||||
|
Это медленно (день модельной истории — минуты и десятки минут CPU),
|
||||||
|
а результат беден (6 часов плоского профиля `ci`). Вокруг — ворох трения:
|
||||||
|
|
||||||
|
- пульт `generator_control` подсовывает `backfill` дефолтом и носит имя,
|
||||||
|
непонятное осваивающему стенд;
|
||||||
|
- два профиля (`ci`/`daily-wave`) путают: менти достаётся плоский тестовый;
|
||||||
|
- `next-day` спрятан в общем пульте и не годится для расписания:
|
||||||
|
каждый прогон перечитывает всю историю Kafka заново — стоимость растёт
|
||||||
|
квадратично по дням, память — линейно (риск OOM);
|
||||||
|
- готового артефакта в репозитории нет: механизм `import` есть, а
|
||||||
|
импортировать нечего.
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- Менти получает богатый мир за секунды-минуты, а не генерирует его.
|
||||||
|
- Путь менти проходится в Airflow UI пустыми формами, без справочника.
|
||||||
|
- У всех менти одинаковый мир: числа в лабах воспроизводимы число-в-число.
|
||||||
|
- Мир можно растить двумя осознанными способами: пакетно по дням и живым
|
||||||
|
потоком.
|
||||||
|
- Плановый `next-day` перестаёт дорожать с возрастом мира.
|
||||||
|
|
||||||
|
## Целевая модель
|
||||||
|
|
||||||
|
**Основной режим менти — `import` эталонного мира.** От восстановленного
|
||||||
|
состояния расходятся две ветки — это три режима менти (термины — в
|
||||||
|
`CONTEXT.md`):
|
||||||
|
|
||||||
|
- **База: `import`** — загрузить эталонный мир из артефакта в git.
|
||||||
|
- **Ветка A: `next-day`** — пакетно добавить следующий модельный день.
|
||||||
|
- **Ветка B: `continue`** — живой поток от той же границы (скорость ×60).
|
||||||
|
|
||||||
|
`backfill` уходит мейнтейнеру: это инструмент сборки эталонного мира,
|
||||||
|
не первый шаг менти (аналогия из соседнего курса Airflow: по умолчанию
|
||||||
|
загружают готовый первый месяц, а не собирают его генератором).
|
||||||
|
|
||||||
|
### Путь менти (UX)
|
||||||
|
|
||||||
|
1. `make up` — поднять стенд (единственная команда терминала).
|
||||||
|
2. Airflow UI: `ddl_init` — создать схему.
|
||||||
|
3. Airflow UI: `world_init` — Trigger с пустой формой. Дефолтная операция —
|
||||||
|
`import`, путь к артефакту — дефолтом на эталонный мир. DAG сам заливает
|
||||||
|
Kafka, запускает ETL и сверяет витрины.
|
||||||
|
4. `make superset-init` — разовая инициализация дашборда (терминал).
|
||||||
|
5. Superset — готовый трёхдневный мир.
|
||||||
|
6. Дальше по выбору лабы: `world_next_day` (кнопка без параметров) или
|
||||||
|
`make generator-continue` (живой поток).
|
||||||
|
|
||||||
|
### Решения
|
||||||
|
|
||||||
|
1. **Эталонный мир**: 3 модельных дня на профиле `daily-wave` (суточная
|
||||||
|
волна видна, есть «средний» день и сравнение день-к-дню). Собирает
|
||||||
|
мейнтейнер один раз через `backfill`; хранится в git по фиксированному
|
||||||
|
пути `data/startup_history/` сжатым `xz` (факт 2026-07-22: ~850 МБ
|
||||||
|
сырой JSON, ~32 МБ после `xz -9e`). Формат не меняется: `import` требует
|
||||||
|
`raw_topics`, сжатие снимает вопрос размера. Паттерн-образец —
|
||||||
|
`airflow-greenplum-solution` (сид `demo.sql.xz` в git, стрим-распаковка
|
||||||
|
при загрузке).
|
||||||
|
2. **Пульт переименовывается: `generator_control` → `world_init`.**
|
||||||
|
Дефолт формы — `import` (сейчас `backfill` — корень находки F3);
|
||||||
|
`backfill` и `check` остаются в выпадашке, но уходят из учебных
|
||||||
|
инструкций в runbook мейнтейнера. Список DAG'ов читается лесенкой:
|
||||||
|
`ddl_init` → `world_init` → `world_next_day`. Цена: история прогонов
|
||||||
|
старого dag_id теряется (на учебном стенде не жалко), доки и
|
||||||
|
контрактный тест правятся синхронно.
|
||||||
|
3. **`world_next_day` — отдельный беспараметрный DAG**: триггернул — день
|
||||||
|
добавился. `schedule` прописан, но DAG paused и `catchup=False`:
|
||||||
|
процесс тяжёлый, автозапуск включается осознанно (шаг лабы), живой
|
||||||
|
рост мира и так даёт ветка `continue`.
|
||||||
|
4. **Инкрементальные счётчики manifest — предусловие расписания.**
|
||||||
|
Накопительное состояние счётчиков (суммы, множества uid/click_id,
|
||||||
|
катящаяся контрольная сумма) переезжает в state/manifest; новый день
|
||||||
|
только добавляется. Полная перечитка Kafka уходит: стоимость дня
|
||||||
|
становится ~постоянной (сейчас день 2 — 638 с и растёт), риск OOM
|
||||||
|
снимается. Разбор по коду — hitl-findings, F9.
|
||||||
|
5. **Один учебный профиль**: `daily-wave` (волна, ×60). `ci` (×1, плоско,
|
||||||
|
6h) — служебный для автотестов, менти не предлагается. Runtime-seam-
|
||||||
|
проверка уже идёт на `daily-wave`; перед слиянием проверить остальных
|
||||||
|
потребителей `ci` — дефолты `launch.py` и автотесты, где профиль зашит.
|
||||||
|
|
||||||
|
## Чего здесь не делаем
|
||||||
|
|
||||||
|
- **Не параллелим генерацию** — ломает детерминизм (один поток ГПСЧ,
|
||||||
|
переходящие визиты); корень стоимости не в ней, а в перечитке (F9).
|
||||||
|
- **Не трогаем гео-фактуру** — отдельная спека по ADR-0006.
|
||||||
|
- **Не мигрируем лабы курса** (`docs/course/`) — они отстали от реальности
|
||||||
|
и потребуют редизайна под три режима; отдельная работа, зафиксирована
|
||||||
|
issue в трекере.
|
||||||
|
- **Не чиним здесь F5** (быстрый разлогин Airflow/Superset) **и F4**
|
||||||
|
(JS-ошибка Grid) — отдельные issues вне фичи.
|
||||||
|
- **Не включаем расписание по умолчанию** и не занимаемся retention сверх
|
||||||
|
инкрементальных счётчиков.
|
||||||
|
- **Не делаем инкрементальный ETL**: `world_next_day` по-прежнему зовёт
|
||||||
|
`full_refresh`, его стоимость растёт с миром (на текущих объёмах —
|
||||||
|
десятки секунд). Известная цена; исторический план —
|
||||||
|
`plans/incremental-etl-v2.md` (legacy).
|
||||||
|
|
||||||
|
## Форма работ
|
||||||
|
|
||||||
|
Четыре дочерних issue (GitHub, по контракту `docs/agents/issue-tracker.md`;
|
||||||
|
корневой issue ссылается сюда):
|
||||||
|
|
||||||
|
1. **Эталонный мир**: сборка 3-дневного артефакта, `xz`-хранение в git,
|
||||||
|
стрим-распаковка на `import`, дефолтный путь в форме.
|
||||||
|
2. **Поверхность DAG'ов**: переименование в `world_init`, дефолт `import`,
|
||||||
|
вынос `world_next_day` беспараметрным DAG'ом с paused-расписанием,
|
||||||
|
синхронная правка доков и контрактных тестов.
|
||||||
|
3. **Инкрементальные счётчики manifest** (без них расписание не включать).
|
||||||
|
4. **Один учебный профиль**: `daily-wave` — учебный дефолт, `ci` —
|
||||||
|
служебный; проверка независимости runtime-seam-теста от ×1.
|
||||||
|
|
||||||
|
Зависимости: (1) и (4) сцеплены (эталонный мир собирается на `daily-wave`);
|
||||||
|
(3) блокирует включение расписания из (2), но не сам DAG.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- Чистый стенд: путь менти из раздела UX проходится пустыми формами;
|
||||||
|
терминал — только `make up` и `make superset-init`.
|
||||||
|
- `import` эталонного мира — секунды-минуты, не десятки минут.
|
||||||
|
- После `import` у любого менти совпадают manifest-числа эталонного мира
|
||||||
|
(события, визиты, пользователи, контрольная сумма).
|
||||||
|
- Задача генерации+manifest в `world_next_day` на дне N по времени
|
||||||
|
~равна дню 1 (ETL `full_refresh` растёт с миром — это вне скоупа,
|
||||||
|
см. «Чего не делаем»); `make generated-history-chain-check` зелёный
|
||||||
|
на стыках.
|
||||||
|
- `make test` / `make lint` зелёные; профиль `ci` продолжает обслуживать
|
||||||
|
автотесты.
|
||||||
|
|
||||||
|
## Решения и отклонённые варианты
|
||||||
|
|
||||||
|
- **Без артефакта, просто ускорить backfill** — отклонено: время менти и
|
||||||
|
воспроизводимость (общий файл надёжнее «одинаковой генерации у всех»).
|
||||||
|
- **Мир «из коробки» при `make up`** — отклонено: механизм становится
|
||||||
|
невидимым, магия в учебном стенде мстит; явный `import` — учебная
|
||||||
|
ценность и общее начало обеих лаб.
|
||||||
|
- **Расписание включённым по умолчанию** — отклонено: эпизодический стенд,
|
||||||
|
тяжёлый процесс, сюрпризы catchup.
|
||||||
|
- **Три тонких DAG вместо пульта** — отклонено: плодит сущности; пульт с
|
||||||
|
правильным дефолтом + один тонкий `world_next_day` достаточно.
|
||||||
|
- **git-lfs / релизные ассеты для артефакта** — отклонено: после `xz`
|
||||||
|
артефакт помещается в обычный git.
|
||||||
|
|
||||||
|
## Влияние на документацию
|
||||||
|
|
||||||
|
- `README.md`, `docs/OPERATIONS.md`, `docs/ARCHITECTURE.md`,
|
||||||
|
`docs/REPO_MAP.md` — новый путь менти и имена DAG'ов (в тех же PR, что
|
||||||
|
и изменения).
|
||||||
|
- `docs/runbooks/startup-history.md` — `backfill`/сборка эталонного мира
|
||||||
|
переезжают сюда как процедура мейнтейнера; дополнить про размер и
|
||||||
|
хранение.
|
||||||
|
- `CONTEXT.md` — термины «мир (стенда)», «эталонный мир», «три режима
|
||||||
|
менти» (добавлены этой же спекой).
|
||||||
|
- Лабы `docs/course/` — сознательно не здесь (см. «Чего не делаем»).
|
||||||
+6
-9
@@ -78,7 +78,7 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
|
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
|
||||||
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
|
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
|
||||||
| `GEN_RUN_MODE` | Режим генератора | `live` |
|
| `GEN_RUN_MODE` | Режим генератора | `live` |
|
||||||
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` |
|
| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `daily-wave` |
|
||||||
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | — |
|
| `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | — |
|
||||||
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
|
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
|
||||||
| `GEN_SEED` | Сид для воспроизводимости | — |
|
| `GEN_SEED` | Сид для воспроизводимости | — |
|
||||||
@@ -126,14 +126,11 @@ make generated-history-analytics
|
|||||||
проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka:
|
проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka:
|
||||||
они пока используются только как фактура для генератора.
|
они пока используются только как фактура для генератора.
|
||||||
|
|
||||||
По умолчанию используется быстрый профиль `ci`: 6 часов модельного времени.
|
По умолчанию используется учебный профиль `daily-wave`: 3 суток с суточной
|
||||||
Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна. В live он
|
волной. В live он идёт с `GEN_MODEL_TIME_SPEED=60` и
|
||||||
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
|
`GEN_TICK_SECONDS=1`: модельные сутки проходят примерно за 24 настенные
|
||||||
проходят примерно за 24 настенные минуты.
|
минуты. Плоский профиль `ci` на 6 часов остаётся служебным для автоматических
|
||||||
|
тестов.
|
||||||
```bash
|
|
||||||
PROFILE=daily-wave make generated-history-analytics
|
|
||||||
```
|
|
||||||
|
|
||||||
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
|
Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`:
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ from clickstream_generator.service import GeneratorService
|
|||||||
from clickstream_generator.startup_history_artifact import (
|
from clickstream_generator.startup_history_artifact import (
|
||||||
KafkaRawPublisher,
|
KafkaRawPublisher,
|
||||||
KafkaTopicInspector,
|
KafkaTopicInspector,
|
||||||
|
ManifestCounters,
|
||||||
compare_clickhouse_stats_to_manifest,
|
compare_clickhouse_stats_to_manifest,
|
||||||
|
cumulative_counter_reference,
|
||||||
import_startup_history_artifact,
|
import_startup_history_artifact,
|
||||||
|
known_counter_ids_from_state,
|
||||||
load_startup_history_artifact,
|
load_startup_history_artifact,
|
||||||
manifest_boundaries,
|
manifest_boundaries,
|
||||||
validate_startup_history_artifact,
|
validate_startup_history_artifact,
|
||||||
@@ -171,6 +174,38 @@ def assert_next_day_snapshot(manifest: dict | None, state) -> None:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"state не согласован с T_end manifest: {exc}"
|
f"state не согласован с T_end manifest: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
counter_reference = state.cumulative_manifest_counters
|
||||||
|
counter_state = manifest.get("cumulative_manifest_counters")
|
||||||
|
if counter_reference is None or counter_state is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"В state нет накопительных счётчиков manifest; "
|
||||||
|
"повторно запустите import эталонного мира."
|
||||||
|
)
|
||||||
|
if cumulative_counter_reference(counter_state) != counter_reference:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Накопительные счётчики state и manifest не совпадают; "
|
||||||
|
"повторно запустите import эталонного мира."
|
||||||
|
)
|
||||||
|
known_click_ids, known_user_ids = known_counter_ids_from_state(state)
|
||||||
|
try:
|
||||||
|
counters = ManifestCounters.from_state(
|
||||||
|
counter_state,
|
||||||
|
known_click_ids=known_click_ids,
|
||||||
|
known_user_ids=known_user_ids,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Накопительные счётчики manifest повреждены; "
|
||||||
|
"повторно запустите import эталонного мира."
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
counters.to_manifest_topics() != manifest.get("topics")
|
||||||
|
or counters.to_manifest_totals() != manifest.get("totals")
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Накопительные счётчики не совпадают с числами manifest; "
|
||||||
|
"повторно запустите import эталонного мира."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_utc_timestamp(value: str):
|
def _parse_utc_timestamp(value: str):
|
||||||
@@ -328,19 +363,20 @@ def target_dag_trigger_error(dag_id: str, dag_model) -> str | None:
|
|||||||
if dag_model.is_paused:
|
if dag_model.is_paused:
|
||||||
return (
|
return (
|
||||||
f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, "
|
f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, "
|
||||||
"затем повторите generator_control."
|
"затем повторите запуск DAG."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def default_artifact_path(operation: str) -> str:
|
def default_artifact_path(operation: str) -> str:
|
||||||
"""Возвращает путь артефакта по умолчанию в общем томе data."""
|
"""Возвращает путь артефакта по умолчанию в общем томе data."""
|
||||||
filename = (
|
if operation == "import":
|
||||||
"startup-history-import.json"
|
return str(
|
||||||
if operation == "import"
|
Path(AIRFLOW_DATA_DIR)
|
||||||
else "startup-history.json"
|
/ "startup_history"
|
||||||
|
/ "reference-world.json.xz"
|
||||||
)
|
)
|
||||||
return str(Path(AIRFLOW_DATA_DIR) / filename)
|
return str(Path(AIRFLOW_DATA_DIR) / "startup-history.json")
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class Config:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
launch_profile: str = field(
|
launch_profile: str = field(
|
||||||
default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "ci")
|
default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "daily-wave")
|
||||||
)
|
)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Kafka-интеграция генератора."""
|
"""Kafka-интеграция генератора."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -14,6 +15,7 @@ from clickstream_generator.state import UnsupportedStateVersionError
|
|||||||
|
|
||||||
logger = logging.getLogger("generator")
|
logger = logging.getLogger("generator")
|
||||||
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
||||||
|
MAX_COMPACT_MESSAGE_BYTES = 900_000
|
||||||
|
|
||||||
_kafka_imported = False
|
_kafka_imported = False
|
||||||
KafkaProducer = None
|
KafkaProducer = None
|
||||||
@@ -207,7 +209,9 @@ class KafkaStartupHistoryManifest:
|
|||||||
"""Хранение манифеста стартовой истории в Kafka compact topic."""
|
"""Хранение манифеста стартовой истории в Kafka compact topic."""
|
||||||
|
|
||||||
MANIFEST_TOPIC = "generator_startup_history_manifest"
|
MANIFEST_TOPIC = "generator_startup_history_manifest"
|
||||||
|
COUNTER_TOPIC = "generator_startup_history_counter_chunks"
|
||||||
MANIFEST_KEY = "default"
|
MANIFEST_KEY = "default"
|
||||||
|
COUNTER_CHUNK_KEY_PREFIX = "id-set:"
|
||||||
|
|
||||||
def __init__(self, bootstrap_servers: str):
|
def __init__(self, bootstrap_servers: str):
|
||||||
self.bootstrap_servers = bootstrap_servers
|
self.bootstrap_servers = bootstrap_servers
|
||||||
@@ -227,6 +231,8 @@ class KafkaStartupHistoryManifest:
|
|||||||
|
|
||||||
def save(self, manifest: dict) -> None:
|
def save(self, manifest: dict) -> None:
|
||||||
"""Сохраняет манифест стартовой истории."""
|
"""Сохраняет манифест стартовой истории."""
|
||||||
|
self._assert_message_size(manifest, "manifest")
|
||||||
|
|
||||||
def _do_send():
|
def _do_send():
|
||||||
self.producer.send(
|
self.producer.send(
|
||||||
self.MANIFEST_TOPIC,
|
self.MANIFEST_TOPIC,
|
||||||
@@ -236,6 +242,40 @@ class KafkaStartupHistoryManifest:
|
|||||||
|
|
||||||
_retry(_do_send, max_retries=3, base_delay=0.5)
|
_retry(_do_send, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
def save_counter_chunk(self, chunk_sha256: str, chunk: dict) -> None:
|
||||||
|
"""Сохраняет неизменяемый фрагмент точных множеств идентификаторов."""
|
||||||
|
if not isinstance(chunk_sha256, str) or len(chunk_sha256) != 64:
|
||||||
|
raise ValueError("хеш фрагмента множеств идентификаторов неверен")
|
||||||
|
actual_sha256 = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
chunk,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
if actual_sha256 != chunk_sha256:
|
||||||
|
raise ValueError("хеш фрагмента множеств идентификаторов не совпадает")
|
||||||
|
self._assert_message_size(chunk, "фрагмент множеств идентификаторов")
|
||||||
|
|
||||||
|
def _do_send():
|
||||||
|
self.producer.send(
|
||||||
|
self.COUNTER_TOPIC,
|
||||||
|
key=self.COUNTER_CHUNK_KEY_PREFIX + chunk_sha256,
|
||||||
|
value=chunk,
|
||||||
|
)
|
||||||
|
|
||||||
|
_retry(_do_send, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_message_size(value: dict, label: str) -> None:
|
||||||
|
size = len(json.dumps(value, ensure_ascii=True).encode("utf-8"))
|
||||||
|
if size > MAX_COMPACT_MESSAGE_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} занимает {size} байт и превышает безопасный предел "
|
||||||
|
f"{MAX_COMPACT_MESSAGE_BYTES} байт одного сообщения Kafka"
|
||||||
|
)
|
||||||
|
|
||||||
def flush(self) -> None:
|
def flush(self) -> None:
|
||||||
"""Сбрасывает буфер с retry."""
|
"""Сбрасывает буфер с retry."""
|
||||||
def _do_flush():
|
def _do_flush():
|
||||||
@@ -372,8 +412,23 @@ def ensure_topics(bootstrap_servers: str) -> None:
|
|||||||
"delete.retention.ms": "100",
|
"delete.retention.ms": "100",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
counter_topic = NewTopic(
|
||||||
|
name=KafkaStartupHistoryManifest.COUNTER_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]:
|
for topic in [
|
||||||
|
history_topic,
|
||||||
|
state_topic,
|
||||||
|
manifest_topic,
|
||||||
|
counter_topic,
|
||||||
|
]:
|
||||||
try:
|
try:
|
||||||
admin_client.create_topics([topic])
|
admin_client.create_topics([topic])
|
||||||
logger.info(f"Created topic: {topic.name}")
|
logger.info(f"Created topic: {topic.name}")
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ PROFILES = {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
"daily-wave": LaunchProfile(
|
"daily-wave": LaunchProfile(
|
||||||
duration="2d",
|
duration="3d",
|
||||||
env={
|
env={
|
||||||
"GEN_SEED": "4242",
|
"GEN_SEED": "4242",
|
||||||
"GEN_MODEL_T0": "2026-01-01T00:00:00+00:00",
|
"GEN_MODEL_T0": "2026-01-01T00:00:00+00:00",
|
||||||
@@ -88,7 +88,7 @@ def parse_duration(value: str) -> timedelta:
|
|||||||
def build_launch_env(
|
def build_launch_env(
|
||||||
verb: str,
|
verb: str,
|
||||||
*,
|
*,
|
||||||
profile_name: str = "ci",
|
profile_name: str = "daily-wave",
|
||||||
duration: str | None = None,
|
duration: str | None = None,
|
||||||
overrides: dict[str, str] | None = None,
|
overrides: dict[str, str] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -161,7 +161,11 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--profile",
|
"--profile",
|
||||||
default=os.getenv("PROFILE") or os.getenv("GEN_LAUNCH_PROFILE") or "ci",
|
default=(
|
||||||
|
os.getenv("PROFILE")
|
||||||
|
or os.getenv("GEN_LAUNCH_PROFILE")
|
||||||
|
or "daily-wave"
|
||||||
|
),
|
||||||
choices=sorted(PROFILES),
|
choices=sorted(PROFILES),
|
||||||
)
|
)
|
||||||
parser.add_argument("--duration", default=os.getenv("GEN_HISTORY_DURATION"))
|
parser.add_argument("--duration", default=os.getenv("GEN_HISTORY_DURATION"))
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from clickstream_generator.generation import EventGenerator
|
|||||||
from clickstream_generator.kafka_io import (
|
from clickstream_generator.kafka_io import (
|
||||||
BatchRecord,
|
BatchRecord,
|
||||||
KafkaBatchHistory,
|
KafkaBatchHistory,
|
||||||
KafkaDataTopicReader,
|
|
||||||
KafkaPublisher,
|
KafkaPublisher,
|
||||||
KafkaStateManager,
|
KafkaStateManager,
|
||||||
KafkaStartupHistoryManifest,
|
KafkaStartupHistoryManifest,
|
||||||
@@ -32,7 +31,9 @@ from clickstream_generator.startup_history_artifact import (
|
|||||||
StartupHistoryArtifactBuilder,
|
StartupHistoryArtifactBuilder,
|
||||||
ManifestCounters,
|
ManifestCounters,
|
||||||
build_manifest,
|
build_manifest,
|
||||||
|
cumulative_counter_reference,
|
||||||
generation_settings_from_config,
|
generation_settings_from_config,
|
||||||
|
known_counter_ids_from_state,
|
||||||
manifest_boundaries,
|
manifest_boundaries,
|
||||||
write_startup_history_artifact,
|
write_startup_history_artifact,
|
||||||
)
|
)
|
||||||
@@ -58,7 +59,6 @@ class GeneratorService:
|
|||||||
self.history: KafkaBatchHistory | None = None
|
self.history: KafkaBatchHistory | None = None
|
||||||
self.state_manager: KafkaStateManager | None = None
|
self.state_manager: KafkaStateManager | None = None
|
||||||
self.manifest_manager: KafkaStartupHistoryManifest | None = None
|
self.manifest_manager: KafkaStartupHistoryManifest | None = None
|
||||||
self.data_reader: KafkaDataTopicReader | None = None
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
self._shutdown_event = threading.Event()
|
self._shutdown_event = threading.Event()
|
||||||
@@ -133,10 +133,7 @@ class GeneratorService:
|
|||||||
restored_state,
|
restored_state,
|
||||||
model_t_end=model_t_end,
|
model_t_end=model_t_end,
|
||||||
)
|
)
|
||||||
self.data_reader = KafkaDataTopicReader(
|
self._run_next_day(manifest, restored_state)
|
||||||
self.config.kafka_bootstrap_servers
|
|
||||||
)
|
|
||||||
self._run_next_day(manifest)
|
|
||||||
self.stop()
|
self.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -471,13 +468,24 @@ class GeneratorService:
|
|||||||
model_t0=self.config.model_t0,
|
model_t0=self.config.model_t0,
|
||||||
gen_seed=self.config.seed,
|
gen_seed=self.config.seed,
|
||||||
)
|
)
|
||||||
|
cumulative_state, counter_chunks = (
|
||||||
|
artifact_builder.counters.to_state_with_chunks()
|
||||||
|
)
|
||||||
|
state.cumulative_manifest_counters = cumulative_counter_reference(
|
||||||
|
cumulative_state
|
||||||
|
)
|
||||||
|
|
||||||
manifest = build_manifest(
|
manifest = build_manifest(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
counters=artifact_builder.counters,
|
counters=artifact_builder.counters,
|
||||||
state=state,
|
state=state,
|
||||||
|
cumulative_state=cumulative_state,
|
||||||
)
|
)
|
||||||
if self.manifest_manager:
|
if self.manifest_manager:
|
||||||
|
for chunk_sha256, chunk in counter_chunks:
|
||||||
|
self.manifest_manager.save_counter_chunk(chunk_sha256, chunk)
|
||||||
|
if counter_chunks:
|
||||||
|
self.manifest_manager.flush()
|
||||||
self.manifest_manager.save(manifest)
|
self.manifest_manager.save(manifest)
|
||||||
self.manifest_manager.flush()
|
self.manifest_manager.flush()
|
||||||
|
|
||||||
@@ -519,7 +527,7 @@ class GeneratorService:
|
|||||||
f"status={status}, sent_counts={sent_counts}"
|
f"status={status}, sent_counts={sent_counts}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _run_next_day(self, manifest: dict) -> None:
|
def _run_next_day(self, manifest: dict, restored_state) -> None:
|
||||||
"""Доливает ровно 24 модельных часа от границы manifest."""
|
"""Доливает ровно 24 модельных часа от границы manifest."""
|
||||||
if not self.publisher:
|
if not self.publisher:
|
||||||
raise RuntimeError("publisher не инициализирован")
|
raise RuntimeError("publisher не инициализирован")
|
||||||
@@ -528,6 +536,41 @@ class GeneratorService:
|
|||||||
if not self.state_manager or not self.manifest_manager:
|
if not self.state_manager or not self.manifest_manager:
|
||||||
raise RuntimeError("менеджеры state и manifest не инициализированы")
|
raise RuntimeError("менеджеры state и manifest не инициализированы")
|
||||||
|
|
||||||
|
counter_reference = restored_state.cumulative_manifest_counters
|
||||||
|
counter_state = manifest.get("cumulative_manifest_counters")
|
||||||
|
if counter_reference is None or counter_state is None:
|
||||||
|
raise IncompatibleStateError(
|
||||||
|
"в state нет накопительных счётчиков manifest; "
|
||||||
|
"повторно запустите import эталонного мира"
|
||||||
|
)
|
||||||
|
if cumulative_counter_reference(counter_state) != counter_reference:
|
||||||
|
raise IncompatibleStateError(
|
||||||
|
"накопительные счётчики state и manifest не совпадают; "
|
||||||
|
"повторно запустите import эталонного мира"
|
||||||
|
)
|
||||||
|
known_click_ids, known_user_ids = known_counter_ids_from_state(
|
||||||
|
restored_state
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
counters = ManifestCounters.from_state(
|
||||||
|
counter_state,
|
||||||
|
known_click_ids=known_click_ids,
|
||||||
|
known_user_ids=known_user_ids,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise IncompatibleStateError(
|
||||||
|
"накопительные счётчики manifest повреждены; "
|
||||||
|
"повторно запустите import эталонного мира"
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
counters.to_manifest_topics() != manifest.get("topics")
|
||||||
|
or counters.to_manifest_totals() != manifest.get("totals")
|
||||||
|
):
|
||||||
|
raise IncompatibleStateError(
|
||||||
|
"накопительные счётчики не совпадают с числами manifest; "
|
||||||
|
"повторно запустите import эталонного мира"
|
||||||
|
)
|
||||||
|
|
||||||
current_t_end = self._as_aware_utc(
|
current_t_end = self._as_aware_utc(
|
||||||
datetime.fromisoformat(manifest["model_t_end"])
|
datetime.fromisoformat(manifest["model_t_end"])
|
||||||
)
|
)
|
||||||
@@ -554,6 +597,7 @@ class GeneratorService:
|
|||||||
)
|
)
|
||||||
total_sent, sent_counts, status = self._publish_batch(batch)
|
total_sent, sent_counts, status = self._publish_batch(batch)
|
||||||
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
||||||
|
counters.add_batch(batch)
|
||||||
self._write_batch_history(
|
self._write_batch_history(
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
@@ -573,6 +617,7 @@ class GeneratorService:
|
|||||||
started_at = datetime.now(timezone.utc)
|
started_at = datetime.now(timezone.utc)
|
||||||
total_sent, sent_counts, status = self._publish_batch(final_batch)
|
total_sent, sent_counts, status = self._publish_batch(final_batch)
|
||||||
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
self._raise_on_next_day_publish_error(batch_id, status, sent_counts)
|
||||||
|
counters.add_batch(final_batch)
|
||||||
self._write_batch_history(
|
self._write_batch_history(
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
@@ -584,11 +629,6 @@ class GeneratorService:
|
|||||||
|
|
||||||
self.publisher.flush()
|
self.publisher.flush()
|
||||||
self._model_time = target_t_end
|
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_batch_id = self._startup_state_batch_id(counters)
|
||||||
state = self.stream.to_state(
|
state = self.stream.to_state(
|
||||||
tick=self._tick,
|
tick=self._tick,
|
||||||
@@ -602,6 +642,10 @@ class GeneratorService:
|
|||||||
model_t0=self.config.model_t0,
|
model_t0=self.config.model_t0,
|
||||||
gen_seed=self.config.seed,
|
gen_seed=self.config.seed,
|
||||||
)
|
)
|
||||||
|
cumulative_state, counter_chunks = counters.to_state_with_chunks()
|
||||||
|
state.cumulative_manifest_counters = cumulative_counter_reference(
|
||||||
|
cumulative_state
|
||||||
|
)
|
||||||
boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()]
|
boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()]
|
||||||
updated_manifest = build_manifest(
|
updated_manifest = build_manifest(
|
||||||
self.config,
|
self.config,
|
||||||
@@ -609,12 +653,17 @@ class GeneratorService:
|
|||||||
state,
|
state,
|
||||||
model_t_end=target_t_end,
|
model_t_end=target_t_end,
|
||||||
boundaries=boundaries,
|
boundaries=boundaries,
|
||||||
|
cumulative_state=cumulative_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.state_manager.save(state)
|
for chunk_sha256, chunk in counter_chunks:
|
||||||
self.state_manager.flush()
|
self.manifest_manager.save_counter_chunk(chunk_sha256, chunk)
|
||||||
|
if counter_chunks:
|
||||||
|
self.manifest_manager.flush()
|
||||||
self.manifest_manager.save(updated_manifest)
|
self.manifest_manager.save(updated_manifest)
|
||||||
self.manifest_manager.flush()
|
self.manifest_manager.flush()
|
||||||
|
self.state_manager.save(state)
|
||||||
|
self.state_manager.flush()
|
||||||
|
|
||||||
def _raise_on_next_day_publish_error(
|
def _raise_on_next_day_publish_error(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import lzma
|
||||||
import logging
|
import logging
|
||||||
|
import zlib
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +30,11 @@ logger = logging.getLogger("generator")
|
|||||||
ARTIFACT_KIND = "clickstream_generator_startup_history"
|
ARTIFACT_KIND = "clickstream_generator_startup_history"
|
||||||
ARTIFACT_VERSION = "1.0"
|
ARTIFACT_VERSION = "1.0"
|
||||||
TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
||||||
|
COUNTER_STATE_VERSION = "2.0"
|
||||||
|
CHECKSUM_MODULUS = 1 << 256
|
||||||
|
ID_SET_CHUNK_VERSION = "1.0"
|
||||||
|
ID_SET_CHUNK_SIZE = 10_000
|
||||||
|
ID_SET_STORAGE = "manifest-topic-sha256-chain-v1"
|
||||||
IMPORT_TOPICS = (
|
IMPORT_TOPICS = (
|
||||||
"browser_events",
|
"browser_events",
|
||||||
"location_events",
|
"location_events",
|
||||||
@@ -34,6 +42,7 @@ IMPORT_TOPICS = (
|
|||||||
"geo_events",
|
"geo_events",
|
||||||
KafkaStateManager.STATE_TOPIC,
|
KafkaStateManager.STATE_TOPIC,
|
||||||
KafkaStartupHistoryManifest.MANIFEST_TOPIC,
|
KafkaStartupHistoryManifest.MANIFEST_TOPIC,
|
||||||
|
KafkaStartupHistoryManifest.COUNTER_TOPIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,17 +53,20 @@ class _TopicManifestStats:
|
|||||||
self.rows = 0
|
self.rows = 0
|
||||||
self.min_event_timestamp: str | None = None
|
self.min_event_timestamp: str | None = None
|
||||||
self.max_event_timestamp: str | None = None
|
self.max_event_timestamp: str | None = None
|
||||||
self._checksum = hashlib.sha256()
|
self._checksum_sum = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def checksum(self) -> str:
|
def checksum(self) -> str:
|
||||||
return self._checksum.hexdigest()
|
return f"{self._checksum_sum:064x}"
|
||||||
|
|
||||||
def add(self, event: dict, event_timestamp: str | None = None) -> None:
|
def add(self, event: dict, event_timestamp: str | None = None) -> None:
|
||||||
self.rows += 1
|
self.rows += 1
|
||||||
self._checksum.update(
|
event_digest = hashlib.sha256(
|
||||||
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
|
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
|
||||||
)
|
).digest()
|
||||||
|
self._checksum_sum = (
|
||||||
|
self._checksum_sum + int.from_bytes(event_digest, "big")
|
||||||
|
) % CHECKSUM_MODULUS
|
||||||
timestamp = (
|
timestamp = (
|
||||||
event_timestamp
|
event_timestamp
|
||||||
if event_timestamp is not None
|
if event_timestamp is not None
|
||||||
@@ -78,6 +90,55 @@ class _TopicManifestStats:
|
|||||||
"checksum_sha256": self.checksum,
|
"checksum_sha256": self.checksum,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_state(cls, payload: dict) -> "_TopicManifestStats":
|
||||||
|
"""Восстанавливает накопительные числа одного топика."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("счётчики топика должны быть объектом")
|
||||||
|
rows = payload.get("rows")
|
||||||
|
checksum = payload.get("checksum_sha256")
|
||||||
|
if isinstance(rows, bool) or not isinstance(rows, int) or rows < 0:
|
||||||
|
raise ValueError("rows в накопительных счётчиках должен быть целым")
|
||||||
|
if not isinstance(checksum, str) or len(checksum) != 64:
|
||||||
|
raise ValueError("checksum_sha256 в накопительных счётчиках неверен")
|
||||||
|
try:
|
||||||
|
checksum_sum = int(checksum, 16)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"checksum_sha256 в накопительных счётчиках неверен"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
stats = cls()
|
||||||
|
stats.rows = rows
|
||||||
|
stats.min_event_timestamp = payload.get("min_event_timestamp")
|
||||||
|
stats.max_event_timestamp = payload.get("max_event_timestamp")
|
||||||
|
stats._checksum_sum = checksum_sum
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyTopicManifestStats(_TopicManifestStats):
|
||||||
|
"""Прежняя контрольная сумма для проверки неизменённого артефакта."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._legacy_checksum = hashlib.sha256()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def checksum(self) -> str:
|
||||||
|
return self._legacy_checksum.hexdigest()
|
||||||
|
|
||||||
|
def add(self, event: dict, event_timestamp: str | None = None) -> None:
|
||||||
|
self.rows += 1
|
||||||
|
self._legacy_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)
|
||||||
|
|
||||||
|
|
||||||
class ManifestCounters:
|
class ManifestCounters:
|
||||||
"""Счётчики стартовой истории для manifest."""
|
"""Счётчики стартовой истории для manifest."""
|
||||||
@@ -86,6 +147,11 @@ class ManifestCounters:
|
|||||||
self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS}
|
self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS}
|
||||||
self.click_ids: set[str] = set()
|
self.click_ids: set[str] = set()
|
||||||
self.user_ids: set[str] = set()
|
self.user_ids: set[str] = set()
|
||||||
|
self._new_click_ids: set[str] = set()
|
||||||
|
self._new_user_ids: set[str] = set()
|
||||||
|
self._visit_count = 0
|
||||||
|
self._user_count = 0
|
||||||
|
self._previous_id_set_chain = _empty_id_set_chain()
|
||||||
|
|
||||||
def add_batch(self, batch: dict[str, list[dict]]) -> None:
|
def add_batch(self, batch: dict[str, list[dict]]) -> None:
|
||||||
browser_events = batch.get("browser_events", [])
|
browser_events = batch.get("browser_events", [])
|
||||||
@@ -116,11 +182,23 @@ class ManifestCounters:
|
|||||||
stats.add_timestamp(max(timestamps))
|
stats.add_timestamp(max(timestamps))
|
||||||
stats.add(event, event_timestamp=event_timestamp)
|
stats.add(event, event_timestamp=event_timestamp)
|
||||||
click_id = event.get("click_id")
|
click_id = event.get("click_id")
|
||||||
if topic == "browser_events" and click_id:
|
if (
|
||||||
|
topic == "browser_events"
|
||||||
|
and click_id
|
||||||
|
and click_id not in self.click_ids
|
||||||
|
):
|
||||||
self.click_ids.add(click_id)
|
self.click_ids.add(click_id)
|
||||||
|
self._new_click_ids.add(click_id)
|
||||||
|
self._visit_count += 1
|
||||||
user_id = event.get("user_domain_id")
|
user_id = event.get("user_domain_id")
|
||||||
if topic == "device_events" and user_id:
|
if (
|
||||||
|
topic == "device_events"
|
||||||
|
and user_id
|
||||||
|
and user_id not in self.user_ids
|
||||||
|
):
|
||||||
self.user_ids.add(user_id)
|
self.user_ids.add(user_id)
|
||||||
|
self._new_user_ids.add(user_id)
|
||||||
|
self._user_count += 1
|
||||||
|
|
||||||
def to_manifest_topics(self) -> dict:
|
def to_manifest_topics(self) -> dict:
|
||||||
return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()}
|
return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()}
|
||||||
@@ -129,12 +207,214 @@ class ManifestCounters:
|
|||||||
browser_stats = self.topic_stats["browser_events"]
|
browser_stats = self.topic_stats["browser_events"]
|
||||||
return {
|
return {
|
||||||
"events": browser_stats.rows,
|
"events": browser_stats.rows,
|
||||||
"visits": len(self.click_ids),
|
"visits": self._visit_count,
|
||||||
"users": len(self.user_ids),
|
"users": self._user_count,
|
||||||
"min_event_timestamp": browser_stats.min_event_timestamp,
|
"min_event_timestamp": browser_stats.min_event_timestamp,
|
||||||
"max_event_timestamp": browser_stats.max_event_timestamp,
|
"max_event_timestamp": browser_stats.max_event_timestamp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def to_state(self) -> dict:
|
||||||
|
"""Возвращает JSON-сериализуемое накопительное состояние."""
|
||||||
|
state, _chunks = self.to_state_with_chunks()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def to_state_with_chunks(self) -> tuple[dict, list[tuple[str, dict]]]:
|
||||||
|
"""Возвращает компактное состояние и новые фрагменты точных множеств."""
|
||||||
|
id_set_chain, chunks = _extend_id_set_chain(
|
||||||
|
self._previous_id_set_chain,
|
||||||
|
self._new_click_ids,
|
||||||
|
self._new_user_ids,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"version": COUNTER_STATE_VERSION,
|
||||||
|
"checksum_algorithm": "sha256-sum-v1",
|
||||||
|
"topics": self.to_manifest_topics(),
|
||||||
|
"totals": {
|
||||||
|
"visits": self._visit_count,
|
||||||
|
"users": self._user_count,
|
||||||
|
},
|
||||||
|
"id_sets": id_set_chain,
|
||||||
|
}
|
||||||
|
return state, chunks
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_state(
|
||||||
|
cls,
|
||||||
|
payload: dict,
|
||||||
|
*,
|
||||||
|
known_click_ids: set[str] | None = None,
|
||||||
|
known_user_ids: set[str] | None = None,
|
||||||
|
) -> "ManifestCounters":
|
||||||
|
"""Продолжает счётчики из state без чтения старых событий."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("накопительные счётчики должны быть объектом")
|
||||||
|
if payload.get("version") != COUNTER_STATE_VERSION:
|
||||||
|
raise ValueError("версия накопительных счётчиков не поддерживается")
|
||||||
|
if payload.get("checksum_algorithm") != "sha256-sum-v1":
|
||||||
|
raise ValueError("алгоритм накопительной контрольной суммы не поддерживается")
|
||||||
|
topic_payloads = payload.get("topics")
|
||||||
|
if not isinstance(topic_payloads, dict) or set(topic_payloads) != set(TOPICS):
|
||||||
|
raise ValueError("в накопительных счётчиках нет всех топиков")
|
||||||
|
totals = payload.get("totals")
|
||||||
|
if not isinstance(totals, dict):
|
||||||
|
raise ValueError("в накопительных счётчиках нет итогов")
|
||||||
|
visits = _non_negative_int(totals.get("visits"), "visits")
|
||||||
|
users = _non_negative_int(totals.get("users"), "users")
|
||||||
|
id_sets = payload.get("id_sets")
|
||||||
|
_validate_id_set_chain(id_sets, visits=visits, users=users)
|
||||||
|
|
||||||
|
counters = cls()
|
||||||
|
counters.topic_stats = {
|
||||||
|
topic: _TopicManifestStats.from_state(topic_payloads[topic])
|
||||||
|
for topic in TOPICS
|
||||||
|
}
|
||||||
|
counters.click_ids = set(known_click_ids or ())
|
||||||
|
counters.user_ids = set(known_user_ids or ())
|
||||||
|
counters._new_click_ids = set()
|
||||||
|
counters._new_user_ids = set()
|
||||||
|
counters._visit_count = visits
|
||||||
|
counters._user_count = users
|
||||||
|
counters._previous_id_set_chain = dict(id_sets)
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
def cumulative_counter_reference(counter_state: dict) -> dict:
|
||||||
|
"""Строит компактную ссылку state на полный набор из manifest."""
|
||||||
|
canonical = json.dumps(
|
||||||
|
counter_state,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return {
|
||||||
|
"version": COUNTER_STATE_VERSION,
|
||||||
|
"manifest_sha256": hashlib.sha256(canonical).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def known_counter_ids_from_state(state: GeneratorState) -> tuple[set[str], set[str]]:
|
||||||
|
"""Возвращает идентификаторы, которые уже успели попасть в data-топики."""
|
||||||
|
click_ids = {visit["click_id"] for visit in state.active_visits}
|
||||||
|
user_ids = {
|
||||||
|
user["user_domain_id"]
|
||||||
|
for user in state.population
|
||||||
|
if user.get("active_click_id") is not None
|
||||||
|
or user.get("last_finished_at") is not None
|
||||||
|
}
|
||||||
|
return click_ids, user_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_id_set_chain() -> dict:
|
||||||
|
return {
|
||||||
|
"storage": ID_SET_STORAGE,
|
||||||
|
"latest_sha256": None,
|
||||||
|
"chunks": 0,
|
||||||
|
"click_ids": 0,
|
||||||
|
"user_ids": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _non_negative_int(value: Any, label: str) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
raise ValueError(f"{label} в накопительных счётчиках должен быть целым")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_id_set_chain(payload: Any, *, visits: int, users: int) -> None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("в накопительных счётчиках нет множеств идентификаторов")
|
||||||
|
if payload.get("storage") != ID_SET_STORAGE:
|
||||||
|
raise ValueError("хранилище множеств идентификаторов не поддерживается")
|
||||||
|
chunks = _non_negative_int(payload.get("chunks"), "chunks")
|
||||||
|
click_ids = _non_negative_int(payload.get("click_ids"), "click_ids")
|
||||||
|
user_ids = _non_negative_int(payload.get("user_ids"), "user_ids")
|
||||||
|
latest = payload.get("latest_sha256")
|
||||||
|
if latest is not None:
|
||||||
|
if not isinstance(latest, str) or len(latest) != 64:
|
||||||
|
raise ValueError("ссылка на фрагмент множеств идентификаторов неверна")
|
||||||
|
try:
|
||||||
|
int(latest, 16)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"ссылка на фрагмент множеств идентификаторов неверна"
|
||||||
|
) from exc
|
||||||
|
if (chunks == 0) != (latest is None):
|
||||||
|
raise ValueError("цепочка множеств идентификаторов повреждена")
|
||||||
|
if click_ids != visits or user_ids != users:
|
||||||
|
raise ValueError("размеры множеств идентификаторов не совпадают с итогами")
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_id_set_chain(
|
||||||
|
previous: dict,
|
||||||
|
click_ids: set[str],
|
||||||
|
user_ids: set[str],
|
||||||
|
) -> tuple[dict, list[tuple[str, dict]]]:
|
||||||
|
_validate_id_set_chain(
|
||||||
|
previous,
|
||||||
|
visits=_non_negative_int(previous.get("click_ids"), "click_ids"),
|
||||||
|
users=_non_negative_int(previous.get("user_ids"), "user_ids"),
|
||||||
|
)
|
||||||
|
tagged_ids = [("click_id", value) for value in sorted(click_ids)]
|
||||||
|
tagged_ids.extend(("user_id", value) for value in sorted(user_ids))
|
||||||
|
previous_sha256 = previous["latest_sha256"]
|
||||||
|
chunk_count = previous["chunks"]
|
||||||
|
chunks: list[tuple[str, dict]] = []
|
||||||
|
for offset in range(0, len(tagged_ids), ID_SET_CHUNK_SIZE):
|
||||||
|
part = tagged_ids[offset : offset + ID_SET_CHUNK_SIZE]
|
||||||
|
part_click_ids = {
|
||||||
|
value for kind, value in part if kind == "click_id"
|
||||||
|
}
|
||||||
|
part_user_ids = {
|
||||||
|
value for kind, value in part if kind == "user_id"
|
||||||
|
}
|
||||||
|
chunk = {
|
||||||
|
"version": ID_SET_CHUNK_VERSION,
|
||||||
|
"encoding": "json-zlib-base64-v1",
|
||||||
|
"sequence": chunk_count + 1,
|
||||||
|
"previous_sha256": previous_sha256,
|
||||||
|
"click_ids": _encode_id_set(part_click_ids),
|
||||||
|
"user_ids": _encode_id_set(part_user_ids),
|
||||||
|
}
|
||||||
|
digest = hashlib.sha256(_canonical_json(chunk)).hexdigest()
|
||||||
|
chunks.append((digest, chunk))
|
||||||
|
previous_sha256 = digest
|
||||||
|
chunk_count += 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"storage": ID_SET_STORAGE,
|
||||||
|
"latest_sha256": previous_sha256,
|
||||||
|
"chunks": chunk_count,
|
||||||
|
"click_ids": previous["click_ids"] + len(click_ids),
|
||||||
|
"user_ids": previous["user_ids"] + len(user_ids),
|
||||||
|
},
|
||||||
|
chunks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: dict) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_id_set(values: set[str]) -> str:
|
||||||
|
raw = json.dumps(sorted(values), separators=(",", ":")).encode("utf-8")
|
||||||
|
return base64.b64encode(zlib.compress(raw, level=9)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyManifestCounters(ManifestCounters):
|
||||||
|
"""Полный пересчёт старой суммы только при проверке артефакта."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.topic_stats = {
|
||||||
|
topic: _LegacyTopicManifestStats() for topic in TOPICS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class StartupHistoryArtifactBuilder:
|
class StartupHistoryArtifactBuilder:
|
||||||
"""Собирает сообщения топиков для портативного файла."""
|
"""Собирает сообщения топиков для портативного файла."""
|
||||||
@@ -157,12 +437,16 @@ class StartupHistoryArtifactBuilder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def to_artifact(self, manifest: dict, state: GeneratorState) -> dict:
|
def to_artifact(self, manifest: dict, state: GeneratorState) -> dict:
|
||||||
|
artifact_manifest = deepcopy(manifest)
|
||||||
|
artifact_manifest.pop("cumulative_manifest_counters", None)
|
||||||
|
artifact_state = state.to_dict()
|
||||||
|
artifact_state.pop("cumulative_manifest_counters", None)
|
||||||
return {
|
return {
|
||||||
"artifact_version": ARTIFACT_VERSION,
|
"artifact_version": ARTIFACT_VERSION,
|
||||||
"kind": ARTIFACT_KIND,
|
"kind": ARTIFACT_KIND,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"manifest": deepcopy(manifest),
|
"manifest": artifact_manifest,
|
||||||
"state": state.to_dict(),
|
"state": artifact_state,
|
||||||
"topics": deepcopy(self.topics),
|
"topics": deepcopy(self.topics),
|
||||||
"raw_topics": deepcopy(self.raw_topics),
|
"raw_topics": deepcopy(self.raw_topics),
|
||||||
}
|
}
|
||||||
@@ -294,6 +578,7 @@ def build_manifest(
|
|||||||
*,
|
*,
|
||||||
model_t_end: datetime | None = None,
|
model_t_end: datetime | None = None,
|
||||||
boundaries: list[str] | None = None,
|
boundaries: list[str] | None = None,
|
||||||
|
cumulative_state: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Строит manifest стартовой истории."""
|
"""Строит manifest стартовой истории."""
|
||||||
selected_t_end = model_t_end or config.model_t_end
|
selected_t_end = model_t_end or config.model_t_end
|
||||||
@@ -324,6 +609,7 @@ def build_manifest(
|
|||||||
},
|
},
|
||||||
"topics": counters.to_manifest_topics(),
|
"topics": counters.to_manifest_topics(),
|
||||||
"totals": counters.to_manifest_totals(),
|
"totals": counters.to_manifest_totals(),
|
||||||
|
"cumulative_manifest_counters": cumulative_state or counters.to_state(),
|
||||||
}
|
}
|
||||||
manifest_boundaries(manifest)
|
manifest_boundaries(manifest)
|
||||||
return manifest
|
return manifest
|
||||||
@@ -353,6 +639,15 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None:
|
|||||||
"""Пишет артефакт в JSON-файл."""
|
"""Пишет артефакт в JSON-файл."""
|
||||||
target = Path(path)
|
target = Path(path)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.suffix == ".xz":
|
||||||
|
with lzma.open(
|
||||||
|
target,
|
||||||
|
"wt",
|
||||||
|
encoding="utf-8",
|
||||||
|
preset=9 | lzma.PRESET_EXTREME,
|
||||||
|
) as output:
|
||||||
|
json.dump(artifact, output, ensure_ascii=True, indent=2)
|
||||||
|
return
|
||||||
target.write_text(
|
target.write_text(
|
||||||
json.dumps(artifact, ensure_ascii=True, indent=2),
|
json.dumps(artifact, ensure_ascii=True, indent=2),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -361,7 +656,11 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None:
|
|||||||
|
|
||||||
def load_startup_history_artifact(path: str | Path) -> dict:
|
def load_startup_history_artifact(path: str | Path) -> dict:
|
||||||
"""Читает артефакт из JSON-файла."""
|
"""Читает артефакт из JSON-файла."""
|
||||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
source = Path(path)
|
||||||
|
if source.suffix == ".xz":
|
||||||
|
with lzma.open(source, "rt", encoding="utf-8") as input_file:
|
||||||
|
return json.load(input_file)
|
||||||
|
return json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def validate_startup_history_artifact(
|
def validate_startup_history_artifact(
|
||||||
@@ -423,6 +722,16 @@ def import_startup_history_artifact(
|
|||||||
artifact,
|
artifact,
|
||||||
expected_config=expected_config,
|
expected_config=expected_config,
|
||||||
)
|
)
|
||||||
|
counters = ManifestCounters()
|
||||||
|
counters.add_batch(topics)
|
||||||
|
cumulative_state, counter_chunks = counters.to_state_with_chunks()
|
||||||
|
state.cumulative_manifest_counters = cumulative_counter_reference(
|
||||||
|
cumulative_state
|
||||||
|
)
|
||||||
|
manifest = deepcopy(manifest)
|
||||||
|
manifest["topics"] = counters.to_manifest_topics()
|
||||||
|
manifest["totals"] = counters.to_manifest_totals()
|
||||||
|
manifest["cumulative_manifest_counters"] = cumulative_state
|
||||||
if topic_inspector is not None:
|
if topic_inspector is not None:
|
||||||
topic_inspector.assert_data_topics_empty()
|
topic_inspector.assert_data_topics_empty()
|
||||||
snapshot = (
|
snapshot = (
|
||||||
@@ -457,6 +766,10 @@ def import_startup_history_artifact(
|
|||||||
sent_by_topic[topic] = sent
|
sent_by_topic[topic] = sent
|
||||||
|
|
||||||
publisher.flush()
|
publisher.flush()
|
||||||
|
for chunk_sha256, chunk in counter_chunks:
|
||||||
|
manifest_manager.save_counter_chunk(chunk_sha256, chunk)
|
||||||
|
if counter_chunks:
|
||||||
|
manifest_manager.flush()
|
||||||
manifest_manager.save(manifest)
|
manifest_manager.save(manifest)
|
||||||
manifest_manager.flush()
|
manifest_manager.flush()
|
||||||
state_manager.save(state)
|
state_manager.save(state)
|
||||||
@@ -534,10 +847,15 @@ def _validate_manifest_topics(manifest: dict, topics: dict[str, list[dict]]) ->
|
|||||||
counters.add_batch({topic: topics[topic] for topic in TOPICS})
|
counters.add_batch({topic: topics[topic] for topic in TOPICS})
|
||||||
expected_topics = manifest.get("topics") or {}
|
expected_topics = manifest.get("topics") or {}
|
||||||
expected_totals = manifest.get("totals") 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:
|
if counters.to_manifest_totals() != expected_totals:
|
||||||
raise ValueError("startup history artifact mismatch: totals")
|
raise ValueError("startup history artifact mismatch: totals")
|
||||||
|
if counters.to_manifest_topics() == expected_topics:
|
||||||
|
return
|
||||||
|
|
||||||
|
legacy_counters = _LegacyManifestCounters()
|
||||||
|
legacy_counters.add_batch({topic: topics[topic] for topic in TOPICS})
|
||||||
|
if legacy_counters.to_manifest_topics() != expected_topics:
|
||||||
|
raise ValueError("startup history artifact mismatch: topic checksums")
|
||||||
|
|
||||||
|
|
||||||
def _validate_raw_topics(
|
def _validate_raw_topics(
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ class GeneratorState:
|
|||||||
population: list[dict] = field(default_factory=list)
|
population: list[dict] = field(default_factory=list)
|
||||||
active_visits: list[dict] = field(default_factory=list)
|
active_visits: list[dict] = field(default_factory=list)
|
||||||
pending_visit_births: float = 0.0
|
pending_visit_births: float = 0.0
|
||||||
|
cumulative_manifest_counters: dict | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.model_timestamp is None:
|
if self.model_timestamp is None:
|
||||||
@@ -221,7 +222,7 @@ class GeneratorState:
|
|||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Конвертирует в словарь для JSON-сериализации."""
|
"""Конвертирует в словарь для JSON-сериализации."""
|
||||||
return {
|
payload = {
|
||||||
"tick": self.tick,
|
"tick": self.tick,
|
||||||
"rng_state": self.rng_state,
|
"rng_state": self.rng_state,
|
||||||
"last_batch_id": self.last_batch_id,
|
"last_batch_id": self.last_batch_id,
|
||||||
@@ -237,6 +238,11 @@ class GeneratorState:
|
|||||||
"active_visits": self.active_visits,
|
"active_visits": self.active_visits,
|
||||||
"pending_visit_births": self.pending_visit_births,
|
"pending_visit_births": self.pending_visit_births,
|
||||||
}
|
}
|
||||||
|
if self.cumulative_manifest_counters is not None:
|
||||||
|
payload["cumulative_manifest_counters"] = (
|
||||||
|
self.cumulative_manifest_counters
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "GeneratorState":
|
def from_dict(cls, data: dict) -> "GeneratorState":
|
||||||
@@ -290,6 +296,9 @@ class GeneratorState:
|
|||||||
population=data.get("population", []),
|
population=data.get("population", []),
|
||||||
active_visits=data.get("active_visits", []),
|
active_visits=data.get("active_visits", []),
|
||||||
pending_visit_births=data.get("pending_visit_births", 0.0),
|
pending_visit_births=data.get("pending_visit_births", 0.0),
|
||||||
|
cumulative_manifest_counters=data.get(
|
||||||
|
"cumulative_manifest_counters"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except UnsupportedStateVersionError:
|
except UnsupportedStateVersionError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_artifact_paths_keep_import_and_backfill_separate():
|
||||||
|
"""Import читает эталонный мир, а backfill пишет в прежний файл."""
|
||||||
|
from clickstream_generator.airflow_control import default_artifact_path
|
||||||
|
|
||||||
|
assert default_artifact_path("import") == (
|
||||||
|
"/opt/airflow/data/startup_history/reference-world.json.xz"
|
||||||
|
)
|
||||||
|
assert default_artifact_path("backfill") == (
|
||||||
|
"/opt/airflow/data/startup-history.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_control_env_uses_profile_duration_and_airflow_data_dir():
|
def test_build_control_env_uses_profile_duration_and_airflow_data_dir():
|
||||||
"""Пульт строит env для backfill из профиля и каталога Airflow."""
|
"""Пульт строит env для backfill из профиля и каталога Airflow."""
|
||||||
from clickstream_generator.airflow_control import build_control_env
|
from clickstream_generator.airflow_control import build_control_env
|
||||||
@@ -47,7 +59,7 @@ def test_import_env_requires_artifact_path_and_uses_backfill_contract():
|
|||||||
|
|
||||||
assert env["GEN_RUN_MODE"] == "backfill"
|
assert env["GEN_RUN_MODE"] == "backfill"
|
||||||
assert env["GEN_STATE_RESET"] == "true"
|
assert env["GEN_STATE_RESET"] == "true"
|
||||||
assert env["GEN_HISTORY_DURATION"] == "2d"
|
assert env["GEN_HISTORY_DURATION"] == "3d"
|
||||||
|
|
||||||
|
|
||||||
def test_next_day_env_uses_world_settings_and_current_manifest_boundary():
|
def test_next_day_env_uses_world_settings_and_current_manifest_boundary():
|
||||||
@@ -137,6 +149,11 @@ def test_next_day_precheck_requires_manifest_and_matching_state():
|
|||||||
with pytest.raises(RuntimeError, match="state.*T_end"):
|
with pytest.raises(RuntimeError, match="state.*T_end"):
|
||||||
assert_next_day_snapshot(manifest, state)
|
assert_next_day_snapshot(manifest, state)
|
||||||
|
|
||||||
|
manifest["model_t_end"] = state.model_timestamp.isoformat()
|
||||||
|
manifest["state"]["model_timestamp"] = state.model_timestamp.isoformat()
|
||||||
|
with pytest.raises(RuntimeError, match="повторно запустите import"):
|
||||||
|
assert_next_day_snapshot(manifest, state)
|
||||||
|
|
||||||
|
|
||||||
def test_target_dag_trigger_error_covers_missing_paused_and_ready_states():
|
def test_target_dag_trigger_error_covers_missing_paused_and_ready_states():
|
||||||
"""Проверка зависимого DAG различает три реальные ветки."""
|
"""Проверка зависимого DAG различает три реальные ветки."""
|
||||||
|
|||||||
@@ -119,14 +119,14 @@ class TestConfigDefaults:
|
|||||||
if orig_value is not None:
|
if orig_value is not None:
|
||||||
os.environ["GEN_TICK_SECONDS"] = orig_value
|
os.environ["GEN_TICK_SECONDS"] = orig_value
|
||||||
|
|
||||||
def test_launch_profile_defaults_to_ci(self, monkeypatch, data_dir):
|
def test_launch_profile_defaults_to_daily_wave(self, monkeypatch, data_dir):
|
||||||
"""По умолчанию выбран быстрый профиль запуска ci."""
|
"""По умолчанию выбран учебный профиль запуска daily-wave."""
|
||||||
monkeypatch.setenv("GEN_DATA_DIR", str(data_dir))
|
monkeypatch.setenv("GEN_DATA_DIR", str(data_dir))
|
||||||
monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False)
|
monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False)
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
assert config.launch_profile == "ci"
|
assert config.launch_profile == "daily-wave"
|
||||||
|
|
||||||
def test_launch_profile_can_be_read_from_env(self, monkeypatch, data_dir):
|
def test_launch_profile_can_be_read_from_env(self, monkeypatch, data_dir):
|
||||||
"""Выбранный профиль запуска попадает в конфигурацию для логов."""
|
"""Выбранный профиль запуска попадает в конфигурацию для логов."""
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_wave_is_the_default_for_api_and_cli(monkeypatch):
|
||||||
|
"""Ручные точки входа без профиля выбирают daily-wave."""
|
||||||
|
from clickstream_generator.launch import _parse_args, build_launch_env
|
||||||
|
|
||||||
|
monkeypatch.delenv("PROFILE", raising=False)
|
||||||
|
monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False)
|
||||||
|
|
||||||
|
assert build_launch_env("backfill")["GEN_LAUNCH_PROFILE"] == "daily-wave"
|
||||||
|
assert _parse_args(["backfill"]).profile == "daily-wave"
|
||||||
|
|
||||||
|
|
||||||
def test_duration_2d_sets_model_t_end_from_t0():
|
def test_duration_2d_sets_model_t_end_from_t0():
|
||||||
"""Длительность 2d считается от T0 без ручного GEN_MODEL_T_END."""
|
"""Длительность 2d считается от T0 без ручного GEN_MODEL_T_END."""
|
||||||
from clickstream_generator.launch import build_launch_env
|
from clickstream_generator.launch import build_launch_env
|
||||||
@@ -15,15 +26,15 @@ def test_duration_2d_sets_model_t_end_from_t0():
|
|||||||
assert env["GEN_MODEL_T_END"] == "2026-01-03T00: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():
|
def test_daily_wave_profile_uses_three_days_by_default():
|
||||||
"""Профиль daily-wave даёт историю с суточной волной."""
|
"""Профиль daily-wave даёт историю с суточной волной."""
|
||||||
from clickstream_generator.launch import build_launch_env
|
from clickstream_generator.launch import build_launch_env
|
||||||
|
|
||||||
env = build_launch_env("backfill", profile_name="daily-wave")
|
env = build_launch_env("backfill", profile_name="daily-wave")
|
||||||
|
|
||||||
assert env["GEN_LAUNCH_PROFILE"] == "daily-wave"
|
assert env["GEN_LAUNCH_PROFILE"] == "daily-wave"
|
||||||
assert env["GEN_HISTORY_DURATION"] == "2d"
|
assert env["GEN_HISTORY_DURATION"] == "3d"
|
||||||
assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00"
|
assert env["GEN_MODEL_T_END"] == "2026-01-04T00:00:00+00:00"
|
||||||
assert env["GEN_MODEL_TIME_SPEED"] == "60"
|
assert env["GEN_MODEL_TIME_SPEED"] == "60"
|
||||||
assert env["GEN_TICK_SECONDS"] == "1"
|
assert env["GEN_TICK_SECONDS"] == "1"
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from generator import (
|
|||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
from clickstream_generator.state import UnsupportedStateVersionError
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
from clickstream_generator.service import IncompatibleStateError
|
||||||
|
|
||||||
|
|
||||||
class TestBatchRecordWithDictConversion:
|
class TestBatchRecordWithDictConversion:
|
||||||
@@ -425,6 +426,10 @@ class TestGeneratorServiceBackfill:
|
|||||||
self, base_config
|
self, base_config
|
||||||
):
|
):
|
||||||
"""Backfill пишет [T0, T_end), state на T_end и повторяемый manifest."""
|
"""Backfill пишет [T0, T_end), state на T_end и повторяемый manifest."""
|
||||||
|
from clickstream_generator.startup_history_artifact import (
|
||||||
|
cumulative_counter_reference,
|
||||||
|
)
|
||||||
|
|
||||||
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
|
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
|
||||||
model_t_end = model_t0 + timedelta(minutes=3)
|
model_t_end = model_t0 + timedelta(minutes=3)
|
||||||
config = replace(
|
config = replace(
|
||||||
@@ -463,6 +468,11 @@ class TestGeneratorServiceBackfill:
|
|||||||
assert manifest["model_t0"] == model_t0.isoformat()
|
assert manifest["model_t0"] == model_t0.isoformat()
|
||||||
assert manifest["model_t_end"] == model_t_end.isoformat()
|
assert manifest["model_t_end"] == model_t_end.isoformat()
|
||||||
assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id
|
assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id
|
||||||
|
assert saved_state.cumulative_manifest_counters == (
|
||||||
|
cumulative_counter_reference(
|
||||||
|
manifest["cumulative_manifest_counters"]
|
||||||
|
)
|
||||||
|
)
|
||||||
assert manifest["topics"]["browser_events"]["rows"] == len(browser_events)
|
assert manifest["topics"]["browser_events"]["rows"] == len(browser_events)
|
||||||
for topic in (
|
for topic in (
|
||||||
"browser_events",
|
"browser_events",
|
||||||
@@ -516,6 +526,8 @@ class TestGeneratorServiceBackfill:
|
|||||||
self._run_backfill(config)
|
self._run_backfill(config)
|
||||||
|
|
||||||
artifact = load_startup_history_artifact(artifact_path)
|
artifact = load_startup_history_artifact(artifact_path)
|
||||||
|
assert "cumulative_manifest_counters" not in artifact["state"]
|
||||||
|
assert "cumulative_manifest_counters" not in artifact["manifest"]
|
||||||
state, manifest, topics, _ = validate_startup_history_artifact(
|
state, manifest, topics, _ = validate_startup_history_artifact(
|
||||||
artifact,
|
artifact,
|
||||||
expected_config=config,
|
expected_config=config,
|
||||||
@@ -960,7 +972,8 @@ class TestGeneratorServiceBackfill:
|
|||||||
with pytest.raises(RuntimeError, match="flush down"):
|
with pytest.raises(RuntimeError, match="flush down"):
|
||||||
service._run_backfill()
|
service._run_backfill()
|
||||||
|
|
||||||
service.manifest_manager.save.assert_called_once()
|
service.manifest_manager.save_counter_chunk.assert_called()
|
||||||
|
service.manifest_manager.save.assert_not_called()
|
||||||
service.manifest_manager.flush.assert_called_once()
|
service.manifest_manager.flush.assert_called_once()
|
||||||
service.state_manager.save.assert_not_called()
|
service.state_manager.save.assert_not_called()
|
||||||
service.state_manager.flush.assert_not_called()
|
service.state_manager.flush.assert_not_called()
|
||||||
@@ -1008,13 +1021,14 @@ class TestGeneratorServiceBackfill:
|
|||||||
class TestGeneratorServiceNextDay:
|
class TestGeneratorServiceNextDay:
|
||||||
"""Проверки ограниченной доливки следующего модельного дня."""
|
"""Проверки ограниченной доливки следующего модельного дня."""
|
||||||
|
|
||||||
def test_next_day_publishes_24h_then_state_then_cumulative_manifest(
|
def test_next_day_publishes_24h_then_chunks_manifest_and_state(
|
||||||
self, base_config
|
self, base_config
|
||||||
):
|
):
|
||||||
"""Next-day пишет [T_end, T_end+24h), затем state и manifest."""
|
"""Next-day пишет день, фрагменты множеств, manifest и затем state."""
|
||||||
from clickstream_generator.startup_history_artifact import (
|
from clickstream_generator.startup_history_artifact import (
|
||||||
StartupHistoryArtifactBuilder,
|
StartupHistoryArtifactBuilder,
|
||||||
build_manifest,
|
build_manifest,
|
||||||
|
cumulative_counter_reference,
|
||||||
)
|
)
|
||||||
|
|
||||||
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
@@ -1062,6 +1076,9 @@ class TestGeneratorServiceNextDay:
|
|||||||
}
|
}
|
||||||
initial_builder = StartupHistoryArtifactBuilder()
|
initial_builder = StartupHistoryArtifactBuilder()
|
||||||
initial_builder.add_batch(old_batch)
|
initial_builder.add_batch(old_batch)
|
||||||
|
state.cumulative_manifest_counters = cumulative_counter_reference(
|
||||||
|
initial_builder.counters.to_state()
|
||||||
|
)
|
||||||
manifest = build_manifest(config, initial_builder.counters, state)
|
manifest = build_manifest(config, initial_builder.counters, state)
|
||||||
|
|
||||||
published = {topic: [] for topic in old_batch}
|
published = {topic: [] for topic in old_batch}
|
||||||
@@ -1078,21 +1095,21 @@ class TestGeneratorServiceNextDay:
|
|||||||
service.state_manager = MagicMock()
|
service.state_manager = MagicMock()
|
||||||
service.state_manager.save.side_effect = lambda _state: order.append("state")
|
service.state_manager.save.side_effect = lambda _state: order.append("state")
|
||||||
service.manifest_manager = MagicMock()
|
service.manifest_manager = MagicMock()
|
||||||
|
service.manifest_manager.save_counter_chunk.side_effect = (
|
||||||
|
lambda _chunk_sha256, _chunk: order.append("counter_chunk")
|
||||||
|
)
|
||||||
service.manifest_manager.save.side_effect = (
|
service.manifest_manager.save.side_effect = (
|
||||||
lambda _manifest: order.append("manifest")
|
lambda _manifest: order.append("manifest")
|
||||||
)
|
)
|
||||||
|
|
||||||
class DataReader:
|
class DataReader:
|
||||||
def load(self):
|
def load(self):
|
||||||
return {
|
raise AssertionError("next-day не должен перечитывать Kafka")
|
||||||
topic: old_batch[topic] + published[topic]
|
|
||||||
for topic in old_batch
|
|
||||||
}
|
|
||||||
|
|
||||||
service.data_reader = DataReader()
|
service.data_reader = DataReader()
|
||||||
service.restore_from_startup_history(state, model_t_end=current_t_end)
|
service.restore_from_startup_history(state, model_t_end=current_t_end)
|
||||||
|
|
||||||
service._run_next_day(manifest)
|
service._run_next_day(manifest, state)
|
||||||
|
|
||||||
browser_events = published["browser_events"]
|
browser_events = published["browser_events"]
|
||||||
timestamps = [
|
timestamps = [
|
||||||
@@ -1114,18 +1131,21 @@ class TestGeneratorServiceNextDay:
|
|||||||
target_t_end.isoformat(),
|
target_t_end.isoformat(),
|
||||||
]
|
]
|
||||||
assert saved_manifest["totals"]["events"] == len(browser_events) + 1
|
assert saved_manifest["totals"]["events"] == len(browser_events) + 1
|
||||||
assert order == ["data", "state", "manifest"]
|
assert order == ["data", "counter_chunk", "manifest", "state"]
|
||||||
|
|
||||||
first_day_events = len(browser_events)
|
first_day_events = len(browser_events)
|
||||||
first_day_checksum = saved_manifest["topics"]["browser_events"][
|
first_day_checksum = saved_manifest["topics"]["browser_events"][
|
||||||
"checksum_sha256"
|
"checksum_sha256"
|
||||||
]
|
]
|
||||||
service._run_next_day(saved_manifest)
|
service._run_next_day(saved_manifest, saved_state)
|
||||||
|
|
||||||
second_state = service.state_manager.save.call_args.args[0]
|
second_state = service.state_manager.save.call_args.args[0]
|
||||||
second_manifest = service.manifest_manager.save.call_args.args[0]
|
second_manifest = service.manifest_manager.save.call_args.args[0]
|
||||||
expected_builder = StartupHistoryArtifactBuilder()
|
expected_builder = StartupHistoryArtifactBuilder()
|
||||||
expected_builder.add_batch(DataReader().load())
|
expected_builder.add_batch({
|
||||||
|
topic: old_batch[topic] + published[topic]
|
||||||
|
for topic in old_batch
|
||||||
|
})
|
||||||
assert second_state.model_timestamp == target_t_end + timedelta(hours=24)
|
assert second_state.model_timestamp == target_t_end + timedelta(hours=24)
|
||||||
assert second_manifest["boundaries"] == [
|
assert second_manifest["boundaries"] == [
|
||||||
model_t0.isoformat(),
|
model_t0.isoformat(),
|
||||||
@@ -1140,15 +1160,57 @@ class TestGeneratorServiceNextDay:
|
|||||||
!= first_day_checksum
|
!= first_day_checksum
|
||||||
)
|
)
|
||||||
assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics()
|
assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics()
|
||||||
|
assert second_manifest["totals"] == expected_builder.counters.to_manifest_totals()
|
||||||
|
assert second_state.cumulative_manifest_counters == (
|
||||||
|
cumulative_counter_reference(
|
||||||
|
second_manifest["cumulative_manifest_counters"]
|
||||||
|
)
|
||||||
|
)
|
||||||
assert order == [
|
assert order == [
|
||||||
"data",
|
"data",
|
||||||
"state",
|
"counter_chunk",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
"state",
|
||||||
"data",
|
"data",
|
||||||
"state",
|
"counter_chunk",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
"state",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_next_day_requires_cumulative_counters_from_import(self, base_config):
|
||||||
|
"""Старый локальный state требует повторного импорта, а не чтения Kafka."""
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
service = GeneratorService(config)
|
||||||
|
state = service.stream.to_state(
|
||||||
|
tick=1,
|
||||||
|
rng_state=service.generator.rng.getstate(),
|
||||||
|
last_batch_id="startup-history-old",
|
||||||
|
last_timestamp=config.model_t_end,
|
||||||
|
model_timestamp=config.model_t_end,
|
||||||
|
model_t0=config.model_t0,
|
||||||
|
)
|
||||||
|
service.publisher = MagicMock()
|
||||||
|
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(IncompatibleStateError, match="повторно запустите import"):
|
||||||
|
service._run_next_day(manifest, state)
|
||||||
|
|
||||||
|
service.publisher.publish.assert_not_called()
|
||||||
|
|
||||||
def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config):
|
def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config):
|
||||||
"""Ошибка data-топика оставляет обе точки фиксации без изменений."""
|
"""Ошибка data-топика оставляет обе точки фиксации без изменений."""
|
||||||
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
@@ -1177,8 +1239,29 @@ class TestGeneratorServiceNextDay:
|
|||||||
"boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()],
|
"boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from clickstream_generator.startup_history_artifact import (
|
||||||
|
ManifestCounters,
|
||||||
|
cumulative_counter_reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = service.stream.to_state(
|
||||||
|
tick=1,
|
||||||
|
rng_state=service.generator.rng.getstate(),
|
||||||
|
last_batch_id="startup-history-test",
|
||||||
|
last_timestamp=config.model_t_end,
|
||||||
|
model_timestamp=config.model_t_end,
|
||||||
|
model_t0=config.model_t0,
|
||||||
|
)
|
||||||
|
empty_counters = ManifestCounters()
|
||||||
|
manifest["cumulative_manifest_counters"] = empty_counters.to_state()
|
||||||
|
manifest["topics"] = empty_counters.to_manifest_topics()
|
||||||
|
manifest["totals"] = empty_counters.to_manifest_totals()
|
||||||
|
state.cumulative_manifest_counters = cumulative_counter_reference(
|
||||||
|
manifest["cumulative_manifest_counters"]
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="Публикация next-day не удалась"):
|
with pytest.raises(RuntimeError, match="Публикация next-day не удалась"):
|
||||||
service._run_next_day(manifest)
|
service._run_next_day(manifest, state)
|
||||||
|
|
||||||
service.state_manager.save.assert_not_called()
|
service.state_manager.save.assert_not_called()
|
||||||
service.manifest_manager.save.assert_not_called()
|
service.manifest_manager.save.assert_not_called()
|
||||||
|
|||||||
@@ -4,12 +4,56 @@
|
|||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import lzma
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from generator import GeneratorState
|
from generator import GeneratorState
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_json_roundtrip_keeps_existing_serialization(tmp_path):
|
||||||
|
"""Обычный JSON по-прежнему пишется без сжатия и читается обратно."""
|
||||||
|
from clickstream_generator.startup_history_artifact import (
|
||||||
|
load_startup_history_artifact,
|
||||||
|
write_startup_history_artifact,
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = {"text": "мир", "nested": {"value": 42}}
|
||||||
|
path = tmp_path / "artifact.json"
|
||||||
|
|
||||||
|
write_startup_history_artifact(path, artifact)
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == json.dumps(
|
||||||
|
artifact,
|
||||||
|
ensure_ascii=True,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
assert load_startup_history_artifact(path) == artifact
|
||||||
|
|
||||||
|
|
||||||
|
def test_xz_artifact_roundtrip(tmp_path):
|
||||||
|
"""XZ-артефакт потоково пишется и читается без изменения данных."""
|
||||||
|
from clickstream_generator.startup_history_artifact import (
|
||||||
|
load_startup_history_artifact,
|
||||||
|
write_startup_history_artifact,
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = {"text": "мир", "nested": {"value": 42}}
|
||||||
|
path = tmp_path / "artifact.json.xz"
|
||||||
|
|
||||||
|
write_startup_history_artifact(path, artifact)
|
||||||
|
|
||||||
|
assert load_startup_history_artifact(path) == artifact
|
||||||
|
with lzma.open(path, "rt", encoding="utf-8") as input_file:
|
||||||
|
assert input_file.read() == json.dumps(
|
||||||
|
artifact,
|
||||||
|
ensure_ascii=True,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _state() -> GeneratorState:
|
def _state() -> GeneratorState:
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -67,6 +111,144 @@ def _batch() -> dict[str, list[dict]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_counters_equal_full_recompute():
|
||||||
|
"""Накопление по частям даёт те же числа, что полный пересчёт мира."""
|
||||||
|
from clickstream_generator.startup_history_artifact import ManifestCounters
|
||||||
|
|
||||||
|
first_day = _batch()
|
||||||
|
second_day = {
|
||||||
|
"browser_events": [
|
||||||
|
{
|
||||||
|
"event_id": "event-2",
|
||||||
|
"click_id": "click-1",
|
||||||
|
"user_domain_id": "user-1",
|
||||||
|
"event_timestamp": "2026-01-02 00:00:00.000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "event-3",
|
||||||
|
"click_id": "click-2",
|
||||||
|
"user_domain_id": "user-2",
|
||||||
|
"event_timestamp": "2026-01-02 00:01:00.000000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"location_events": [
|
||||||
|
{"event_id": "event-2", "page_url_path": "/cart"},
|
||||||
|
{"event_id": "event-3", "page_url_path": "/home"},
|
||||||
|
],
|
||||||
|
"device_events": [
|
||||||
|
{"click_id": "click-1", "user_domain_id": "user-1"},
|
||||||
|
{"click_id": "click-2", "user_domain_id": "user-2"},
|
||||||
|
],
|
||||||
|
"geo_events": [
|
||||||
|
{"click_id": "click-1", "country": "RU"},
|
||||||
|
{"click_id": "click-2", "country": "KZ"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
incremental = ManifestCounters()
|
||||||
|
incremental.add_batch(first_day)
|
||||||
|
incremental = ManifestCounters.from_state(
|
||||||
|
incremental.to_state(),
|
||||||
|
known_click_ids={"click-1"},
|
||||||
|
known_user_ids={"user-1"},
|
||||||
|
)
|
||||||
|
incremental.add_batch(second_day)
|
||||||
|
|
||||||
|
full = ManifestCounters()
|
||||||
|
full.add_batch({
|
||||||
|
topic: first_day[topic] + second_day[topic]
|
||||||
|
for topic in first_day
|
||||||
|
})
|
||||||
|
|
||||||
|
assert incremental.to_manifest_topics() == full.to_manifest_topics()
|
||||||
|
assert incremental.to_manifest_totals() == full.to_manifest_totals()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_id_sets_are_split_into_bounded_hash_chain(monkeypatch):
|
||||||
|
"""Manifest ссылается на цепочку малых точных фрагментов, а не растёт сам."""
|
||||||
|
from clickstream_generator import startup_history_artifact as module
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "ID_SET_CHUNK_SIZE", 2)
|
||||||
|
counters = module.ManifestCounters()
|
||||||
|
batch = _batch()
|
||||||
|
batch["browser_events"].extend([
|
||||||
|
{
|
||||||
|
"event_id": "event-2",
|
||||||
|
"click_id": "click-2",
|
||||||
|
"event_timestamp": "2026-01-01 00:01:00.000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "event-3",
|
||||||
|
"click_id": "click-3",
|
||||||
|
"event_timestamp": "2026-01-01 00:02:00.000000",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
counters.add_batch(batch)
|
||||||
|
|
||||||
|
state, chunks = counters.to_state_with_chunks()
|
||||||
|
|
||||||
|
assert len(chunks) == 2
|
||||||
|
assert state["totals"] == {"visits": 3, "users": 1}
|
||||||
|
assert state["id_sets"] == {
|
||||||
|
"storage": "manifest-topic-sha256-chain-v1",
|
||||||
|
"latest_sha256": chunks[-1][0],
|
||||||
|
"chunks": 2,
|
||||||
|
"click_ids": 3,
|
||||||
|
"user_ids": 1,
|
||||||
|
}
|
||||||
|
assert chunks[0][1]["previous_sha256"] is None
|
||||||
|
assert chunks[1][1]["previous_sha256"] == chunks[0][0]
|
||||||
|
assert "click-1" not in json.dumps(state)
|
||||||
|
|
||||||
|
restored = module.ManifestCounters.from_state(
|
||||||
|
state,
|
||||||
|
known_click_ids={"click-3"},
|
||||||
|
known_user_ids={"user-1"},
|
||||||
|
)
|
||||||
|
restored.add_batch({
|
||||||
|
"browser_events": [
|
||||||
|
{
|
||||||
|
"event_id": "event-4",
|
||||||
|
"click_id": "click-3",
|
||||||
|
"event_timestamp": "2026-01-02 00:00:00.000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "event-5",
|
||||||
|
"click_id": "click-4",
|
||||||
|
"event_timestamp": "2026-01-02 00:01:00.000000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"location_events": [],
|
||||||
|
"device_events": [{"click_id": "click-4", "user_domain_id": "user-2"}],
|
||||||
|
"geo_events": [],
|
||||||
|
})
|
||||||
|
next_state, next_chunks = restored.to_state_with_chunks()
|
||||||
|
|
||||||
|
assert next_state["totals"] == {"visits": 4, "users": 2}
|
||||||
|
assert len(next_chunks) == 1
|
||||||
|
assert next_chunks[0][1]["previous_sha256"] == chunks[-1][0]
|
||||||
|
assert next_state["id_sets"]["chunks"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_counter_chunk_has_explicit_kafka_size_and_hash_guards():
|
||||||
|
"""Большой или неверно адресованный фрагмент падает до отправки в Kafka."""
|
||||||
|
from clickstream_generator.kafka_io import (
|
||||||
|
MAX_COMPACT_MESSAGE_BYTES,
|
||||||
|
KafkaStartupHistoryManifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = object.__new__(KafkaStartupHistoryManifest)
|
||||||
|
with pytest.raises(ValueError, match="безопасный предел"):
|
||||||
|
manager._assert_message_size(
|
||||||
|
{"payload": "x" * MAX_COMPACT_MESSAGE_BYTES},
|
||||||
|
"фрагмент",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="хеш.*не совпадает"):
|
||||||
|
manager.save_counter_chunk("0" * 64, {"version": "1.0"})
|
||||||
|
|
||||||
|
assert manager.COUNTER_TOPIC != manager.MANIFEST_TOPIC
|
||||||
|
|
||||||
|
|
||||||
def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config):
|
def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config):
|
||||||
"""Артефакт хранит события, state и manifest одним проверяемым набором."""
|
"""Артефакт хранит события, state и manifest одним проверяемым набором."""
|
||||||
from clickstream_generator.startup_history_artifact import (
|
from clickstream_generator.startup_history_artifact import (
|
||||||
@@ -130,6 +312,43 @@ def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config):
|
|||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_artifact_checksum_remains_valid(base_config):
|
||||||
|
"""Эталонный артефакт со старой суммой не требует пересборки."""
|
||||||
|
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, builder.counters, state)
|
||||||
|
for topic, events in builder.topics.items():
|
||||||
|
checksum = hashlib.sha256()
|
||||||
|
for event in events:
|
||||||
|
checksum.update(
|
||||||
|
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
|
||||||
|
)
|
||||||
|
manifest["topics"][topic]["checksum_sha256"] = checksum.hexdigest()
|
||||||
|
|
||||||
|
artifact = builder.to_artifact(manifest=manifest, state=state)
|
||||||
|
|
||||||
|
_, loaded_manifest, _, _ = validate_startup_history_artifact(
|
||||||
|
artifact,
|
||||||
|
expected_config=config,
|
||||||
|
)
|
||||||
|
assert loaded_manifest["topics"] == artifact["manifest"]["topics"]
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_and_artifact_show_launch_profile(base_config):
|
def test_manifest_and_artifact_show_launch_profile(base_config):
|
||||||
"""Manifest и файл артефакта показывают выбранный профиль запуска."""
|
"""Manifest и файл артефакта показывают выбранный профиль запуска."""
|
||||||
from clickstream_generator.startup_history_artifact import (
|
from clickstream_generator.startup_history_artifact import (
|
||||||
@@ -329,6 +548,8 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config):
|
|||||||
manifest=build_manifest(config=config, counters=builder.counters, state=state),
|
manifest=build_manifest(config=config, counters=builder.counters, state=state),
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
|
assert "cumulative_manifest_counters" not in artifact["state"]
|
||||||
|
assert "cumulative_manifest_counters" not in artifact["manifest"]
|
||||||
raw_value = (
|
raw_value = (
|
||||||
'{"event_timestamp":"2026-01-01 00:00:00.000000",'
|
'{"event_timestamp":"2026-01-01 00:00:00.000000",'
|
||||||
'"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}'
|
'"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}'
|
||||||
@@ -348,9 +569,15 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
class CompactWriter:
|
class CompactWriter:
|
||||||
|
def __init__(self):
|
||||||
|
self.chunks = []
|
||||||
|
|
||||||
def save(self, value):
|
def save(self, value):
|
||||||
self.saved = value
|
self.saved = value
|
||||||
|
|
||||||
|
def save_counter_chunk(self, chunk_sha256, chunk):
|
||||||
|
self.chunks.append((chunk_sha256, chunk))
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -566,8 +793,10 @@ def test_import_rolls_back_kafka_topics_after_partial_publish(base_config):
|
|||||||
def test_import_replays_events_and_compact_topics(base_config):
|
def test_import_replays_events_and_compact_topics(base_config):
|
||||||
"""Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse."""
|
"""Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse."""
|
||||||
from clickstream_generator.startup_history_artifact import (
|
from clickstream_generator.startup_history_artifact import (
|
||||||
|
ManifestCounters,
|
||||||
StartupHistoryArtifactBuilder,
|
StartupHistoryArtifactBuilder,
|
||||||
build_manifest,
|
build_manifest,
|
||||||
|
cumulative_counter_reference,
|
||||||
import_startup_history_artifact,
|
import_startup_history_artifact,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -603,10 +832,14 @@ def test_import_replays_events_and_compact_topics(base_config):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.saved = None
|
self.saved = None
|
||||||
self.flushed = False
|
self.flushed = False
|
||||||
|
self.chunks = []
|
||||||
|
|
||||||
def save(self, value):
|
def save(self, value):
|
||||||
self.saved = value
|
self.saved = value
|
||||||
|
|
||||||
|
def save_counter_chunk(self, chunk_sha256, chunk):
|
||||||
|
self.chunks.append((chunk_sha256, chunk))
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
self.flushed = True
|
self.flushed = True
|
||||||
|
|
||||||
@@ -635,7 +868,27 @@ def test_import_replays_events_and_compact_topics(base_config):
|
|||||||
)
|
)
|
||||||
assert result["events"] == 4
|
assert result["events"] == 4
|
||||||
assert state_manager.saved.last_batch_id == state.last_batch_id
|
assert state_manager.saved.last_batch_id == state.last_batch_id
|
||||||
|
assert state_manager.saved.cumulative_manifest_counters is not None
|
||||||
assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id
|
assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id
|
||||||
|
assert state_manager.saved.cumulative_manifest_counters == (
|
||||||
|
cumulative_counter_reference(
|
||||||
|
manifest_manager.saved["cumulative_manifest_counters"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seeded = ManifestCounters.from_state(
|
||||||
|
manifest_manager.saved["cumulative_manifest_counters"],
|
||||||
|
known_click_ids={"click-1"},
|
||||||
|
known_user_ids={"user-1"},
|
||||||
|
)
|
||||||
|
assert seeded.click_ids == {"click-1"}
|
||||||
|
assert seeded.user_ids == {"user-1"}
|
||||||
|
assert len(manifest_manager.chunks) == 1
|
||||||
|
assert (
|
||||||
|
manifest_manager.saved["cumulative_manifest_counters"]["id_sets"][
|
||||||
|
"latest_sha256"
|
||||||
|
]
|
||||||
|
== manifest_manager.chunks[0][0]
|
||||||
|
)
|
||||||
assert publisher.flushed
|
assert publisher.flushed
|
||||||
assert state_manager.flushed
|
assert state_manager.flushed
|
||||||
assert manifest_manager.flushed
|
assert manifest_manager.flushed
|
||||||
@@ -227,6 +227,25 @@ class TestGeneratorState:
|
|||||||
|
|
||||||
assert next_values == values_after
|
assert next_values == values_after
|
||||||
|
|
||||||
|
def test_state_roundtrip_keeps_cumulative_manifest_counters(self):
|
||||||
|
"""State сохраняет накопительные числа для следующего дня."""
|
||||||
|
counter_reference = {
|
||||||
|
"version": "1.0",
|
||||||
|
"manifest_sha256": "a" * 64,
|
||||||
|
}
|
||||||
|
state = GeneratorState(
|
||||||
|
tick=1,
|
||||||
|
rng_state=_make_valid_rng_state(),
|
||||||
|
last_batch_id="startup-history-test",
|
||||||
|
last_timestamp=datetime.now(timezone.utc),
|
||||||
|
population=_minimal_population(),
|
||||||
|
cumulative_manifest_counters=counter_reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = GeneratorState.from_dict(json.loads(json.dumps(state.to_dict())))
|
||||||
|
|
||||||
|
assert restored.cumulative_manifest_counters == counter_reference
|
||||||
|
|
||||||
def test_state_roundtrip_keeps_population_and_active_visits(self):
|
def test_state_roundtrip_keeps_population_and_active_visits(self):
|
||||||
"""State хранит популяцию и активные визиты в JSON."""
|
"""State хранит популяцию и активные визиты в JSON."""
|
||||||
rng_state = _make_valid_rng_state(42)
|
rng_state = _make_valid_rng_state(42)
|
||||||
|
|||||||
+73
-34
@@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Контракты DAG world_init и world_next_day без запуска Airflow."""
|
||||||
Контракт DAG generator_control без запуска Airflow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,7 +6,8 @@ from pathlib import Path
|
|||||||
from clickstream_generator.launch import PROFILES
|
from clickstream_generator.launch import PROFILES
|
||||||
|
|
||||||
|
|
||||||
DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "generator_control_dag.py"
|
DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_init_dag.py"
|
||||||
|
NEXT_DAY_DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_next_day_dag.py"
|
||||||
REPO_ROOT = Path(__file__).parents[2]
|
REPO_ROOT = Path(__file__).parents[2]
|
||||||
|
|
||||||
|
|
||||||
@@ -42,11 +41,30 @@ def _declared_param_types() -> dict[str, set[str]]:
|
|||||||
return param_types
|
return param_types
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_param_defaults() -> dict[str, object]:
|
||||||
|
param_defaults = {}
|
||||||
|
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"
|
||||||
|
and value.args
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
param_defaults[key.value] = ast.literal_eval(value.args[0])
|
||||||
|
return param_defaults
|
||||||
|
|
||||||
|
|
||||||
def test_dag_file_exists_and_uses_dynamic_profiles():
|
def test_dag_file_exists_and_uses_dynamic_profiles():
|
||||||
"""DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
|
"""DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "dag_id=\"generator_control\"" in text
|
assert "dag_id=\"world_init\"" in text
|
||||||
assert "sorted(PROFILES)" in text
|
assert "sorted(PROFILES)" in text
|
||||||
for profile in PROFILES:
|
for profile in PROFILES:
|
||||||
assert profile not in {"hardcoded-profile"}
|
assert profile not in {"hardcoded-profile"}
|
||||||
@@ -55,12 +73,15 @@ def test_dag_file_exists_and_uses_dynamic_profiles():
|
|||||||
def test_trigger_form_has_expected_param_enums():
|
def test_trigger_form_has_expected_param_enums():
|
||||||
"""Форма запуска ограничивает операции и профили."""
|
"""Форма запуска ограничивает операции и профили."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
param_defaults = _declared_param_defaults()
|
||||||
|
|
||||||
assert 'enum=["backfill", "import", "next-day", "check"]' in text
|
assert 'enum=["backfill", "import", "check"]' in text
|
||||||
|
assert param_defaults["operation"] == "import"
|
||||||
assert "enum=sorted(PROFILES)" in text
|
assert "enum=sorted(PROFILES)" in text
|
||||||
|
assert param_defaults["profile"] == "daily-wave"
|
||||||
assert '"duration": Param(' in text
|
assert '"duration": Param(' in text
|
||||||
assert '"artifact_path": Param(' in text
|
assert '"artifact_path": Param(' in text
|
||||||
assert '"expected_t_end": Param(' in text
|
assert '"expected_t_end": Param(' not in text
|
||||||
|
|
||||||
|
|
||||||
def test_trigger_form_marks_only_optional_params_as_nullable():
|
def test_trigger_form_marks_only_optional_params_as_nullable():
|
||||||
@@ -72,7 +93,6 @@ def test_trigger_form_marks_only_optional_params_as_nullable():
|
|||||||
"seed",
|
"seed",
|
||||||
"model_time_speed",
|
"model_time_speed",
|
||||||
"artifact_path",
|
"artifact_path",
|
||||||
"expected_t_end",
|
|
||||||
}:
|
}:
|
||||||
assert "null" in param_types[name]
|
assert "null" in param_types[name]
|
||||||
for name in {"operation", "profile"}:
|
for name in {"operation", "profile"}:
|
||||||
@@ -80,7 +100,7 @@ def test_trigger_form_marks_only_optional_params_as_nullable():
|
|||||||
|
|
||||||
|
|
||||||
def test_dag_branches_and_waits_for_etl_completion():
|
def test_dag_branches_and_waits_for_etl_completion():
|
||||||
"""Backfill/import/next-day запускают ETL и ждут завершения перед check."""
|
"""Backfill/import запускают ETL и ждут завершения перед check."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "BranchPythonOperator" in text
|
assert "BranchPythonOperator" in text
|
||||||
@@ -91,41 +111,43 @@ def test_dag_branches_and_waits_for_etl_completion():
|
|||||||
assert 'failed_states=["failed"]' in text
|
assert 'failed_states=["failed"]' in text
|
||||||
|
|
||||||
|
|
||||||
def test_next_day_has_own_boundary_precheck_and_serial_execution():
|
def test_world_next_day_is_parameterless_paused_half_hour_dag():
|
||||||
"""Next-day не использует clean-guard и не допускает параллельных запусков."""
|
"""DAG следующего дня запускается пустой формой каждые полчаса."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = NEXT_DAY_DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "schedule=None" in text
|
assert 'dag_id="world_next_day"' in text
|
||||||
|
assert 'schedule="*/30 * * * *"' in text
|
||||||
|
assert "catchup=False" in text
|
||||||
|
assert "is_paused_upon_creation=True" in text
|
||||||
assert "max_active_runs=1" in text
|
assert "max_active_runs=1" in text
|
||||||
assert 'return "check_etl_not_paused_before_next_day"' in text
|
assert "params=" not in text
|
||||||
assert "assert_next_day_snapshot" in text
|
assert "precheck_next_day" in text
|
||||||
assert "assert_expected_t_end" in text
|
assert "run_next_day_task" in text
|
||||||
next_day_precheck = text.split("def precheck_next_day", maxsplit=1)[1].split(
|
assert "TriggerDagRunOperator" in text
|
||||||
"\ndef ", maxsplit=1
|
assert "wait_for_completion=True" in text
|
||||||
)[0]
|
assert "check_manifest_task" in text
|
||||||
assert "assert_stand_clean" not in next_day_precheck
|
assert "check_etl_not_paused >> precheck" in text
|
||||||
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():
|
def test_world_dags_precheck_etl_dag_not_paused_before_waiting():
|
||||||
"""Пульт проверяет паузу etl_pipeline до долгого ожидания."""
|
"""Пульт проверяет паузу etl_pipeline до долгого ожидания."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
shared_tasks = (
|
||||||
|
REPO_ROOT / "airflow" / "dags" / "utils" / "startup_history_tasks.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "assert_target_dag_not_paused" in text
|
assert "assert_target_dag_not_paused" in text
|
||||||
assert 'ETL_DAG_ID = "etl_pipeline"' in text
|
assert 'ETL_DAG_ID = "etl_pipeline"' in text
|
||||||
assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text
|
assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text
|
||||||
assert "session.query(DagModel)" in text
|
assert "session.query(DagModel)" in shared_tasks
|
||||||
assert "DagModel.dag_id == dag_id" in text
|
assert "DagModel.dag_id == dag_id" in shared_tasks
|
||||||
assert "target_dag_trigger_error" in text
|
assert "target_dag_trigger_error" in shared_tasks
|
||||||
assert "Airflow 2.10.5" in text
|
assert "Airflow 2.10.5" in shared_tasks
|
||||||
assert "fail_when_dag_is_paused" in text
|
assert "fail_when_dag_is_paused" in shared_tasks
|
||||||
assert 'return "check_etl_not_paused_before_backfill"' 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_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_backfill >> precheck_backfill_task" in text
|
||||||
assert "check_etl_not_paused_before_import >> precheck_import_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(
|
assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index(
|
||||||
"precheck_backfill_task >> backfill_task"
|
"precheck_backfill_task >> backfill_task"
|
||||||
)
|
)
|
||||||
@@ -149,15 +171,16 @@ def test_make_up_rebuilds_airflow_images_after_repo_update():
|
|||||||
assert "superset-init" not in up_block
|
assert "superset-init" not in up_block
|
||||||
|
|
||||||
|
|
||||||
def test_readme_quick_start_lists_prerequisites_and_runs_ddl_before_generator_control():
|
def test_readme_quick_start_creates_schema_via_ddl_init_before_world_init():
|
||||||
"""Быстрый старт называет инструменты и DDL до generator_control."""
|
"""Быстрый старт: терминал — только make up, схему создаёт ddl_init до world_init."""
|
||||||
text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "uv" in text
|
assert "uv" in text
|
||||||
assert "Docker" in text
|
assert "Docker" in text
|
||||||
assert "docker compose" in text
|
assert "docker compose" in text
|
||||||
assert "make up\nmake ddl" in text
|
assert "make ddl" not in text
|
||||||
assert text.index("make ddl") < text.index("generator_control")
|
quick_start = text.split("## Быстрый старт", maxsplit=1)[1]
|
||||||
|
assert quick_start.index("ddl_init") < quick_start.index("world_init")
|
||||||
|
|
||||||
|
|
||||||
def test_course_readme_lists_uv_before_first_command():
|
def test_course_readme_lists_uv_before_first_command():
|
||||||
@@ -226,6 +249,22 @@ def test_console_generator_paths_precheck_clean_stand_before_writes():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_artifact_is_import_default_only():
|
||||||
|
"""Экспорт не может по умолчанию перезаписать эталонный мир."""
|
||||||
|
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
export_script = (
|
||||||
|
REPO_ROOT / "scripts" / "export_startup_history_artifact.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
import_script = (
|
||||||
|
REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "STARTUP_HISTORY_EXPORT_ARTIFACT" in makefile
|
||||||
|
assert "STARTUP_HISTORY_IMPORT_ARTIFACT" in makefile
|
||||||
|
assert "/tmp/clickstream-startup-history.json" in export_script
|
||||||
|
assert "data/startup_history/reference-world.json.xz" in import_script
|
||||||
|
|
||||||
|
|
||||||
def test_console_continue_checks_state_or_clean_stand_before_live_start():
|
def test_console_continue_checks_state_or_clean_stand_before_live_start():
|
||||||
"""Continue не стартует новый live-мир на грязном стенде без state."""
|
"""Continue не стартует новый live-мир на грязном стенде без state."""
|
||||||
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
|
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
|
||||||
@@ -26,7 +26,8 @@ fi
|
|||||||
if [[ "${CHECK_MODE}" == "clean" ]]; then
|
if [[ "${CHECK_MODE}" == "clean" ]]; then
|
||||||
echo "Предпроверка: live-генератор не должен быть запущен."
|
echo "Предпроверка: live-генератор не должен быть запущен."
|
||||||
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
||||||
uv run python -m clickstream_generator.stand_clean \
|
uv run --with-requirements generator/requirements.txt \
|
||||||
|
python -m clickstream_generator.stand_clean \
|
||||||
--mode live \
|
--mode live \
|
||||||
--live-metrics-url "${GENERATOR_METRICS_URL_HOST}"
|
--live-metrics-url "${GENERATOR_METRICS_URL_HOST}"
|
||||||
fi
|
fi
|
||||||
@@ -54,7 +55,8 @@ fi
|
|||||||
KAFKA_BOOTSTRAP_SERVERS="${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
KAFKA_BOOTSTRAP_SERVERS="${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
||||||
GEN_DATA_DIR="${REPO_ROOT}/data" \
|
GEN_DATA_DIR="${REPO_ROOT}/data" \
|
||||||
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
||||||
uv run python -m clickstream_generator.stand_clean \
|
uv run --with-requirements generator/requirements.txt \
|
||||||
|
python -m clickstream_generator.stand_clean \
|
||||||
--mode "${CHECK_MODE}" \
|
--mode "${CHECK_MODE}" \
|
||||||
--kafka-bootstrap-servers "${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
--kafka-bootstrap-servers "${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
||||||
--stg-counts "${stg_counts}"
|
--stg-counts "${stg_counts}"
|
||||||
@@ -27,7 +27,7 @@ clickhouse_datetime_literal() {
|
|||||||
|
|
||||||
[[ -s "${ARTIFACT}" ]] || fail "артефакт не найден или пуст: ${ARTIFACT}"
|
[[ -s "${ARTIFACT}" ]] || fail "артефакт не найден или пуст: ${ARTIFACT}"
|
||||||
|
|
||||||
ARTIFACT_DIR="$(dirname "${ARTIFACT}")"
|
ARTIFACT_DIR="$(cd "$(dirname "${ARTIFACT}")" && pwd)"
|
||||||
ARTIFACT_FILE="$(basename "${ARTIFACT}")"
|
ARTIFACT_FILE="$(basename "${ARTIFACT}")"
|
||||||
|
|
||||||
manifest_summary="$(${COMPOSE_BIN} run --rm --no-deps \
|
manifest_summary="$(${COMPOSE_BIN} run --rm --no-deps \
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|||||||
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
||||||
CLEAN_START="${CLEAN_START:-1}"
|
CLEAN_START="${CLEAN_START:-1}"
|
||||||
ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}"
|
ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}"
|
||||||
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}"
|
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}"
|
||||||
duration_args=()
|
duration_args=()
|
||||||
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
||||||
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
||||||
ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}"
|
ARTIFACT="${ARTIFACT:-data/startup_history/reference-world.json.xz}"
|
||||||
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}"
|
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}"
|
||||||
duration_args=()
|
duration_args=()
|
||||||
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
||||||
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
||||||
@@ -31,7 +31,7 @@ if [[ ! -s "${ARTIFACT}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ARTIFACT_DIR="$(dirname "${ARTIFACT}")"
|
ARTIFACT_DIR="$(cd "$(dirname "${ARTIFACT}")" && pwd)"
|
||||||
ARTIFACT_FILE="$(basename "${ARTIFACT}")"
|
ARTIFACT_FILE="$(basename "${ARTIFACT}")"
|
||||||
|
|
||||||
echo "=== Импорт стартовой истории ==="
|
echo "=== Импорт стартовой истории ==="
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
|||||||
CLEAN_START="${CLEAN_START:-1}"
|
CLEAN_START="${CLEAN_START:-1}"
|
||||||
WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}"
|
WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}"
|
||||||
WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-10}"
|
WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-10}"
|
||||||
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}"
|
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}"
|
||||||
duration_args=()
|
duration_args=()
|
||||||
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
||||||
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
||||||
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}"
|
PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}"
|
||||||
duration_args=()
|
duration_args=()
|
||||||
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then
|
||||||
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
duration_args=(--duration "${GEN_HISTORY_DURATION}")
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ def test_generated_history_check_uses_actual_manifest_boundary_and_profile():
|
|||||||
assert "kafka-manifest-summary" in script
|
assert "kafka-manifest-summary" in script
|
||||||
assert "manifest_model_t_end" in script
|
assert "manifest_model_t_end" in script
|
||||||
assert "CH_MODEL_T_END=\"$(clickhouse_datetime_literal \"${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 "PROFILE ?= daily-wave" in makefile
|
||||||
assert "GEN_LAUNCH_PROFILE" in makefile
|
assert "GEN_LAUNCH_PROFILE" in makefile
|
||||||
assert "PROFILE" in makefile
|
assert "PROFILE" in makefile
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user