feat(generator): добавлено модельное время live-потока

- Зачем:
  - генератор должен писать события от модельной точки T0 и проверяться повторяемо в ClickHouse.
- Что:
  - добавлены настройки модельного времени и передача модельной точки в live-тик.
  - дневной коэффициент считается по модельному времени и часовому поясу.
  - обновлены проверки, compose, документация и статус issue 02.
- Проверка:
  - make generator-test.
  - два чистых ClickHouse-прогона с GEN_STATE_RESET=true дали одинаковые контрольные числа.
This commit is contained in:
2026-06-14 17:23:12 +03:00
parent 31a71f93ea
commit efb07b0283
10 changed files with 248 additions and 18 deletions
+9
View File
@@ -72,6 +72,10 @@ generator-service -> Kafka topics -> (потребители отдельно)
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
| `GEN_P_NEW_USER` | Вероятность отдать новый визит новому пользователю | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим генератора | `live` |
| `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` |
| `GEN_SEED` | Сид для воспроизводимости | — |
| `GEN_ENABLED` | Включить генерацию | `true` |
@@ -86,6 +90,11 @@ generator-service -> Kafka topics -> (потребители отдельно)
GEN_LAMBDA_BASE_PER_MIN=60 GEN_POPULATION_MAX=500 docker compose up -d generator
```
В живом режиме `event_timestamp` берётся из модельного времени: первый чистый
тик стартует от `GEN_MODEL_T0`, дальше модельная точка сдвигается на
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. Дневной коэффициент считается по
`GEN_MODEL_TIMEZONE`, а не по реальному часу запуска процесса.
Контейнерные значения `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose
оставлены безопасными внутренними значениями `kafka:29092` и `/data`.
@@ -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
+17 -3
View File
@@ -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():
"""Точка входа сервиса."""
+19
View File
@@ -3,6 +3,7 @@
"""
from pathlib import Path
from datetime import datetime, timedelta, timezone
import pytest
from generator import Config
@@ -60,6 +61,24 @@ class TestConfigValidation:
assert base_config.jitter_pct == 20
assert base_config.max_active_sessions < base_config.population_max
def test_model_t0_is_normalized_to_utc(self, base_config):
"""model_t0 нормализуется к UTC даже при прямом создании Config."""
from dataclasses import replace
config = replace(
base_config,
model_t0=datetime(
2026,
1,
1,
13,
0,
tzinfo=timezone(timedelta(hours=3)),
),
)
assert config.model_t0 == datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
class TestConfigDefaults:
"""Тесты значений по умолчанию."""
+71
View File
@@ -107,6 +107,77 @@ class TestGeneratorServiceDisabled:
class TestGeneratorServiceSteadyStream:
"""Проверки сервисного тика без настоящей Kafka."""
def test_service_live_tick_uses_model_time_for_events_and_day_factor(self, base_config):
"""Живой тик пишет события от T0 и не зависит от реального часа запуска."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
config = replace(
base_config,
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=1,
max_active_sessions=250,
population_max=251,
model_t0=model_t0,
)
night_wall_run = self._run_service_ticks(
config,
wall_now=datetime(2026, 6, 14, 3, 0, tzinfo=timezone.utc),
ticks_count=2,
)
day_wall_run = self._run_service_ticks(
config,
wall_now=datetime(2026, 6, 14, 11, 0, tzinfo=timezone.utc),
ticks_count=2,
)
assert len(night_wall_run["browser_events"]) == len(day_wall_run["browser_events"])
assert night_wall_run["browser_events"]
timestamps = {
event["event_timestamp"]
for event in night_wall_run["browser_events"]
}
assert timestamps == {
"2026-01-01 10:00:00.000000",
"2026-01-01 10:01:00.000000",
}
def _run_service_ticks(self, config, wall_now: datetime, ticks_count: int):
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = (
lambda topic, events: (len(events), 0)
)
service.history = MagicMock()
service._running = True
sleep_calls = 0
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
if tz is None:
return wall_now.replace(tzinfo=None)
return wall_now.astimezone(tz)
def stop_after_tick(_sleep_seconds):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls >= ticks_count:
service._running = False
with patch("clickstream_generator.intensity.datetime", FrozenDateTime), \
patch("clickstream_generator.service.time.sleep", side_effect=stop_after_tick):
service._main_loop()
published = {}
for call in service.publisher.publish.call_args_list:
topic, events = call.args
published.setdefault(topic, []).extend(events)
return published
def test_service_ticks_publish_connected_multi_event_visit(self, base_config):
"""Сервисные тики публикуют несколько связанных событий одного визита."""
config = replace(base_config, tick_seconds=1, max_session_events=3)