feat(infra): добавлен автономный генератор событий для Kafka

- Зачем:
  - нужен постоянный поток данных для демонстрации работы стека
  - текущий batch-загрузчик не позволяет показать streaming-сценарии
- Что:
  - добавлен сервис generator с режимом steady (Poisson-интенсивность)
  - генератор публикует в 4 топика: browser/location/device/geo_events
  - сохраняются связи event_id и click_id между событиями
  - сборка через uv для скорости и компактности образа
  - добавлены команды generator-* в Makefile
  - комплексные тесты: валидация, статистика, формат сообщений
- Проверка:
  - `docker run --rm -v $(pwd)/..:/workspace -w /workspace/generator generator:test python test_comprehensive.py` — 8/8 тестов
  - `make generator-up` — 3 тика без ошибок, отправлено 2904 сообщения
This commit is contained in:
2026-06-09 17:27:01 +03:00
committed by Dmitry Dementiev
parent 7c6cfd7d18
commit e8d1c9efa9
9 changed files with 1332 additions and 1 deletions
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env python3
"""
Автономный генератор событий для Kafka (MVP).
Режим 'steady': каждую минуту публикуем фиксированный объём событий
с небольшой вариативностью (Poisson + jitter).
"""
import json
import logging
import os
import random
import sys
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from kafka import KafkaProducer
from kafka.errors import KafkaError
# ---------------------------------------------------------------------------
# Настройка логирования
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("generator")
# ---------------------------------------------------------------------------
# Конфигурация через env
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Config:
"""Конфигурация генератора из переменных окружения."""
# Подключение к Kafka
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", "60"))
)
lambda_base_per_min: int = field(
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "200"))
)
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", "50"))
)
max_events_per_tick: int = field(
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "500"))
)
# Пути к данным
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"
)
# История batch (ClickHouse)
clickhouse_host: str = field(
default_factory=lambda: os.getenv("CLICKHOUSE_HOST", "clickhouse")
)
clickhouse_port: int = field(
default_factory=lambda: int(os.getenv("CLICKHOUSE_PORT", "9000"))
)
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 not self.data_dir.exists():
raise ValueError(f"Data directory does not exist: {self.data_dir}")
# ---------------------------------------------------------------------------
# Загрузка базового словаря событий
# ---------------------------------------------------------------------------
@dataclass
class EventDictionary:
"""Базовый словарь событий из JSONL файлов."""
browser_events: list[dict[str, Any]]
location_events: list[dict[str, Any]]
device_events: list[dict[str, Any]]
geo_events: list[dict[str, Any]]
# Индексы для быстрого поиска
location_by_event_id: dict[str, dict] = field(default_factory=dict)
device_by_click_id: dict[str, dict] = field(default_factory=dict)
geo_by_click_id: dict[str, dict] = field(default_factory=dict)
def __post_init__(self):
# Строим индексы для связности
for loc in self.location_events:
self.location_by_event_id[loc["event_id"]] = loc
for dev in self.device_events:
self.device_by_click_id[dev["click_id"]] = dev
for geo in self.geo_events:
self.geo_by_click_id[geo["click_id"]] = geo
@classmethod
def load(cls, data_dir: Path) -> "EventDictionary":
"""Загружает события из JSONL файлов."""
logger.info(f"Loading event dictionary from {data_dir}")
def load_jsonl(filename: str) -> list[dict]:
path = data_dir / filename
events = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
events.append(json.loads(line))
logger.info(f" Loaded {len(events)} events from {filename}")
return events
return cls(
browser_events=load_jsonl("browser_events.jsonl"),
location_events=load_jsonl("location_events.jsonl"),
device_events=load_jsonl("device_events.jsonl"),
geo_events=load_jsonl("geo_events.jsonl"),
)
# ---------------------------------------------------------------------------
# Генерация событий
# ---------------------------------------------------------------------------
class EventGenerator:
"""Генератор событий с сохранением связности."""
def __init__(self, dictionary: EventDictionary, config: Config):
self.dictionary = dictionary
self.config = config
self.rng = random.Random(config.seed)
def _new_uuid(self) -> str:
"""Генерирует новый UUID."""
return str(uuid.uuid4())
def _current_timestamp(self) -> str:
"""Возвращает текущую метку времени в формате JSONL."""
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
def _hour_factor(self) -> float:
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
hour = datetime.now(timezone.utc).hour
# Дневное окно (9-18): 1.2
# Ночное окно (0-5): 0.7
# Остальное: 1.0
if 9 <= hour <= 18:
return 1.2
elif 0 <= hour <= 5:
return 0.7
return 1.0
def _calculate_events_count(self) -> int:
"""Вычисляет количество событий для текущего тика (Poisson + ограничения)."""
import math
# Базовая интенсивность с учётом часа
lambda_t = self.config.lambda_base_per_min * self._hour_factor()
# Масштабируем на длительность тика
lambda_tick = lambda_t * (self.config.tick_seconds / 60.0)
# Генерируем Poisson
# Используем numpy-style подход через exponential
count = 0
L = math.exp(-lambda_tick)
p = 1.0
while p > L:
p *= self.rng.random()
count += 1
count -= 1
# Применяем границы
count = max(self.config.min_events_per_tick, min(count, self.config.max_events_per_tick))
return count
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]:
"""
Генерирует батч событий с сохранением связей.
Возвращает словарь {topic: [events]}
"""
batch = {
"browser_events": [],
"location_events": [],
"device_events": [],
"geo_events": [],
}
for _ in range(batch_size):
# Выбираем случайное браузерное событие как базу
base_browser = self.rng.choice(self.dictionary.browser_events)
base_location = self.dictionary.location_by_event_id.get(base_browser["event_id"])
base_device = self.dictionary.device_by_click_id.get(base_browser["click_id"])
base_geo = self.dictionary.geo_by_click_id.get(base_browser["click_id"])
# Генерируем новые ID
new_event_id = self._new_uuid()
new_click_id = self._new_uuid()
new_timestamp = self._current_timestamp()
# Создаём новое браузерное событие
browser_event = {
**base_browser,
"event_id": new_event_id,
"click_id": new_click_id,
"event_timestamp": new_timestamp,
}
batch["browser_events"].append(browser_event)
# Связанное location событие
if base_location:
location_event = {
**base_location,
"event_id": new_event_id,
}
batch["location_events"].append(location_event)
# Связанное device событие
if base_device:
device_event = {
**base_device,
"click_id": new_click_id,
}
batch["device_events"].append(device_event)
# Связанное geo событие
if base_geo:
geo_event = {
**base_geo,
"click_id": new_click_id,
}
batch["geo_events"].append(geo_event)
return batch
# ---------------------------------------------------------------------------
# Batch history (мета-информация)
# ---------------------------------------------------------------------------
@dataclass
class BatchRecord:
"""Запись об отправленном батче."""
batch_id: str
started_at: datetime
finished_at: datetime
sent_total: int
sent_browser: int
sent_location: int
sent_device: int
sent_geo: int
status: str # 'success', 'partial', 'error'
error_message: str | None = None
class BatchHistory:
"""Хранение истории batch (пока в памяти, потом в ClickHouse)."""
def __init__(self):
self.batches: list[BatchRecord] = []
def add(self, record: BatchRecord):
self.batches.append(record)
# Храним последние 1000 batch
if len(self.batches) > 1000:
self.batches = self.batches[-1000:]
def get_stats(self) -> dict:
"""Возвращает статистику по истории."""
if not self.batches:
return {}
total = len(self.batches)
success = sum(1 for b in self.batches if b.status == "success")
return {
"total_batches": total,
"success_rate": success / total if total > 0 else 0,
"last_batch_status": self.batches[-1].status if self.batches else None,
}
# ---------------------------------------------------------------------------
# Kafka publisher
# ---------------------------------------------------------------------------
class KafkaPublisher:
"""Публикация событий в Kafka."""
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
self.producer: KafkaProducer | None = None
self._connect()
def _connect(self):
"""Устанавливает соединение с Kafka."""
logger.info(f"Connecting to Kafka at {self.bootstrap_servers}")
try:
self.producer = KafkaProducer(
bootstrap_servers=self.bootstrap_servers,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
# Небольшая буферизация для производительности
batch_size=16384,
linger_ms=100,
retries=3,
retry_backoff_ms=1000,
)
logger.info("Connected to Kafka successfully")
except KafkaError as e:
logger.error(f"Failed to connect to Kafka: {e}")
raise
def publish(self, topic: str, events: list[dict]) -> tuple[int, int]:
"""
Публикует события в топик.
Returns:
(sent_count, error_count)
"""
if not self.producer:
raise RuntimeError("Producer not connected")
sent = 0
errors = 0
futures = []
for event in events:
# Используем event_id или click_id как ключ для партиционирования
key = event.get("event_id") or event.get("click_id")
try:
future = self.producer.send(topic, key=key, value=event)
futures.append(future)
except KafkaError as e:
logger.error(f"Failed to send message to {topic}: {e}")
errors += 1
# Ждём подтверждений
for future in futures:
try:
future.get(timeout=10)
sent += 1
except KafkaError as e:
logger.error(f"Failed to confirm message delivery: {e}")
errors += 1
return sent, errors
def flush(self):
"""Сбрасывает буфер."""
if self.producer:
self.producer.flush()
def close(self):
"""Закрывает соединение."""
if self.producer:
self.producer.close()
# ---------------------------------------------------------------------------
# Основной цикл генератора
# ---------------------------------------------------------------------------
class GeneratorService:
"""Основной сервис генератора."""
def __init__(self, config: Config):
self.config = config
self.dictionary = EventDictionary.load(config.data_dir)
self.generator = EventGenerator(self.dictionary, config)
self.publisher: KafkaPublisher | None = None
self.history = BatchHistory()
self._running = False
def start(self):
"""Запускает основной цикл."""
if not self.config.enabled:
logger.warning("Generator is disabled (GEN_ENABLED=false)")
return
logger.info("Starting generator service...")
logger.info(f"Configuration: tick={self.config.tick_seconds}s, "
f"lambda_base={self.config.lambda_base_per_min}, "
f"jitter={self.config.jitter_pct}%")
self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers)
self._running = True
try:
self._main_loop()
except KeyboardInterrupt:
logger.info("Received shutdown signal")
finally:
self.stop()
def stop(self):
"""Останавливает сервис."""
logger.info("Stopping generator service...")
self._running = False
if self.publisher:
self.publisher.close()
def _main_loop(self):
"""Основной цикл тиков."""
tick = 0
while self._running:
tick += 1
tick_start = time.time()
batch_id = str(uuid.uuid4())[:8]
logger.info(f"=== Tick {tick} (batch_id={batch_id}) ===")
try:
# Вычисляем количество событий
events_count = self.generator._calculate_events_count()
logger.info(f"Generating ~{events_count} base events")
# Генерируем батч
gen_start = time.time()
batch = self.generator.generate_batch(events_count)
gen_duration = time.time() - gen_start
# Публикуем в Kafka
pub_start = time.time()
total_sent = 0
total_errors = 0
sent_counts = {}
for topic, events in batch.items():
if events:
sent, errors = self.publisher.publish(topic, events)
sent_counts[topic] = {"sent": sent, "errors": errors}
total_sent += sent
total_errors += errors
self.publisher.flush()
pub_duration = time.time() - pub_start
# Определяем статус
if total_errors == 0:
status = "success"
elif total_sent > 0:
status = "partial"
else:
status = "error"
# Сохраняем в историю
batch_record = BatchRecord(
batch_id=batch_id,
started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc),
finished_at=datetime.now(timezone.utc),
sent_total=total_sent,
sent_browser=sent_counts.get("browser_events", {}).get("sent", 0),
sent_location=sent_counts.get("location_events", {}).get("sent", 0),
sent_device=sent_counts.get("device_events", {}).get("sent", 0),
sent_geo=sent_counts.get("geo_events", {}).get("sent", 0),
status=status,
error_message=None if status == "success" else f"Errors: {total_errors}",
)
self.history.add(batch_record)
# Логируем результат
tick_duration = time.time() - tick_start
logger.info(
f"Batch {batch_id} completed: "
f"sent={total_sent}, errors={total_errors}, "
f"gen_time={gen_duration:.3f}s, pub_time={pub_duration:.3f}s, "
f"total_time={tick_duration:.3f}s"
)
# Выводим детализацию по топикам
for topic, counts in sent_counts.items():
if counts["sent"] > 0:
logger.info(f" {topic}: {counts['sent']} sent")
except Exception as e:
logger.exception(f"Error in tick {tick}: {e}")
# Сохраняем ошибку в историю
self.history.add(
BatchRecord(
batch_id=batch_id,
started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc),
finished_at=datetime.now(timezone.utc),
sent_total=0,
sent_browser=0,
sent_location=0,
sent_device=0,
sent_geo=0,
status="error",
error_message=str(e),
)
)
# Ждём до следующего тика
elapsed = time.time() - tick_start
sleep_time = max(0, self.config.tick_seconds - elapsed)
if sleep_time > 0:
logger.info(f"Sleeping for {sleep_time:.1f}s until next tick")
time.sleep(sleep_time)
# ---------------------------------------------------------------------------
# Точка входа
# ---------------------------------------------------------------------------
def main():
try:
config = Config()
service = GeneratorService(config)
service.start()
except Exception as e:
logger.exception(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()