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.
This commit is contained in:
2026-07-04 18:51:10 +03:00
parent d2899e9ee3
commit 4f8f992363
19 changed files with 547 additions and 101 deletions
@@ -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:
@@ -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())
@@ -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}"
)
@@ -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": {