# Модельное время на практике: сохранение состояния, заливка прошлого и стартовая история Дата: 2026-06-14 Статус: Draft Связано: [ADR-0005](../adr/0005-generator-model-clock.md) (решение про модельные часы — эта спека его дорабатывает), [ADR-0006](../adr/0006-generation-as-sole-analytics-source.md) (стартовая история как источник аналитики), мат-спека [`2026-06-10-generator-math-model.md`](./2026-06-10-generator-math-model.md) (её разделы «Персистентность через рестарты» и «Воспроизводимость» здесь **приводятся в соответствие** с модельным временем — не повторяются, а переописываются ссылкой), [`CONTEXT.md`](../../CONTEXT.md), задача [`06-state-v2-and-restart`](../../.scratch/feature-data-generator/issues/06-state-v2-and-restart.md) (сохранение состояния построено на настенных часах). Уровень документа — **модель и правила**, как в мат-спеке. Раздел «Рабочий контракт реализации» ниже фиксирует внешний интерфейс для задач 02–06. Внутренние классы, функции и разбиение кода остаются за исполнителем. ## Проблема ADR-0005 решил отвязать время генератора от реальных часов и ввёл три скорости его хода: обычную (×1), ускоренную (×K) и мгновенную «промотку» прошлого. Но два правила старой модели — как генератор сохраняет состояние между перезапусками и как добивается повторяемости — описаны от реальных часов. А ADR-0006 сделал стартовую историю (готовое сгенерированное прошлое) единственным источником данных стенда. Осталось довести модель до конца: как работает промотка прошлого, его заморозка и непрерывный запуск стенда с этого момента; как при этом меняются правила сохранения состояния и повторяемости; и как всё это проверить. Всё это — **один механизм**: промотать прошлое → заморозить → продолжить живьём. Это один и тот же путь сохранения и восстановления, просто с разных сторон. ## Цели - **Дать генератору собственные часы и сделать поток повторяемым.** Генератор должен отсчитывать время от своей точки отсчёта (ниже — «стартовая модельная точка `T0`»), а не от реальных часов компьютера. Тогда при одних и тех же настройках он каждый раз порождает один и тот же поток — это нужно для тестов и для повторяемых уроков. - **Переписать правила сохранения и восстановления состояния по этим часам.** Сейчас они завязаны на реальное время. Главное правило — «если посетитель молчал дольше 30 минут, считаем, что он ушёл» — должно мерить эти 30 минут по часам генератора. И поток больше не должен зависеть от того, в котором часу реального дня запущен генератор. - **Научиться быстро «проматывать» прошлое и замораживать его как стартовый набор данных.** Генератор прокручивает время без пауз от точки отсчёта до нужного момента, порождает события прошлого и сохраняет «слепок» своего состояния. Этот замороженный набор — то, с чего свежий стенд начинает жить, уже имея историю: здоровую пирамиду «пользователей меньше, чем визитов, а визитов меньше, чем событий» с первой минуты. - **Описать, как всё это проверить — в два шага.** Сначала числами: агент поднимает стенд и сверяет данные в ClickHouse. Потом глазами: человек смотрит на дашборды и видит, что распределение похоже на задуманное, а стенд «дышит» во времени. ## Чего здесь не делаем - **Не учим генератор придумывать «фактуру» сам** (браузеры, гео, устройства, метки кампаний). Это отдельная спека — следствие ADR-0006. Пока её нет, стартовая история берёт фактуру из статического сида; это осознанно временно. - **Не описываем здесь перестройку процесса** на новый сид (загрузка `kafka_load_dag`, витрины/Superset, уроки) — это решено в ADR-0006 и проектируется отдельно. Эта спека — только про сам генератор. - **Не фиксируем** внутренние имена классов и функций — это за исполнителем. - **Не добавляем** новые типы событий и инкрементальную загрузку ETL. ## Модель ### Рабочий контракт реализации Этот раздел — источник истины для задач 02–06. Если кодовая задача меняет любое имя, формат state, манифест или правило проверки, сначала обновляется этот контракт. #### Настройки - `GEN_MODEL_T0` — стартовая модельная точка `T0`. Формат: ISO 8601 с часовым поясом, например `2026-01-01T00:00:00+00:00`. Внутри генератора метка нормализуется к UTC. - `GEN_MODEL_T_END` — граница стартовой истории `T_end`. Формат такой же, как у `GEN_MODEL_T0`. Обязательна только для режима `backfill`. - `GEN_MODEL_TIMEZONE` — часовой пояс модельных часов для дневного коэффициента. Формат: имя IANA, например `UTC` или `Europe/Moscow`. Значение по умолчанию — `UTC`. - `GEN_MODEL_TIME_SPEED` — скорость `K`: сколько модельных секунд проходит за одну настенную секунду. Формат: положительное число, по умолчанию `1`. - `GEN_RUN_MODE` — режим запуска: `live` или `backfill`. Значение по умолчанию — `live`. - `GEN_SEED`, `GEN_TICK_SECONDS` и остальные настройки генерации остаются частью контракта повторяемости. Если они отличаются, артефакт стартовой истории считается другим. #### Ход часов В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно равно `GEN_MODEL_T0`. После каждого успешного тика оно сдвигается на `GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. Событийный бюджет считается по этой модельной длительности, а не по тому, сколько процесс реально спал. При одинаковых `GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_TIME_SPEED`, настройках генерации и числе успешных тиков живой поток повторяется точно. Это выбранный путь для текущей цепочки: он ближе к учебной цели и не добавляет новую публичную матрицу режимов поверх ADR-0005. `backfill` не спит и не измеряет настенные интервалы. Генератор детерминированно проматывает модельное время от `GEN_MODEL_T0` до `GEN_MODEL_T_END`. Артефакт повторяется точно при тех же настройках и чистом состоянии. Измеренный настенный интервал между тиками не входит в текущий контракт живого режима. Если он понадобится позже, это отдельное изменение спеки или ADR, потому что оно меняет уровень повторяемости стенда. #### Граница `T_end` Стартовая история покрывает полуоткрытый отрезок `[T0, T_end)`: в неё попадают события с `event_timestamp >= T0` и `event_timestamp < T_end`. Слепок состояния сохраняется на модельной границе `T_end`. Живое продолжение начинается из этого слепка с модельной точки `T_end`. Событие, которое запланировано ровно на `T_end`, не входит в стартовую историю и может быть выпущено первым живым тиком. Так на стыке нет дублей и дыр. #### Модельные и настенные метки Модельными считаются: - `event_timestamp` во всех событиях; - метки внутри визитов и популяции: начало визита, запланированные смещения, `last_finished_at`; - `GEN_MODEL_T0`, `GEN_MODEL_T_END`, точка возобновления и метка слепка state. Настенными остаются операционные метки: время записи логов, метрики здоровья, длительность тика, история отправки пачек, время сохранения state в Kafka и служебная метка `generated_at` в манифесте. Они помогают обслуживать сервис, но не должны менять `event_timestamp` и бизнес-логику визитов. #### Восстановление после сбоя Следующая версия state должна хранить связку: - `model_timestamp` — модельная точка последнего сохранённого состояния; - `wall_timestamp` — настенная UTC-метка, когда это состояние было сохранено; - `model_time_speed`, `model_timezone` и `model_t0`. При восстановлении живого режима после сбоя модельная точка считается так: ```text resume_model_at = state.model_timestamp + max(0, wall_now_utc - state.wall_timestamp) * state.model_time_speed ``` Для восстановления из стартовой истории эта формула не применяется: `resume_model_at = manifest.model_t_end`. Иначе долгий простой между созданием артефакта и запуском стенда искусственно оборвёт активные визиты. Короткий или долгий простой считается только по модельному времени. При большом `GEN_MODEL_TIME_SPEED` короткая настенная пауза может стать долгой модельной паузой, и тогда просроченные активные визиты закрываются. #### Манифест стартовой истории Стартовая история состоит из трёх частей: события, слепок состояния и манифест. Манифест хранится как JSON в Kafka compact-topic `generator_startup_history_manifest`, ключ `default`. Слепок состояния хранится в `generator_state`, ключ `default`. Манифест минимум содержит: - `manifest_version`; - `generated_at` — настенная UTC-метка создания артефакта; - `gen_seed`; - `model_t0`, `model_t_end`, `model_timezone`; - `run_mode = "backfill"`; - настройки генерации, влияющие на поток, в `generation_settings`; - `state_version` и ссылку на запись слепка состояния; - контрольные числа по каждому топику: количество строк, минимум и максимум `event_timestamp`, контрольная сумма; - итоговые контрольные числа для проверки в ClickHouse: события, визиты, пользователи и диапазон модельного времени. Нельзя смешивать события, state и манифест от разных `GEN_SEED`, `T0`, `T_end` или настроек генерации. Live-запуск использует manifest как стартовую историю только если `state.last_batch_id`, `state.model_timestamp`, `GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_T_END`, `GEN_MODEL_TIMEZONE`, `GEN_MODEL_TIME_SPEED` и `generation_settings` совпадают. Startup-history state без подходящего manifest считается несовместимым и ведёт к чистому старту, а не к восстановлению по правилу live-сбоя. #### Повторяемая проверка в ClickHouse Для этой цепочки выбираем чистый прогон, а не идемпотентную дозаливку. Перед повторной проверкой нужно сбросить: - таблицы ClickHouse в слоях STG, ODS, DDS и DM, куда попадают события стенда; - Kafka-топики данных: `browser_events`, `location_events`, `device_events`, `geo_events`; - состояние генератора: compact topic `generator_state` или явный сброс состояния при старте, если он гарантированно не читает старую запись. После такого сброса один и тот же артефакт стартовой истории должен давать те же контрольные числа в ClickHouse. Если проверка запускается без чистки, это уже другой сценарий и его нужно описывать отдельно. ### Точка отсчёта и скорость хода часов У генератора своя точка отсчёта времени — **стартовая модельная точка `T0`** (задаётся в настройках). В метку события (`event_timestamp`) пишется это внутреннее время. Скорость, с которой оно идёт относительно реальных часов, задаётся режимами из ADR-0005: - **×1** — как реальное время; - **×K** — в `K` раз быстрее; - **заливка прошлого** — время гонится без пауз от `T0` до нужного момента (особый случай очень большого `K`). ### Повторяемость При одном и том же зерне `GEN_SEED`, одной и той же `T0` и одной скорости генератор каждый раз даёт **один и тот же поток** в `backfill` и в живом режиме при одинаковом числе успешных тиков. Дневной коэффициент считается по модельному времени в `GEN_MODEL_TIMEZONE`, а не по реальным часам. Так раздел «Воспроизводимость» мат-спеки приводится в соответствие с модельным временем. ### Заливка прошлого и стартовая история «Промотать» прошлое — значит прогнать время без пауз от `T0` до момента `T_end` и сгенерировать события этого отрезка. На выходе — события за `[T0, T_end)` и **слепок состояния** генератора на момент `T_end`. События плюс слепок и есть **стартовая история** («стартовый сид» или «новый сид» — синонимы, новое значение слова «сид» не заводим). При создании стенда это прошлое заливается, и генератор готов продолжить ровно с `T_end`. Стенд сразу живёт с готовой историей — с настоящей пирамидой «пользователей меньше, чем визитов, визитов меньше, чем событий», без вырождения статического сида, где пользователей ровно столько же, сколько визитов. ### Сохранение и восстановление состояния Правило «молчал дольше 30 минут — посетитель ушёл» остаётся; меняется только **от какого момента отсчитывать эти 30 минут**: - **после сбоя** — от реального «сейчас» (время и правда прошло) → как сегодня; - **при запуске со стартовой истории** — от метки слепка (`T_end`) → разрыва почти нет, активные визиты не обрываются, мир продолжается без шва. Паузы между визитами одного пользователя и обязательная пауза перед возвратом тоже считаются по внутренним меткам времени, а не по числу тактов. Если сохранённое состояние повреждено или его нет — генератор стартует с чистого листа (свежая популяция от `GEN_SEED`) и пишет предупреждение в лог. Так раздел «Персистентность» мат-спеки приводится в соответствие с модельным временем. ### С какой скоростью стенд живёт дальше После старта стенд продолжает жить на выбранной скорости. Скорость — это **ручка под задачу**: чтобы увидеть медленные вещи (возвраты, сдвиг воронки после правки таблицы переходов, «дыхание» суточной нагрузки) за учебное время, нужно ускорение (×K) — иначе суточная волна разворачивается реальные сутки. Какой скорость будет по умолчанию (×1 «как настоящий сайт» или ускоренная «учебная») — решает урок/исполнитель; спека лишь фиксирует, что это ручка и что именно на ней держится наблюдаемость медленных явлений. ### Временная опора на сид (фактура) Пока генератор не умеет придумывать фактуру сам (ADR-0006), стартовая история одевает события в данные из статического сида (браузер, гео, устройство, метки кампаний). Это временно и не мешает: механизм времени и стартовой истории не ждёт синтеза фактуры, а сид до его появления остаётся кладовкой готовых значений — но уже не источником аналитики. ## Проверка Два шага — это разделение труда: первый объективный и повторяемый (его делает агент или CI), второй — человеческий взгляд. ### Шаг 1 — агент сверяет числа в ClickHouse На чистом стенде с фиксированными `GEN_SEED` и `T0` залить стартовую историю, прогнать STG→DM и проверить: - **пирамида:** уникальных пользователей меньше, чем визитов, а визитов меньше, чем событий; - **воронка** убывает по шагам `/home → товары → /cart → /payment → /confirmation`; доля дошедших до `/confirmation` — в нужном коридоре; - **возвраты:** у части пользователей больше одного визита; - **длина визита** и доля коротких визитов — в коридорах мат-спеки. Главное: проверки **повторяемы** (тот же `GEN_SEED` и `T0` → те же числа в пределах допуска) и записаны как команды и запросы, чтобы их мог прогнать агент или CI без человека. Сами запросы и числовые коридоры — при реализации. ### Шаг 2 — человек смотрит на дашборды Глазами убедиться, что: - **распределение похоже на задуманное** — на наши спроектированные распределения (мат-спека), а не на профиль чужого сида; - **стенд «дышит»** — видна суточная волна нагрузки, копятся возвраты и воронка (заметно на ×K). Здесь всплывает нехватка: дашборда с распределением сгенерированных данных пока нет. Superset построен на сиде, а Grafana показывает только скорость работы сервиса, не форму данных. Закрыть можно двумя способами: переключить Superset на генерацию (часть большой миграции — позже) или добавить простую панель «распределение из ClickHouse» в Grafana. Пошаговые действия шага 2 — в будущий runbook «проверка генератора на стенде». ## Решения и отклонённые варианты - **Приняли:** одна спека на время, сохранение состояния и стартовую историю — это один механизм, а не три задачи. - **Приняли:** своя точка отсчёта `T0` как опора повторяемости; дневной коэффициент считается по внутреннему времени. - **Приняли:** проверка в два шага (агент — числа, человек — глаза). - **Отклонили:** жёстко подгонять генерацию под профиль сида (см. ADR-0006). - **Отклонили:** приводить сохранение состояния в соответствие с модельным временем отдельно от стартовой истории — это разрезало бы один механизм надвое. ## Влияние на документацию - **Мат-спека:** разделы «Персистентность через рестарты» и «Воспроизводимость» пометить как переописанные в модельном времени этой спекой (ссылкой, без повтора). - **ADR-0005:** направление «стартовая история» — отмечено как запущенное. - **CONTEXT.md:** «стартовый сид» / «новый сид» = «стартовая история стенда» (синонимы, не новое значение); роль статического сида — по ADR-0006. - **Будущий runbook** «проверка генератора на стенде» — шаги шага 2. - **`generator/README.md`, `docs/OPERATIONS.md`** — при реализации: скорость хода часов, ×K, стартовая история, проверки шага 1.