diff --git a/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md b/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md index 2e5be8c..ea168a6 100644 --- a/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md +++ b/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md @@ -1,4 +1,4 @@ -Status: ready-for-agent +Status: done # Глаголы, длительность и профили запуска генератора diff --git a/Makefile b/Makefile index 7fb09e0..f6c6427 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ reload-monitoring recover-monitoring \ superset-init superset-dashboard superset-ui superset-restart \ generator-up generator-down generator-logs generator-restart \ + generator-backfill generator-continue generator-reset \ generator-test generator-test-build generator-test-cov COMPOSE ?= docker compose @@ -130,6 +131,18 @@ generator-logs: generator-restart: $(COMPOSE) up -d --build --force-recreate generator +# Промотать стартовую историю через профиль запуска +generator-backfill: + COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh backfill + +# Продолжить live-поток из существующего state +generator-continue: + COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh continue + +# Запустить live-поток как новый мир +generator-reset: + COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh reset + # Собрать тестовый образ генератора generator-test-build: cd generator && docker build -t generator:test . diff --git a/README.md b/README.md index abbb004..4435214 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,17 @@ make generated-history-analytics docker compose ps ``` -По умолчанию это быстрый проверочный профиль на 6 часов модельного времени. -Суточную историю можно прогнать отдельно: +По умолчанию это быстрый профиль `ci`: 6 часов модельного времени. Историю на +2 суток с суточной волной можно прогнать одной командой: ```bash -GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics +PROFILE=daily-wave make generated-history-analytics +``` + +Разовую длительность можно задать без ручного расчёта правой границы: + +```bash +GEN_HISTORY_DURATION=2d make generated-history-analytics ``` Повторить только техническую проверку после уже выполненного прогона: diff --git a/docker-compose.yml b/docker-compose.yml index 4e4c98b..a9c323f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -307,6 +307,7 @@ services: GEN_MODEL_TIMEZONE: ${GEN_MODEL_TIMEZONE:-UTC} GEN_MODEL_TIME_SPEED: ${GEN_MODEL_TIME_SPEED:-1} GEN_RUN_MODE: ${GEN_RUN_MODE:-live} + GEN_LAUNCH_PROFILE: ${GEN_LAUNCH_PROFILE:-ci} GEN_DATA_DIR: /data GEN_SEED: ${GEN_SEED:-} GEN_ENABLED: ${GEN_ENABLED:-true} diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 200dc5d..eafba41 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -118,6 +118,7 @@ make generator-logs | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_RUN_MODE` | Режим генератора | `live` | +| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` | | `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто | | `GEN_STATE_ENABLED` | Сохранять state v2 между рестартами | `true` | | `GEN_STATE_RESET` | Сбросить state при старте | `false` | @@ -141,20 +142,25 @@ make generated-history-analytics ``` Она выполняет полный сброс volumes, поднимает ClickHouse и Kafka, применяет DDL, -запускает `GEN_RUN_MODE=backfill`, прогоняет batch STG -> ODS -> DDS -> DM, -инициализирует Superset и запускает техническую проверку. Для координатора или CI -короткая повторная проверка после уже готового стенда: +выполняет глагол `backfill`, прогоняет batch STG -> ODS -> DDS -> DM, +инициализирует Superset и запускает техническую проверку. Для координатора или +CI короткая повторная проверка после уже готового стенда: ```bash make generated-history-check ``` -По умолчанию команда использует быстрый проверочный профиль: 6 часов модельного -времени (`GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`). Суточную историю можно -прогнать отдельно, явно задав правую границу: +По умолчанию команда использует быстрый профиль `ci`: 6 часов модельного +времени. Историю на 2 суток с суточной волной можно получить одной командой: ```bash -GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics +PROFILE=daily-wave make generated-history-analytics +``` + +Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`: + +```bash +GEN_HISTORY_DURATION=2d make generated-history-analytics ``` `GEN_RUN_MODE=backfill` быстро проматывает модельное прошлое от `GEN_MODEL_T0` @@ -171,30 +177,16 @@ GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics Kafka-топики данных, state и manifest генератора. `make generated-history-analytics` делает это по умолчанию (`CLEAN_START=1`). +Ручной backfill без всего аналитического контура: + ```bash -make clean -docker compose up -d clickhouse kafka -make ddl - -GEN_RUN_MODE=backfill \ -GEN_STATE_RESET=true \ -GEN_SEED=4242 \ -GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \ -GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \ -GEN_MODEL_TIMEZONE=UTC \ -GEN_MODEL_TIME_SPEED=1 \ -GEN_TICK_SECONDS=60 \ -GEN_LAMBDA_BASE_PER_MIN=60 \ -GEN_JITTER_PCT=0 \ -docker compose run --rm --no-deps generator - -# Materialized View слоя STG читает Kafka сама; даём ей коротко догнать. -sleep 10 -bash scripts/run_batch.sh +PROFILE=daily-wave make generator-backfill ``` -Ручной сценарий выше нужен для отладки. В обычной проверке используйте -`make generated-history-analytics`, чтобы не забыть Superset и итоговый check. +Старый способ через `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END` +остаётся низкоуровневым путём для отладки прямого `docker compose run`. В обычной +проверке используйте `make generated-history-analytics`, чтобы не забыть +Superset и итоговый check. Manifest можно посмотреть так: @@ -207,27 +199,14 @@ docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \ --timeout-ms 5000 ``` -Live-продолжение стартует с этого state. Используйте те же `GEN_SEED`, `T0`, -`T_end`, часовой пояс и настройки генерации. `GEN_STATE_RESET=false` важен: иначе -слепок стартовой истории будет проигнорирован. +Live-продолжение стартует с этого state. Используйте тот же профиль или ту же +длительность, что были у backfill. Команда сама выставит `GEN_STATE_RESET=false`. ```bash -docker compose run -d --name startup-history-live --no-deps \ - -e GEN_RUN_MODE=live \ - -e GEN_STATE_RESET=false \ - -e GEN_SEED=4242 \ - -e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \ - -e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \ - -e GEN_MODEL_TIMEZONE=UTC \ - -e GEN_MODEL_TIME_SPEED=1 \ - -e GEN_TICK_SECONDS=60 \ - -e GEN_LAMBDA_BASE_PER_MIN=60 \ - -e GEN_JITTER_PCT=0 \ - generator +PROFILE=daily-wave make generator-continue sleep 130 -docker stop startup-history-live -docker rm startup-history-live +make generator-down sleep 10 bash scripts/run_batch.sh ``` diff --git a/docs/runbooks/startup-history.md b/docs/runbooks/startup-history.md index a386db1..5145069 100644 --- a/docs/runbooks/startup-history.md +++ b/docs/runbooks/startup-history.md @@ -16,12 +16,24 @@ ## Что требует нового артефакта -- Другая длительность истории (`GEN_MODEL_T_END`). +- Другая длительность истории или другой профиль запуска. - Другой `GEN_SEED`, `GEN_MODEL_T0`, часовой пояс, скорость или настройки генерации. - Осознанный новый мир после несовместимого state: сначала сбросьте state через `GEN_STATE_RESET=true` или чистые volumes. +## Профили запуска + +Основной способ — глагол плюс профиль: + +| Профиль | Для чего | Длительность | +|---------|----------|--------------| +| `ci` | Быстрая проверка и CI | `6h` | +| `daily-wave` | История с видимой суточной волной | `2d` | + +Длительность можно переопределить через `GEN_HISTORY_DURATION`, например `2d`. +Команда сама считает `GEN_MODEL_T_END` от `GEN_MODEL_T0`. + ## Экспорт По умолчанию создаётся быстрый 6-часовой артефакт: @@ -32,11 +44,11 @@ make startup-history-export Файл по умолчанию: `/tmp/clickstream-startup-history.json`. -Суточная история: +История на 2 суток с суточной волной: ```bash -ARTIFACT=/tmp/clickstream-startup-history-1d.json \ -GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 \ +ARTIFACT=/tmp/clickstream-startup-history-2d.json \ +PROFILE=daily-wave \ make startup-history-export ``` @@ -62,6 +74,13 @@ make generated-history-check compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и Materialized View, затем batch строит ODS, DDS и DM. +Если артефакт создан не профилем `ci`, импорт запускайте с тем же профилем: + +```bash +PROFILE=daily-wave ARTIFACT=/tmp/clickstream-startup-history-2d.json \ +make startup-history-import +``` + `make startup-history-check` сверяет контрольные числа DM-витрины с manifest артефакта: события, визиты, пользователей и диапазон `event_timestamp`. Если data-топики Kafka уже непустые, импорт остановится до публикации событий. @@ -74,18 +93,15 @@ ClickHouse уже успел прочитать частичные сообще ## Live-продолжение -После импорта запускайте live с теми же настройками, что были в артефакте: +После импорта запускайте live с тем же профилем, что был в артефакте: ```bash -GEN_STATE_RESET=false \ -GEN_SEED=4242 \ -GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \ -GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \ -GEN_MODEL_TIMEZONE=UTC \ -GEN_MODEL_TIME_SPEED=1 \ -docker compose up -d generator +PROFILE=ci make generator-continue ``` Если читаемый state есть, но настройки не совпадают, генератор падает с перечнем полей. Это защита от смешения разных миров. Для намеренного нового мира -используйте `GEN_STATE_RESET=true` или `make clean`. +используйте `make generator-reset` или `make clean`. + +Старые переменные `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END` остаются +низкоуровневым способом для отладки и прямого `docker compose run`. diff --git a/docs/specs/2026-06-14-generator-model-time-and-startup-history.md b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md index 081ae0e..18429d2 100644 --- a/docs/specs/2026-06-14-generator-model-time-and-startup-history.md +++ b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md @@ -90,12 +90,25 @@ ADR-0005 решил отвязать время генератора от реа одну настенную секунду. Формат: положительное число, по умолчанию `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` внутри слоя +запуска. Старые переменные остаются низкоуровневым механизмом. + #### Ход часов В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно diff --git a/generator/README.md b/generator/README.md index 5837633..ca75c42 100644 --- a/generator/README.md +++ b/generator/README.md @@ -78,6 +78,7 @@ generator-service -> Kafka topics -> (потребители отдельно) | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_RUN_MODE` | Режим генератора | `live` | +| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` | | `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | — | | `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` | | `GEN_SEED` | Сид для воспроизводимости | — | @@ -125,12 +126,17 @@ make generated-history-analytics проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka: они пока используются только как фактура для генератора. -По умолчанию используется быстрый проверочный профиль на 6 часов модельного -времени (`GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`). Суточный прогон доступен -явно: +По умолчанию используется быстрый профиль `ci`: 6 часов модельного времени. +Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна: ```bash -GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics +PROFILE=daily-wave make generated-history-analytics +``` + +Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`: + +```bash +GEN_HISTORY_DURATION=2d make generated-history-analytics ``` ### Режим "раз в минуту" (для демо) @@ -162,13 +168,26 @@ make generator-logs # Перезапуск с пересборкой make generator-restart -# Запуск тестов -make generator-test +# Промотать стартовую историю +make generator-backfill + +# Продолжить live-поток из state +make generator-continue + +# Начать live-поток как новый мир +make generator-reset # Чистый аналитический прогон всего стенда make generated-history-analytics + +# Запуск тестов +make generator-test ``` +Основной ручной путь запуска — глаголы `generator-backfill`, `generator-continue` +и `generator-reset`. Старые `GEN_RUN_MODE`, `GEN_STATE_RESET` и +`GEN_MODEL_T_END` остаются низкоуровневым способом для отладки. + ## Метрики Prometheus Генератор экспортирует метрики на `:9109/metrics`: diff --git a/generator/src/clickstream_generator/config.py b/generator/src/clickstream_generator/config.py index a0efe22..6d7cf3d 100644 --- a/generator/src/clickstream_generator/config.py +++ b/generator/src/clickstream_generator/config.py @@ -97,6 +97,9 @@ class Config: if os.getenv("GEN_STARTUP_HISTORY_ARTIFACT") else None ) + launch_profile: str = field( + default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "ci") + ) def __post_init__(self): if self.tick_seconds < 1: diff --git a/generator/src/clickstream_generator/launch.py b/generator/src/clickstream_generator/launch.py new file mode 100644 index 0000000..e801fa3 --- /dev/null +++ b/generator/src/clickstream_generator/launch.py @@ -0,0 +1,175 @@ +"""Глаголы и профили запуска генератора.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + + +LAUNCH_ENV_KEYS = ( + "GEN_SEED", + "GEN_MODEL_T0", + "GEN_MODEL_TIMEZONE", + "GEN_MODEL_TIME_SPEED", + "GEN_TICK_SECONDS", + "GEN_LAMBDA_BASE_PER_MIN", + "GEN_JITTER_PCT", + "GEN_MIN_EVENTS_PER_TICK", + "GEN_MAX_EVENTS_PER_TICK", +) + + +@dataclass(frozen=True) +class LaunchProfile: + """Именованный набор настроек запуска.""" + + duration: str + env: dict[str, str] + + +PROFILES = { + "ci": LaunchProfile( + duration="6h", + env={ + "GEN_SEED": "4242", + "GEN_MODEL_T0": "2026-01-01T00:00:00+00:00", + "GEN_MODEL_TIMEZONE": "UTC", + "GEN_MODEL_TIME_SPEED": "1", + "GEN_TICK_SECONDS": "60", + "GEN_LAMBDA_BASE_PER_MIN": "60", + "GEN_JITTER_PCT": "0", + "GEN_MIN_EVENTS_PER_TICK": "1", + "GEN_MAX_EVENTS_PER_TICK": "1000", + }, + ), + "daily-wave": LaunchProfile( + duration="2d", + env={ + "GEN_SEED": "4242", + "GEN_MODEL_T0": "2026-01-01T00:00:00+00:00", + "GEN_MODEL_TIMEZONE": "UTC", + "GEN_MODEL_TIME_SPEED": "1", + "GEN_TICK_SECONDS": "60", + "GEN_LAMBDA_BASE_PER_MIN": "60", + "GEN_JITTER_PCT": "0", + "GEN_MIN_EVENTS_PER_TICK": "1", + "GEN_MAX_EVENTS_PER_TICK": "1000", + }, + ), +} + + +def parse_duration(value: str) -> timedelta: + """Разбирает короткую длительность: 30m, 6h, 2d.""" + if len(value) < 2: + raise ValueError("Duration must look like 6h or 2d") + amount_text = value[:-1] + unit = value[-1] + if not amount_text.isdigit(): + raise ValueError("Duration amount must be a positive integer") + amount = int(amount_text) + if amount <= 0: + raise ValueError("Duration amount must be > 0") + if unit == "s": + return timedelta(seconds=amount) + if unit == "m": + return timedelta(minutes=amount) + if unit == "h": + return timedelta(hours=amount) + if unit == "d": + return timedelta(days=amount) + raise ValueError("Duration unit must be one of: s, m, h, d") + + +def build_launch_env( + verb: str, + *, + profile_name: str = "ci", + duration: str | None = None, + overrides: dict[str, str] | None = None, +) -> dict[str, str]: + """Возвращает старые env-переменные для нового глагола запуска.""" + if verb not in {"backfill", "continue", "reset"}: + raise ValueError("verb must be backfill, continue or reset") + if profile_name not in PROFILES: + known = ", ".join(sorted(PROFILES)) + raise ValueError(f"Unknown launch profile {profile_name!r}; known: {known}") + + profile = PROFILES[profile_name] + overrides = overrides or {} + env = dict(profile.env) + for key in LAUNCH_ENV_KEYS: + if key in overrides and overrides[key] != "": + env[key] = overrides[key] + + selected_duration = ( + duration + or overrides.get("GEN_HISTORY_DURATION") + or profile.duration + ) + env["GEN_LAUNCH_VERB"] = verb + env["GEN_LAUNCH_PROFILE"] = profile_name + + if verb == "backfill": + env["GEN_RUN_MODE"] = "backfill" + env["GEN_STATE_RESET"] = "true" + env["GEN_HISTORY_DURATION"] = selected_duration + env["GEN_MODEL_T_END"] = _model_t_end(env["GEN_MODEL_T0"], selected_duration) + elif verb == "continue": + env["GEN_RUN_MODE"] = "live" + env["GEN_STATE_RESET"] = "false" + env["GEN_HISTORY_DURATION"] = selected_duration + env["GEN_MODEL_T_END"] = _model_t_end(env["GEN_MODEL_T0"], selected_duration) + else: + env["GEN_RUN_MODE"] = "live" + env["GEN_STATE_RESET"] = "true" + + return env + + +def shell_assignments(env: dict[str, str]) -> str: + """Печатает env в виде, пригодном для eval в Bash.""" + return "\n".join( + f"export {key}={shlex.quote(value)}" + for key, value in sorted(env.items()) + ) + + +def _model_t_end(model_t0: str, duration: str) -> str: + t0 = datetime.fromisoformat(model_t0.replace("Z", "+00:00")) + if t0.tzinfo is None: + raise ValueError("GEN_MODEL_T0 must include timezone") + return (t0.astimezone(timezone.utc) + parse_duration(duration)).isoformat() + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Глаголы запуска генератора") + parser.add_argument("verb", choices=("backfill", "continue", "reset")) + parser.add_argument( + "--profile", + default=os.getenv("PROFILE") or os.getenv("GEN_LAUNCH_PROFILE") or "ci", + choices=sorted(PROFILES), + ) + parser.add_argument("--duration", default=os.getenv("GEN_HISTORY_DURATION")) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """CLI для Bash-скриптов запуска.""" + args = _parse_args(sys.argv[1:] if argv is None else argv) + env = build_launch_env( + args.verb, + profile_name=args.profile, + duration=args.duration, + overrides=os.environ, + ) + print(shell_assignments(env)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/generator/src/clickstream_generator/service.py b/generator/src/clickstream_generator/service.py index 715ed80..b73cd07 100644 --- a/generator/src/clickstream_generator/service.py +++ b/generator/src/clickstream_generator/service.py @@ -70,6 +70,7 @@ class GeneratorService: f"Configuration: tick={self.config.tick_seconds}s, " f"lambda_base={self.config.lambda_base_per_min}/min, " f"jitter={self.config.jitter_pct}%, " + f"launch_profile={self.config.launch_profile}, " f"state_enabled={self.config.state_enabled}, " f"state_reset={self.config.state_reset}" ) diff --git a/generator/src/clickstream_generator/startup_history_artifact.py b/generator/src/clickstream_generator/startup_history_artifact.py index e4bba3c..edf5fd9 100644 --- a/generator/src/clickstream_generator/startup_history_artifact.py +++ b/generator/src/clickstream_generator/startup_history_artifact.py @@ -299,6 +299,7 @@ def build_manifest(config: Config, counters: ManifestCounters, state: GeneratorS "model_t_end": config.model_t_end.isoformat(), "model_timezone": config.model_timezone, "run_mode": "backfill", + "launch_profile": config.launch_profile, "generation_settings": generation_settings_from_config(config), "state_version": state.version, "state": { diff --git a/generator/tests/test_config.py b/generator/tests/test_config.py index 81a296a..2d67531 100644 --- a/generator/tests/test_config.py +++ b/generator/tests/test_config.py @@ -118,3 +118,21 @@ class TestConfigDefaults: finally: if orig_value is not None: os.environ["GEN_TICK_SECONDS"] = orig_value + + def test_launch_profile_defaults_to_ci(self, monkeypatch, data_dir): + """По умолчанию выбран быстрый профиль запуска ci.""" + monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) + monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False) + + config = Config() + + assert config.launch_profile == "ci" + + def test_launch_profile_can_be_read_from_env(self, monkeypatch, data_dir): + """Выбранный профиль запуска попадает в конфигурацию для логов.""" + monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) + monkeypatch.setenv("GEN_LAUNCH_PROFILE", "daily-wave") + + config = Config() + + assert config.launch_profile == "daily-wave" diff --git a/generator/tests/test_launch.py b/generator/tests/test_launch.py new file mode 100644 index 0000000..128c549 --- /dev/null +++ b/generator/tests/test_launch.py @@ -0,0 +1,66 @@ +""" +Тесты профилей и глаголов запуска генератора. +""" + +from datetime import datetime, timezone + + +def test_duration_2d_sets_model_t_end_from_t0(): + """Длительность 2d считается от T0 без ручного GEN_MODEL_T_END.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci", duration="2d") + + assert env["GEN_MODEL_T0"] == "2026-01-01T00:00:00+00:00" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + + +def test_daily_wave_profile_uses_two_days_by_default(): + """Профиль daily-wave даёт историю с суточной волной.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="daily-wave") + + assert env["GEN_LAUNCH_PROFILE"] == "daily-wave" + assert env["GEN_HISTORY_DURATION"] == "2d" + assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + + +def test_backfill_verb_maps_to_existing_low_level_flags(): + """Глагол backfill оставляет прежнюю механику запуска под капотом.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci") + + assert env["GEN_RUN_MODE"] == "backfill" + assert env["GEN_STATE_RESET"] == "true" + + +def test_continue_and_reset_verbs_map_to_existing_low_level_flags(): + """Глаголы continue и reset задают только старый контракт state.""" + from clickstream_generator.launch import build_launch_env + + continue_env = build_launch_env("continue", profile_name="ci") + reset_env = build_launch_env("reset", profile_name="ci") + + assert continue_env["GEN_RUN_MODE"] == "live" + assert continue_env["GEN_STATE_RESET"] == "false" + assert continue_env["GEN_MODEL_T_END"] == "2026-01-01T06:00:00+00:00" + assert reset_env["GEN_RUN_MODE"] == "live" + assert reset_env["GEN_STATE_RESET"] == "true" + assert "GEN_MODEL_T_END" not in reset_env + + +def test_ci_profile_keeps_previous_default_backfill_window(): + """CI-профиль сохраняет прежний 6-часовой проверочный запуск.""" + from clickstream_generator.launch import build_launch_env + + env = build_launch_env("backfill", profile_name="ci") + + t0 = datetime.fromisoformat(env["GEN_MODEL_T0"]) + t_end = datetime.fromisoformat(env["GEN_MODEL_T_END"]) + assert t0 == datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + assert (t_end - t0).total_seconds() == 6 * 60 * 60 + assert env["GEN_TICK_SECONDS"] == "60" + assert env["GEN_LAMBDA_BASE_PER_MIN"] == "60" + assert env["GEN_JITTER_PCT"] == "0" diff --git a/generator/tests/test_startup_history_artifact.py b/generator/tests/test_startup_history_artifact.py index b47bf16..c966c96 100644 --- a/generator/tests/test_startup_history_artifact.py +++ b/generator/tests/test_startup_history_artifact.py @@ -130,6 +130,43 @@ def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config): path.unlink(missing_ok=True) +def test_manifest_and_artifact_show_launch_profile(base_config): + """Manifest и файл артефакта показывают выбранный профиль запуска.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + load_startup_history_artifact, + write_startup_history_artifact, + ) + + state = _state() + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest( + config=replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + launch_profile="daily-wave", + ), + counters=builder.counters, + state=state, + ) + artifact = builder.to_artifact(manifest=manifest, state=state) + + path = base_config.data_dir.parent / "tmp-startup-history-profile.json" + try: + write_startup_history_artifact(path, artifact) + loaded = load_startup_history_artifact(path) + assert manifest["launch_profile"] == "daily-wave" + assert loaded["manifest"]["launch_profile"] == "daily-wave" + finally: + path.unlink(missing_ok=True) + + def test_artifact_validation_rejects_mismatched_manifest(base_config): """Валидация отвергает артефакт, где manifest не совпадает с событиями.""" from clickstream_generator.startup_history_artifact import ( diff --git a/scripts/export_startup_history_artifact.sh b/scripts/export_startup_history_artifact.sh index f26523f..a7b13a9 100755 --- a/scripts/export_startup_history_artifact.sh +++ b/scripts/export_startup_history_artifact.sh @@ -10,26 +10,31 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" CLEAN_START="${CLEAN_START:-1}" ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" - -GEN_SEED="${GEN_SEED:-4242}" -GEN_MODEL_T0="${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}" -GEN_MODEL_T_END="${GEN_MODEL_T_END:-2026-01-01T06:00:00+00:00}" -GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE:-UTC}" -GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED:-1}" -GEN_TICK_SECONDS="${GEN_TICK_SECONDS:-60}" -GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN:-60}" -GEN_JITTER_PCT="${GEN_JITTER_PCT:-0}" -GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK:-1}" -GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK:-1000}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi cd "${REPO_ROOT}" +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + backfill \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + ARTIFACT_DIR="$(dirname "${ARTIFACT}")" ARTIFACT_FILE="$(basename "${ARTIFACT}")" mkdir -p "${ARTIFACT_DIR}" echo "=== Экспорт стартовой истории ===" echo "ARTIFACT=${ARTIFACT}" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" echo "GEN_SEED=${GEN_SEED}" echo "GEN_MODEL_T0=${GEN_MODEL_T0}" echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" @@ -53,6 +58,7 @@ ${COMPOSE_BIN} run --rm --no-deps \ -v "${ARTIFACT_DIR}:/startup-artifacts" \ -e GEN_RUN_MODE=backfill \ -e GEN_STATE_RESET=true \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ -e GEN_STARTUP_HISTORY_ARTIFACT="/startup-artifacts/${ARTIFACT_FILE}" \ -e GEN_SEED="${GEN_SEED}" \ -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ diff --git a/scripts/import_startup_history_artifact.sh b/scripts/import_startup_history_artifact.sh index 4ce4eb0..bbd8945 100755 --- a/scripts/import_startup_history_artifact.sh +++ b/scripts/import_startup_history_artifact.sh @@ -9,20 +9,23 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" - -GEN_SEED="${GEN_SEED:-4242}" -GEN_MODEL_T0="${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}" -GEN_MODEL_T_END="${GEN_MODEL_T_END:-2026-01-01T06:00:00+00:00}" -GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE:-UTC}" -GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED:-1}" -GEN_TICK_SECONDS="${GEN_TICK_SECONDS:-60}" -GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN:-60}" -GEN_JITTER_PCT="${GEN_JITTER_PCT:-0}" -GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK:-1}" -GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK:-1000}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi cd "${REPO_ROOT}" +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + continue \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + if [[ ! -s "${ARTIFACT}" ]]; then echo "Ошибка: артефакт не найден или пуст: ${ARTIFACT}" >&2 exit 1 @@ -33,6 +36,8 @@ ARTIFACT_FILE="$(basename "${ARTIFACT}")" echo "=== Импорт стартовой истории ===" echo "ARTIFACT=${ARTIFACT}" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" echo "GEN_SEED=${GEN_SEED}" echo "GEN_MODEL_T0=${GEN_MODEL_T0}" echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" @@ -47,6 +52,7 @@ ${COMPOSE_BIN} build generator echo "Шаг 3: воспроизведение артефакта в Kafka" ${COMPOSE_BIN} run --rm --no-deps \ -v "${ARTIFACT_DIR}:/startup-artifacts:ro" \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ -e GEN_SEED="${GEN_SEED}" \ -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ diff --git a/scripts/run_generated_history_analytics.sh b/scripts/run_generated_history_analytics.sh index 26414a9..5c26ad3 100644 --- a/scripts/run_generated_history_analytics.sh +++ b/scripts/run_generated_history_analytics.sh @@ -15,20 +15,23 @@ COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" CLEAN_START="${CLEAN_START:-1}" WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}" WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-10}" - -GEN_SEED="${GEN_SEED:-4242}" -GEN_MODEL_T0="${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}" -GEN_MODEL_T_END="${GEN_MODEL_T_END:-2026-01-01T06:00:00+00:00}" -GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE:-UTC}" -GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED:-1}" -GEN_TICK_SECONDS="${GEN_TICK_SECONDS:-60}" -GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN:-60}" -GEN_JITTER_PCT="${GEN_JITTER_PCT:-0}" -GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK:-1}" -GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK:-1000}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi cd "${REPO_ROOT}" +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + backfill \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + wait_for_clickhouse() { local deadline deadline=$((SECONDS + WAIT_CLICKHOUSE_SECONDS)) @@ -46,6 +49,8 @@ wait_for_clickhouse() { } echo "=== Чистый прогон стартовой истории как источника аналитики ===" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" echo "GEN_SEED=${GEN_SEED}" echo "GEN_MODEL_T0=${GEN_MODEL_T0}" echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" @@ -73,6 +78,7 @@ echo "Шаг 4: backfill стартовой истории в Kafka" ${COMPOSE_BIN} run --rm --no-deps \ -e GEN_RUN_MODE=backfill \ -e GEN_STATE_RESET=true \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ -e GEN_SEED="${GEN_SEED}" \ -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ diff --git a/scripts/run_generator.sh b/scripts/run_generator.sh new file mode 100644 index 0000000..4578861 --- /dev/null +++ b/scripts/run_generator.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Запускает генератор через понятные глаголы поверх старых env-переменных. + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Использование: $0 backfill|continue|reset" >&2 + exit 2 +fi + +VERB="$1" +case "${VERB}" in + backfill|continue|reset) ;; + *) + echo "Ошибка: неизвестный глагол запуска: ${VERB}" >&2 + exit 2 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +duration_args=() +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + duration_args=(--duration "${GEN_HISTORY_DURATION}") +fi + +cd "${REPO_ROOT}" + +launch_env="$( + PYTHONPATH="${REPO_ROOT}/generator/src" \ + uv run python -m clickstream_generator.launch \ + "${VERB}" \ + --profile "${PROFILE}" \ + "${duration_args[@]}" +)" +eval "${launch_env}" + +echo "=== Запуск генератора: ${GEN_LAUNCH_VERB} ===" +echo "GEN_LAUNCH_PROFILE=${GEN_LAUNCH_PROFILE}" +if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then + echo "GEN_HISTORY_DURATION=${GEN_HISTORY_DURATION}" +fi +echo "GEN_RUN_MODE=${GEN_RUN_MODE}" +echo "GEN_STATE_RESET=${GEN_STATE_RESET}" +if [[ -n "${GEN_MODEL_T_END:-}" ]]; then + echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}" +fi +echo "" + +if [[ "${VERB}" == "backfill" ]]; then + echo "Шаг 1: запуск Kafka" + ${COMPOSE_BIN} up -d kafka + + echo "Шаг 2: сборка образа генератора" + ${COMPOSE_BIN} build generator + + echo "Шаг 3: backfill стартовой истории" + ${COMPOSE_BIN} run --rm --no-deps \ + -e GEN_RUN_MODE="${GEN_RUN_MODE}" \ + -e GEN_STATE_RESET="${GEN_STATE_RESET}" \ + -e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ + -e GEN_SEED="${GEN_SEED}" \ + -e GEN_MODEL_T0="${GEN_MODEL_T0}" \ + -e GEN_MODEL_T_END="${GEN_MODEL_T_END}" \ + -e GEN_MODEL_TIMEZONE="${GEN_MODEL_TIMEZONE}" \ + -e GEN_MODEL_TIME_SPEED="${GEN_MODEL_TIME_SPEED}" \ + -e GEN_TICK_SECONDS="${GEN_TICK_SECONDS}" \ + -e GEN_LAMBDA_BASE_PER_MIN="${GEN_LAMBDA_BASE_PER_MIN}" \ + -e GEN_JITTER_PCT="${GEN_JITTER_PCT}" \ + -e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \ + -e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \ + generator +else + echo "Шаг 1: запуск live-генератора" + ${COMPOSE_BIN} up -d --build generator +fi