Files
clickstream-ch-kafka-supers…/generator/src/clickstream_generator/launch.py
T
ddadminandClaude Fable 5 10ee1a1cb6 feat(generator): эталонный мир в git и import по умолчанию
- Зачем:
  - менти собирал мир генерацией (минуты и десятки минут 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>
2026-07-22 18:49:02 +03:00

190 lines
5.8 KiB
Python

"""Глаголы и профили запуска генератора."""
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="3d",
env={
"GEN_SEED": "4242",
"GEN_MODEL_T0": "2026-01-01T00:00:00+00:00",
"GEN_MODEL_TIMEZONE": "UTC",
"GEN_MODEL_TIME_SPEED": "60",
"GEN_TICK_SECONDS": "1",
"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 = "daily-wave",
duration: str | None = None,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Возвращает старые env-переменные для нового глагола запуска."""
if verb not in {"backfill", "continue", "next-day", "reset"}:
raise ValueError("глагол должен быть backfill, continue, next-day или 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)
elif verb == "next-day":
current_t_end = overrides.get("GEN_MODEL_T_END")
if not current_t_end:
raise ValueError("для next-day требуется GEN_MODEL_T_END")
env["GEN_RUN_MODE"] = "next-day"
env["GEN_STATE_RESET"] = "false"
env["GEN_MODEL_T_END"] = current_t_end
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", "next-day", "reset"),
)
parser.add_argument(
"--profile",
default=(
os.getenv("PROFILE")
or os.getenv("GEN_LAUNCH_PROFILE")
or "daily-wave"
),
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())