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-круг выполнен, новых блокирующих находок нет.