Files
clickstream-ch-kafka-supers…/docs/specs/2026-06-14-generator-model-time-and-startup-history.md
T
ddadmin 4f8f992363 feat(generator): добавлены профили запуска
- Зачем:
  - запуск генератора должен быть понятным перед будущим DAG-пультом.
- Что:
  - добавлены глаголы запуска backfill, continue и reset.
  - добавлены профили ci и daily-wave с расчётом длительности истории.
  - обновлены runbook и документы запуска под профильный интерфейс.
- Проверка:
  - uv run --with-requirements generator/requirements.txt pytest generator/tests -q.
  - bash -n scripts/run_generator.sh scripts/export_startup_history_artifact.sh scripts/import_startup_history_artifact.sh scripts/run_generated_history_analytics.sh.
  - PROFILE=daily-wave COMPOSE_BIN=true bash scripts/run_generator.sh backfill.
2026-07-04 18:51:10 +03:00

29 KiB
Raw Blame History

Модельное время на практике: сохранение состояния, заливка прошлого и стартовая история

Дата: 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-часовой прогон. Профиль daily-wave даёт 2 суток, чтобы была видна суточная волна. GEN_HISTORY_DURATION задаёт длительность вида 6h или 2d; GEN_MODEL_T_END считается от GEN_MODEL_T0 внутри слоя запуска. Старые переменные остаются низкоуровневым механизмом.

Ход часов

В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно равно 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.

При восстановлении живого режима после сбоя модельная точка считается так:

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.