feat(generator): добавлено модельное время live-потока
- Зачем: - генератор должен писать события от модельной точки T0 и проверяться повторяемо в ClickHouse. - Что: - добавлены настройки модельного времени и передача модельной точки в live-тик. - дневной коэффициент считается по модельному времени и часовому поясу. - обновлены проверки, compose, документация и статус issue 02. - Проверка: - make generator-test. - два чистых ClickHouse-прогона с GEN_STATE_RESET=true дали одинаковые контрольные числа.
This commit is contained in:
@@ -2,7 +2,18 @@
|
||||
|
||||
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)
|
||||
@@ -62,6 +73,20 @@ class Config:
|
||||
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_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")
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tick_seconds < 1:
|
||||
@@ -82,3 +107,18 @@ class Config:
|
||||
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"}:
|
||||
raise ValueError("GEN_RUN_MODE must be live or backfill")
|
||||
|
||||
@@ -127,13 +127,13 @@ class EventGenerator:
|
||||
pause = self.rng.lognormvariate(math.log(20.0), 0.9)
|
||||
return max(1.0, min(pause, 29 * 60.0))
|
||||
|
||||
def _hour_factor(self) -> float:
|
||||
def _hour_factor(self, now: datetime | None = None) -> float:
|
||||
"""Совместимый wrapper над расчётом часового коэффициента."""
|
||||
return hour_factor()
|
||||
return hour_factor(now, self.config.model_timezone)
|
||||
|
||||
def _calculate_events_count(self) -> int:
|
||||
def _calculate_events_count(self, now: datetime | None = None) -> int:
|
||||
"""Совместимый wrapper над расчётом событийного бюджета."""
|
||||
return calculate_events_count(self.config, self.rng)
|
||||
return calculate_events_count(self.config, self.rng, now=now)
|
||||
|
||||
def generate_batch(
|
||||
self,
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
import math
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from clickstream_generator.config import Config
|
||||
|
||||
|
||||
def hour_factor(now: datetime | None = None) -> float:
|
||||
def hour_factor(now: datetime | None = None, model_timezone: str = "UTC") -> float:
|
||||
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
|
||||
current = now or datetime.now(timezone.utc)
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
current = current.astimezone(ZoneInfo(model_timezone))
|
||||
hour = current.hour
|
||||
if 9 <= hour <= 18:
|
||||
return 1.2
|
||||
@@ -18,9 +22,17 @@ def hour_factor(now: datetime | None = None) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
def calculate_events_count(config: Config, rng: random.Random) -> int:
|
||||
def calculate_events_count(
|
||||
config: Config,
|
||||
rng: random.Random,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Вычисляет количество событий для текущего тика (Poisson + jitter)."""
|
||||
lambda_minute = config.lambda_base_per_min * hour_factor()
|
||||
if now is None:
|
||||
factor = hour_factor()
|
||||
else:
|
||||
factor = hour_factor(now, config.model_timezone)
|
||||
lambda_minute = config.lambda_base_per_min * factor
|
||||
lambda_tick = lambda_minute * (config.tick_seconds / 60.0)
|
||||
|
||||
count = 0
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from prometheus_client import start_http_server
|
||||
|
||||
@@ -42,6 +42,7 @@ class GeneratorService:
|
||||
self.state_manager: KafkaStateManager | None = None
|
||||
self._running = False
|
||||
self._tick = 0
|
||||
self._model_time = config.model_t0
|
||||
|
||||
def start(self):
|
||||
"""Запускает основной цикл."""
|
||||
@@ -139,16 +140,22 @@ class GeneratorService:
|
||||
self._tick += 1
|
||||
tick_start = time.time()
|
||||
batch_id = str(uuid.uuid4())[:8]
|
||||
model_time = self._model_time
|
||||
|
||||
with METRICS_TICK_DURATION.time():
|
||||
logger.info(f"=== Tick {self._tick} (batch_id={batch_id}) ===")
|
||||
|
||||
try:
|
||||
events_count = self.generator._calculate_events_count()
|
||||
events_count = self.generator._calculate_events_count(
|
||||
now=model_time,
|
||||
)
|
||||
logger.info(f"Generating with event budget ~{events_count}")
|
||||
|
||||
gen_start = time.time()
|
||||
batch = self.stream.generate_tick(events_count)
|
||||
batch = self.stream.generate_tick(
|
||||
events_count,
|
||||
tick_started_at=model_time,
|
||||
)
|
||||
gen_duration = time.time() - gen_start
|
||||
|
||||
pub_start = time.time()
|
||||
@@ -173,6 +180,7 @@ class GeneratorService:
|
||||
if status in ("success", "partial"):
|
||||
METRICS_LAST_SUCCESS.set_to_current_time()
|
||||
self._save_state(batch_id)
|
||||
self._advance_model_time()
|
||||
|
||||
self.publisher.flush()
|
||||
pub_duration = time.time() - pub_start
|
||||
@@ -235,6 +243,12 @@ class GeneratorService:
|
||||
logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
def _advance_model_time(self) -> None:
|
||||
"""Сдвигает модельное время после успешного live-тика."""
|
||||
self._model_time = self._model_time + timedelta(
|
||||
seconds=self.config.tick_seconds * self.config.model_time_speed,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Точка входа сервиса."""
|
||||
|
||||
Reference in New Issue
Block a user