- Зачем:
- менти собирал мир генерацией (минуты и десятки минут CPU); теперь
готовый трёхдневный мир загружается импортом за ~2 минуты, и числа
у всех менти совпадают число-в-число (issue #3).
- Что:
- артефакт data/startup_history/reference-world.json.xz в git:
3 модельных дня daily-wave, ~850 МБ JSON → 32 МБ xz;
- чтение и запись артефакта понимают .xz потоково (lzma); пустое поле
artifact_path в пульте и make startup-history-import читают эталон;
- длительность профиля daily-wave стала 3d — в тон эталонному миру;
- предпроверка чистого стенда ставит зависимости генератора через uv;
экспорт и импорт разведены отдельными переменными Makefile;
- доки и runbook обновлены; новые контрактные тесты: xz round-trip
и дефолтные пути артефакта.
- Проверка:
- make test (210 + 31) и make lint зелёные; импорт на чистом стенде
за 2м03с, manifest совпал (280437 событий), Superset-проверка
зелёная; независимое ревью — APPROVED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
31 KiB
Модельное время на практике: сохранение состояния, заливка прошлого и стартовая история
Дата: 2026-06-14
Статус: Draft
Связано: ADR-0005 (решение про модельные
часы — эта спека его дорабатывает), ADR-0006
(стартовая история как источник аналитики), мат-спека
2026-06-10-generator-math-model.md
(её разделы «Персистентность через рестарты» и «Воспроизводимость» здесь
приводятся в соответствие с модельным временем — не повторяются, а
переописываются ссылкой), CONTEXT.md, задача
06-state-v2-and-restart
(сохранение состояния построено на настенных часах).
Уровень документа — модель и правила, как в мат-спеке. Раздел «Рабочий контракт реализации» ниже фиксирует внешний интерфейс для задач 02–06. Внутренние классы, функции и разбиение кода остаются за исполнителем.
Проблема
ADR-0005 решил отвязать время генератора от реальных часов и ввёл три скорости его хода: обычную (×1), ускоренную (×K) и мгновенную «промотку» прошлого. Но два правила старой модели — как генератор сохраняет состояние между перезапусками и как добивается повторяемости — описаны от реальных часов. А ADR-0006 сделал стартовую историю (готовое сгенерированное прошлое) единственным источником данных стенда.
Осталось довести модель до конца: как работает промотка прошлого, его заморозка и непрерывный запуск стенда с этого момента; как при этом меняются правила сохранения состояния и повторяемости; и как всё это проверить. Всё это — один механизм: промотать прошлое → заморозить → продолжить живьём. Это один и тот же путь сохранения и восстановления, просто с разных сторон.
Цели
-
Дать генератору собственные часы и сделать поток повторяемым. Генератор должен отсчитывать время от своей точки отсчёта (ниже — «стартовая модельная точка
T0»), а не от реальных часов компьютера. Тогда при одних и тех же настройках он каждый раз порождает один и тот же поток — это нужно для тестов и для повторяемых уроков. -
Переписать правила сохранения и восстановления состояния по этим часам. Сейчас они завязаны на реальное время. Главное правило — «если посетитель молчал дольше 30 минут, считаем, что он ушёл» — должно мерить эти 30 минут по часам генератора. И поток больше не должен зависеть от того, в котором часу реального дня запущен генератор.
-
Научиться быстро «проматывать» прошлое и замораживать его как стартовый набор данных. Генератор прокручивает время без пауз от точки отсчёта до нужного момента, порождает события прошлого и сохраняет «слепок» своего состояния. Этот замороженный набор — то, с чего свежий стенд начинает жить, уже имея историю: здоровую пирамиду «пользователей меньше, чем визитов, а визитов меньше, чем событий» с первой минуты.
-
Описать, как всё это проверить — в два шага. Сначала числами: агент поднимает стенд и сверяет данные в ClickHouse. Потом глазами: человек смотрит на дашборды и видит, что распределение похоже на задуманное, а стенд «дышит» во времени.
Чего здесь не делаем
- Не учим генератор придумывать «фактуру» сам (браузеры, гео, устройства, метки кампаний). Это отдельная спека — следствие ADR-0006. Пока её нет, стартовая история берёт фактуру из статического сида; это осознанно временно.
- Не описываем здесь перестройку процесса на новый сид (загрузка
kafka_load_dag, витрины/Superset, уроки) — это решено в ADR-0006 и проектируется отдельно. Эта спека — только про сам генератор. - Не фиксируем внутренние имена классов и функций — это за исполнителем.
- Не добавляем новые типы событий и инкрементальную загрузку ETL.
Модель
Рабочий контракт реализации
Этот раздел — источник истины для задач 02–06. Если кодовая задача меняет любое имя, формат state, манифест или правило проверки, сначала обновляется этот контракт.
Настройки
GEN_MODEL_T0— стартовая модельная точкаT0. Формат: ISO 8601 с часовым поясом, например2026-01-01T00:00:00+00:00. Внутри генератора метка нормализуется к UTC.GEN_MODEL_T_END— граница стартовой историиT_end. Формат такой же, как уGEN_MODEL_T0. Обязательна только для режимаbackfill.GEN_MODEL_TIMEZONE— часовой пояс модельных часов для дневного коэффициента. Формат: имя IANA, напримерUTCилиEurope/Moscow. Значение по умолчанию —UTC.GEN_MODEL_TIME_SPEED— скоростьK: сколько модельных секунд проходит за одну настенную секунду. Формат: положительное число, по умолчанию1.GEN_RUN_MODE— режим запуска:liveилиbackfill. Значение по умолчанию —live.GEN_LAUNCH_PROFILE— имя профиля запуска для логов. Значение по умолчанию —ci; на поток не влияет.GEN_STARTUP_HISTORY_ARTIFACT— путь к JSON-файлу, кудаbackfillдополнительно пишет портативный артефакт стартовой истории. В обычном live-запуске не нужен.GEN_SEED,GEN_TICK_SECONDSи остальные настройки генерации остаются частью контракта повторяемости. Если они отличаются, артефакт стартовой истории считается другим.
Поверх этих переменных есть пользовательский слой запуска:
backfill— подставляетGEN_RUN_MODE=backfillиGEN_STATE_RESET=true;continue— подставляетGEN_RUN_MODE=liveиGEN_STATE_RESET=false;reset— подставляетGEN_RUN_MODE=liveиGEN_STATE_RESET=true.
Профиль ci даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с.
Профиль daily-wave даёт 3 суток, чтобы была видна суточная волна, а в live
идёт с GEN_MODEL_TIME_SPEED=60 и GEN_TICK_SECONDS=1: модельные сутки
проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана,
чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны
не меняются относительно старого минутного тика, а пиковый тик не упирается в
GEN_MAX_EVENTS_PER_TICK. Вариант «тик 60 с, скорость ×60» не подходит: один
тик стал бы модельным часом и слипал бы события в грубые часовые пачки.
GEN_HISTORY_DURATION задаёт длительность вида 6h или 2d; GEN_MODEL_T_END
считается от GEN_MODEL_T0 внутри слоя запуска. Старые переменные остаются
низкоуровневым механизмом.
Цена быстрого live-хода daily-wave: при тике 1 с сервис пишет примерно 86 400
state-записей и столько же записей истории пачек за настенные сутки. Частота
state-записей остаётся прежним контрактом возобновления после сбоя: успешный тик
сразу сохраняет новую модельную точку. Чтобы быстрый профиль не заливал журналы,
подробные строки успешного тика пишутся на DEBUG, а не на INFO.
Ход часов
В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно
равно GEN_MODEL_T0. После каждого успешного тика оно сдвигается на
GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED. Событийный бюджет считается по этой
модельной длительности, а не по тому, сколько процесс реально спал.
При одинаковых GEN_SEED, GEN_MODEL_T0, GEN_MODEL_TIME_SPEED, настройках
генерации и числе успешных тиков живой поток повторяется точно. Это выбранный
путь для текущей цепочки: он ближе к учебной цели и не добавляет новую публичную
матрицу режимов поверх ADR-0005.
backfill не спит и не измеряет настенные интервалы. Генератор детерминированно
проматывает модельное время от GEN_MODEL_T0 до GEN_MODEL_T_END. Артефакт
повторяется точно при тех же настройках и чистом состоянии.
Измеренный настенный интервал между тиками не входит в текущий контракт живого режима. Если он понадобится позже, это отдельное изменение спеки или ADR, потому что оно меняет уровень повторяемости стенда.
Граница T_end
Стартовая история покрывает полуоткрытый отрезок [T0, T_end): в неё попадают
события с event_timestamp >= T0 и event_timestamp < T_end. Слепок состояния
сохраняется на модельной границе T_end.
Живое продолжение начинается из этого слепка с модельной точки T_end. Событие,
которое запланировано ровно на T_end, не входит в стартовую историю и может
быть выпущено первым живым тиком. Так на стыке нет дублей и дыр.
Модельные и настенные метки
Модельными считаются:
event_timestampво всех событиях;- метки внутри визитов и популяции: начало визита, запланированные смещения,
last_finished_at; GEN_MODEL_T0,GEN_MODEL_T_END, точка возобновления и метка слепка state.
Настенными остаются операционные метки: время записи логов, метрики здоровья,
длительность тика, история отправки пачек, время сохранения state в Kafka и
служебная метка generated_at в манифесте. Они помогают обслуживать сервис, но
не должны менять event_timestamp и бизнес-логику визитов.
Восстановление после сбоя
Следующая версия state должна хранить связку:
model_timestamp— модельная точка последнего сохранённого состояния;wall_timestamp— настенная UTC-метка, когда это состояние было сохранено;model_time_speed,model_timezoneиmodel_t0.
State v3 также хранит для каждого активного визита base_click_id — click_id
донора браузерной и source-фактуры из статического сида. При восстановлении
активный визит берёт per-event поля от этого донора; неизвестный донор считается
битым state, а не поводом выбрать запасную фактуру.
При восстановлении живого режима после сбоя модельная точка считается так:
resume_model_at =
state.model_timestamp
+ max(0, wall_now_utc - state.wall_timestamp) * state.model_time_speed
Для восстановления из стартовой истории эта формула не применяется:
resume_model_at = manifest.model_t_end. Иначе долгий простой между созданием
артефакта и запуском стенда искусственно оборвёт активные визиты.
Короткий или долгий простой считается только по модельному времени. При большом
GEN_MODEL_TIME_SPEED короткая настенная пауза может стать долгой модельной
паузой, и тогда просроченные активные визиты закрываются.
Если state отсутствует или повреждён, генератор стартует чисто и пишет
предупреждение. Если state читается, GEN_STATE_RESET=false, но настройки
продолжения несовместимы (GEN_SEED, GEN_MODEL_T0, GEN_MODEL_TIMEZONE,
GEN_MODEL_TIME_SPEED, а для стартовой истории ещё и GEN_MODEL_T_END или
manifest), генератор должен упасть с перечнем разошедшихся полей и подсказкой
использовать GEN_STATE_RESET=true для осознанного нового мира.
Манифест стартовой истории
Стартовая история состоит из трёх частей: события, слепок состояния и манифест.
В рабочем стенде манифест хранится как JSON в Kafka compact-topic
generator_startup_history_manifest, ключ default. Слепок состояния хранится
в generator_state, ключ default.
Портативный файл-артефакт хранит тот же связный набор: сообщения топиков
browser_events, location_events, device_events, geo_events, слепок state
и manifest. Для событий хранится raw JSON value, чтобы импорт мог воспроизвести
Kafka-сообщения без повторной сериализации dict. Импорт артефакта воспроизводит
сообщения в Kafka и записывает state с manifest в служебные compact-топики.
Напрямую в ClickHouse импорт не пишет: ClickHouse наполняется штатным путём через
Kafka engine и Materialized View.
Импорт рассчитан на чистый стенд. Перед записью он проверяет, что data-топики Kafka пустые. Так как текущий Python-клиент Kafka не даёт транзакционный producer для нескольких топиков, при ошибке записи импорт удаляет import-топики Kafka, чтобы повторный импорт не дописал дубли; если ClickHouse уже успел прочитать частичные сообщения, стенд очищается как clean-stand сценарий.
Манифест минимум содержит:
manifest_version;generated_at— настенная UTC-метка создания артефакта;gen_seed;model_t0,model_t_end,model_timezone;run_mode = "backfill";- настройки генерации, влияющие на поток, в
generation_settings; state_versionи ссылку на запись слепка состояния;- контрольные числа по каждому топику: количество строк, минимум и максимум
event_timestamp, контрольная сумма; - итоговые контрольные числа для проверки в ClickHouse: события, визиты, пользователи и диапазон модельного времени.
Нельзя смешивать события, state и манифест от разных GEN_SEED, T0, T_end
или настроек генерации. Live-запуск использует manifest как стартовую историю
только если state.last_batch_id, state.model_timestamp, GEN_SEED,
GEN_MODEL_T0, GEN_MODEL_T_END, GEN_MODEL_TIMEZONE, GEN_MODEL_TIME_SPEED
и generation_settings совпадают. Startup-history state без подходящего
manifest считается несовместимым читаемым state и даёт жёсткий отказ при
GEN_STATE_RESET=false.
Повторяемая проверка в ClickHouse
Для этой цепочки выбираем чистый прогон, а не идемпотентную дозаливку. Перед повторной проверкой нужно сбросить:
- таблицы ClickHouse в слоях STG, ODS, DDS и DM, куда попадают события стенда;
- Kafka-топики данных:
browser_events,location_events,device_events,geo_events; - состояние генератора: compact topic
generator_stateили явный сброс состояния при старте, если он гарантированно не читает старую запись.
После такого сброса один и тот же артефакт стартовой истории должен давать те же контрольные числа в ClickHouse. Если проверка запускается без чистки, это уже другой сценарий и его нужно описывать отдельно.
Точка отсчёта и скорость хода часов
У генератора своя точка отсчёта времени — стартовая модельная точка T0
(задаётся в настройках). В метку события (event_timestamp) пишется это
внутреннее время. Скорость, с которой оно идёт относительно реальных часов,
задаётся режимами из ADR-0005:
- ×1 — как реальное время;
- ×K — в
Kраз быстрее; - заливка прошлого — время гонится без пауз от
T0до нужного момента (особый случай очень большогоK).
Повторяемость
При одном и том же зерне GEN_SEED, одной и той же T0 и одной скорости
генератор каждый раз даёт один и тот же поток в backfill и в живом режиме
при одинаковом числе успешных тиков. Дневной коэффициент считается по модельному
времени в GEN_MODEL_TIMEZONE, а не по реальным часам. Так раздел
«Воспроизводимость» мат-спеки приводится в соответствие с модельным временем.
Заливка прошлого и стартовая история
«Промотать» прошлое — значит прогнать время без пауз от T0 до момента T_end и
сгенерировать события этого отрезка. На выходе — события за [T0, T_end) и
слепок состояния генератора на момент T_end.
События плюс слепок и есть стартовая история («стартовый сид» или «новый сид» —
синонимы, новое значение слова «сид» не заводим). При создании стенда это прошлое заливается, и
генератор готов продолжить ровно с T_end. Стенд сразу живёт с готовой историей —
с настоящей пирамидой «пользователей меньше, чем визитов, визитов меньше, чем
событий», без вырождения статического сида, где пользователей ровно столько же,
сколько визитов.
Сохранение и восстановление состояния
Правило «молчал дольше 30 минут — посетитель ушёл» остаётся; меняется только от какого момента отсчитывать эти 30 минут:
- после сбоя — от реального «сейчас» (время и правда прошло) → как сегодня;
- при запуске со стартовой истории — от метки слепка (
T_end) → разрыва почти нет, активные визиты не обрываются, мир продолжается без шва.
Паузы между визитами одного пользователя и обязательная пауза перед возвратом
тоже считаются по внутренним меткам времени, а не по числу тактов. Если
сохранённое состояние повреждено или его нет — генератор стартует с чистого листа
(свежая популяция от GEN_SEED) и пишет предупреждение в лог. Так раздел
«Персистентность» мат-спеки приводится в соответствие с модельным временем.
С какой скоростью стенд живёт дальше
После старта стенд продолжает жить на выбранной скорости. Скорость — это ручка под задачу: чтобы увидеть медленные вещи (возвраты, сдвиг воронки после правки таблицы переходов, «дыхание» суточной нагрузки) за учебное время, нужно ускорение (×K) — иначе суточная волна разворачивается реальные сутки. Какой скорость будет по умолчанию (×1 «как настоящий сайт» или ускоренная «учебная») — решает урок/исполнитель; спека лишь фиксирует, что это ручка и что именно на ней держится наблюдаемость медленных явлений.
Временная опора на сид (фактура)
Пока генератор не умеет придумывать фактуру сам (ADR-0006), стартовая история одевает события в данные из статического сида (браузер, гео, устройство, метки кампаний). Это временно и не мешает: механизм времени и стартовой истории не ждёт синтеза фактуры, а сид до его появления остаётся кладовкой готовых значений — но уже не источником аналитики.
Проверка
Два шага — это разделение труда: первый объективный и повторяемый (его делает агент или CI), второй — человеческий взгляд.
Шаг 1 — агент сверяет числа в ClickHouse
На чистом стенде с фиксированными GEN_SEED и T0 залить стартовую историю,
прогнать STG→DM и проверить:
- пирамида: уникальных пользователей меньше, чем визитов, а визитов меньше, чем событий;
- воронка убывает по шагам
/home → товары → /cart → /payment → /confirmation; доля дошедших до/confirmation— в нужном коридоре; - возвраты: у части пользователей больше одного визита;
- длина визита и доля коротких визитов — в коридорах мат-спеки.
Главное: проверки повторяемы (тот же GEN_SEED и T0 → те же числа в
пределах допуска) и записаны как команды и запросы, чтобы их мог прогнать агент
или CI без человека. Сами запросы и числовые коридоры — при реализации.
Шаг 2 — человек смотрит на дашборды
Глазами убедиться, что:
- распределение похоже на задуманное — на наши спроектированные распределения (мат-спека), а не на профиль чужого сида;
- стенд «дышит» — видна суточная волна нагрузки, копятся возвраты и воронка (заметно на ×K).
Здесь всплывает нехватка: дашборда с распределением сгенерированных данных пока нет. Superset построен на сиде, а Grafana показывает только скорость работы сервиса, не форму данных. Закрыть можно двумя способами: переключить Superset на генерацию (часть большой миграции — позже) или добавить простую панель «распределение из ClickHouse» в Grafana. Пошаговые действия шага 2 — в будущий runbook «проверка генератора на стенде».
Решения и отклонённые варианты
- Приняли: одна спека на время, сохранение состояния и стартовую историю — это один механизм, а не три задачи.
- Приняли: своя точка отсчёта
T0как опора повторяемости; дневной коэффициент считается по внутреннему времени. - Приняли: проверка в два шага (агент — числа, человек — глаза).
- Отклонили: жёстко подгонять генерацию под профиль сида (см. ADR-0006).
- Отклонили: приводить сохранение состояния в соответствие с модельным временем отдельно от стартовой истории — это разрезало бы один механизм надвое.
Влияние на документацию
- Мат-спека: разделы «Персистентность через рестарты» и «Воспроизводимость» пометить как переописанные в модельном времени этой спекой (ссылкой, без повтора).
- ADR-0005: направление «стартовая история» — отмечено как запущенное.
- CONTEXT.md: «стартовый сид» / «новый сид» = «стартовая история стенда» (синонимы, не новое значение); роль статического сида — по ADR-0006.
- Будущий runbook «проверка генератора на стенде» — шаги шага 2.
generator/README.md,docs/OPERATIONS.md— при реализации: скорость хода часов, ×K, стартовая история, проверки шага 1.