- Зачем:
- режим кормления стенда порциями: менти триггерит «следующий день»,
видит полный цикл DWH за один шаг (задача 13, вариант 2 — генерация
от слепка T_end).
- Что:
- новый ограниченный режим next-day: восстановление мира из state,
генерация ровно [T_end, T_end+24h), публикация данные -> state ->
манифест (манифест — точка фиксации, автоотката нет).
- операция next-day в DAG generator_control: своя предпроверка границы
вместо clean-guard, идемпотентность через параметр expected_t_end.
- цепочка границ — накопительное поле boundaries в манифесте, старый
формат читается как [T0, T_end]; импорт не изменён.
- новая проверка цепочки (make generated-history-chain-check): непарные
счётчики и однородность по каждой границе, явный статус нулевого
стыка, хвост за границей по всем четырём топикам, литералы в UTC
с микросекундами.
- документация OPERATIONS.md: глагол, предпроверка, восстановление
после сбоя, ограничение retention; в задаче 13 — решения двух слепых
ревью постановки и кода с аргументами отклонений.
- Проверка:
- make test: 204 теста генератора + 31 контракт корня, зелёные.
- make generated-history-chain-check: зелёный, 2 внутренние границы,
непарные счётчики нулевые; учебный цикл: DM 322 -> 10026 -> 19196
за два next-day подряд.
- make generated-history-runtime-check (регрессия задачи 20): зелёный.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
"""Конфигурация генератора из переменных окружения."""
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
|
|
def _parse_model_timestamp(value: str) -> datetime:
|
|
"""Разбирает ISO-метку модельного времени и нормализует её к UTC."""
|
|
normalized = value.replace("Z", "+00:00")
|
|
timestamp = datetime.fromisoformat(normalized)
|
|
if timestamp.tzinfo is None:
|
|
raise ValueError("GEN_MODEL_T0 must include timezone")
|
|
return timestamp.astimezone(timezone.utc)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
"""Конфигурация генератора из переменных окружения."""
|
|
|
|
kafka_bootstrap_servers: str = field(
|
|
default_factory=lambda: os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:29092")
|
|
)
|
|
tick_seconds: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
|
|
)
|
|
lambda_base_per_min: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "30"))
|
|
)
|
|
jitter_pct: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
|
|
)
|
|
min_events_per_tick: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "1"))
|
|
)
|
|
max_events_per_tick: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
|
)
|
|
max_session_events: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
|
|
)
|
|
max_active_sessions: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_MAX_ACTIVE_SESSIONS", "200"))
|
|
)
|
|
population_max: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_POPULATION_MAX", "300"))
|
|
)
|
|
p_new_user: float = field(
|
|
default_factory=lambda: float(os.getenv("GEN_P_NEW_USER", "0.15"))
|
|
)
|
|
min_return_minutes: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_MIN_RETURN_MINUTES", "30"))
|
|
)
|
|
data_dir: Path = field(
|
|
default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data"))
|
|
)
|
|
seed: int | None = field(
|
|
default_factory=lambda: int(os.getenv("GEN_SEED"))
|
|
if os.getenv("GEN_SEED")
|
|
else None
|
|
)
|
|
enabled: bool = field(
|
|
default_factory=lambda: os.getenv("GEN_ENABLED", "true").lower() == "true"
|
|
)
|
|
metrics_port: int = field(
|
|
default_factory=lambda: int(os.getenv("GEN_METRICS_PORT", "9109"))
|
|
)
|
|
metrics_enabled: bool = field(
|
|
default_factory=lambda: os.getenv("GEN_METRICS_ENABLED", "true").lower()
|
|
== "true"
|
|
)
|
|
state_enabled: bool = field(
|
|
default_factory=lambda: os.getenv("GEN_STATE_ENABLED", "true").lower() == "true"
|
|
)
|
|
state_reset: bool = field(
|
|
default_factory=lambda: os.getenv("GEN_STATE_RESET", "false").lower() == "true"
|
|
)
|
|
model_t0: datetime = field(
|
|
default_factory=lambda: _parse_model_timestamp(
|
|
os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00")
|
|
)
|
|
)
|
|
model_t_end: datetime | None = field(
|
|
default_factory=lambda: _parse_optional_model_timestamp(
|
|
os.getenv("GEN_MODEL_T_END")
|
|
)
|
|
)
|
|
model_timezone: str = field(
|
|
default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC")
|
|
)
|
|
model_time_speed: float = field(
|
|
default_factory=lambda: float(os.getenv("GEN_MODEL_TIME_SPEED", "1"))
|
|
)
|
|
run_mode: str = field(
|
|
default_factory=lambda: os.getenv("GEN_RUN_MODE", "live")
|
|
)
|
|
startup_history_artifact: Path | None = field(
|
|
default_factory=lambda: Path(os.getenv("GEN_STARTUP_HISTORY_ARTIFACT"))
|
|
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:
|
|
raise ValueError("GEN_TICK_SECONDS must be >= 1")
|
|
if self.lambda_base_per_min < 1:
|
|
raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1")
|
|
if self.max_session_events < 1:
|
|
raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1")
|
|
if self.max_active_sessions < 1:
|
|
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be >= 1")
|
|
if self.population_max < 1:
|
|
raise ValueError("GEN_POPULATION_MAX must be >= 1")
|
|
if not 0 <= self.p_new_user <= 1:
|
|
raise ValueError("GEN_P_NEW_USER must be between 0 and 1")
|
|
if self.min_return_minutes < 0:
|
|
raise ValueError("GEN_MIN_RETURN_MINUTES must be >= 0")
|
|
if self.max_active_sessions >= self.population_max:
|
|
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be < GEN_POPULATION_MAX")
|
|
if not self.data_dir.exists():
|
|
raise ValueError(f"Data directory does not exist: {self.data_dir}")
|
|
if self.model_t0.tzinfo is None:
|
|
raise ValueError("GEN_MODEL_T0 must include timezone")
|
|
object.__setattr__(
|
|
self,
|
|
"model_t0",
|
|
self.model_t0.astimezone(timezone.utc),
|
|
)
|
|
try:
|
|
ZoneInfo(self.model_timezone)
|
|
except ZoneInfoNotFoundError as e:
|
|
raise ValueError(f"Unknown GEN_MODEL_TIMEZONE: {self.model_timezone}") from e
|
|
if self.model_time_speed <= 0:
|
|
raise ValueError("GEN_MODEL_TIME_SPEED must be > 0")
|
|
if self.run_mode not in {"live", "backfill", "next-day"}:
|
|
raise ValueError("GEN_RUN_MODE должен быть live, backfill или next-day")
|
|
if self.model_t_end is not None:
|
|
object.__setattr__(
|
|
self,
|
|
"model_t_end",
|
|
self.model_t_end.astimezone(timezone.utc),
|
|
)
|
|
if self.run_mode == "backfill":
|
|
if self.model_t_end is None:
|
|
raise ValueError("GEN_MODEL_T_END is required for backfill")
|
|
if self.model_t_end <= self.model_t0:
|
|
raise ValueError("GEN_MODEL_T_END must be after GEN_MODEL_T0")
|
|
if self.run_mode == "next-day":
|
|
if self.model_t_end is None:
|
|
raise ValueError("для next-day требуется GEN_MODEL_T_END")
|
|
if self.model_t_end <= self.model_t0:
|
|
raise ValueError("GEN_MODEL_T_END должен быть позже GEN_MODEL_T0")
|
|
|
|
|
|
def _parse_optional_model_timestamp(value: str | None) -> datetime | None:
|
|
"""Разбирает необязательную ISO-метку модельного времени."""
|
|
if not value:
|
|
return None
|
|
return _parse_model_timestamp(value)
|