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 отдельно проверяет стык, где переходящий визит гарантирован сценарием.