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
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
Комплексное тестирование генератора событий.
Проверяет граничные случаи, статистику и формат данных.
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from generator import (
Config, EventDictionary, EventGenerator,
BatchHistory, BatchRecord
)
class Colors:
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
RESET = "\033[0m"
def test_config_validation():
"""Тест валидации конфигурации."""
print("\n=== Test: Config Validation ===")
# Невалидный tick_seconds
try:
Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=0,
lambda_base_per_min=100,
jitter_pct=20,
min_events_per_tick=10,
max_events_per_tick=100,
data_dir=Path("/tmp"),
seed=None,
enabled=True,
)
print(f"{Colors.RED}FAIL: Should raise ValueError for tick_seconds=0{Colors.RESET}")
return False
except ValueError as e:
print(f"{Colors.GREEN}PASS: Correctly raised ValueError: {e}{Colors.RESET}")
# Невалидный lambda_base
try:
Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=0,
jitter_pct=20,
min_events_per_tick=10,
max_events_per_tick=100,
data_dir=Path("/tmp"),
seed=None,
enabled=True,
)
print(f"{Colors.RED}FAIL: Should raise ValueError for lambda_base=0{Colors.RESET}")
return False
except ValueError as e:
print(f"{Colors.GREEN}PASS: Correctly raised ValueError: {e}{Colors.RESET}")
# Несуществующая директория
try:
Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=100,
jitter_pct=20,
min_events_per_tick=10,
max_events_per_tick=100,
data_dir=Path("/nonexistent/path"),
seed=None,
enabled=True,
)
print(f"{Colors.RED}FAIL: Should raise ValueError for non-existent dir{Colors.RESET}")
return False
except ValueError as e:
print(f"{Colors.GREEN}PASS: Correctly raised ValueError: {e}{Colors.RESET}")
return True
def test_empty_jsonl():
"""Тест обработки пустых JSONL файлов."""
print("\n=== Test: Empty JSONL Files ===")
with tempfile.TemporaryDirectory() as tmpdir:
# Создаём пустые файлы
for fname in ["browser_events.jsonl", "location_events.jsonl",
"device_events.jsonl", "geo_events.jsonl"]:
open(Path(tmpdir) / fname, "w").close()
try:
dictionary = EventDictionary.load(Path(tmpdir))
if (len(dictionary.browser_events) == 0 and
len(dictionary.location_events) == 0):
print(f"{Colors.GREEN}PASS: Empty files handled correctly{Colors.RESET}")
return True
else:
print(f"{Colors.RED}FAIL: Expected empty lists{Colors.RESET}")
return False
except Exception as e:
print(f"{Colors.RED}FAIL: Exception with empty files: {e}{Colors.RESET}")
return False
def test_event_dictionary_consistency():
"""Тест консистентности связей в словаре."""
print("\n=== Test: Event Dictionary Consistency ===")
data_dir = Path(__file__).parent.parent / "data"
dictionary = EventDictionary.load(data_dir)
# Проверяем, что все location имеют соответствующий event_id в browser
browser_event_ids = {e["event_id"] for e in dictionary.browser_events}
location_orphaned = 0
for loc in dictionary.location_events:
if loc["event_id"] not in browser_event_ids:
location_orphaned += 1
# Проверяем, что все device/geo имеют соответствующий click_id в browser
browser_click_ids = {e["click_id"] for e in dictionary.browser_events}
device_orphaned = 0
for dev in dictionary.device_events:
if dev["click_id"] not in browser_click_ids:
device_orphaned += 1
geo_orphaned = 0
for geo in dictionary.geo_events:
if geo["click_id"] not in browser_click_ids:
geo_orphaned += 1
print(f" Browser events: {len(dictionary.browser_events)}")
print(f" Location events: {len(dictionary.location_events)} (orphaned: {location_orphaned})")
print(f" Device events: {len(dictionary.device_events)} (orphaned: {device_orphaned})")
print(f" Geo events: {len(dictionary.geo_events)} (orphaned: {geo_orphaned})")
# Для MVP допустимы orphaned записи, но предупреждаем
if location_orphaned > 0 or device_orphaned > 0 or geo_orphaned > 0:
print(f"{Colors.YELLOW}WARNING: Found orphaned records{Colors.RESET}")
else:
print(f"{Colors.GREEN}PASS: All records are consistent{Colors.RESET}")
return True
def test_poisson_distribution():
"""Тест статистической модели (распределение Пуассона)."""
print("\n=== Test: Poisson Distribution ===")
data_dir = Path(__file__).parent.parent / "data"
dictionary = EventDictionary.load(data_dir)
config = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=200,
jitter_pct=20,
min_events_per_tick=50,
max_events_per_tick=500,
data_dir=data_dir,
seed=42,
enabled=True,
)
generator = EventGenerator(dictionary, config)
# Генерируем 1000 значений
samples = [generator._calculate_events_count() for _ in range(1000)]
mean = sum(samples) / len(samples)
min_val = min(samples)
max_val = max(samples)
# Проверяем границы
if min_val < config.min_events_per_tick:
print(f"{Colors.RED}FAIL: min={min_val} < {config.min_events_per_tick}{Colors.RESET}")
return False
if max_val > config.max_events_per_tick:
print(f"{Colors.RED}FAIL: max={max_val} > {config.max_events_per_tick}{Colors.RESET}")
return False
# Проверяем среднее (должно быть около lambda_base при hour_factor=1.0)
expected = config.lambda_base_per_min # примерно
deviation = abs(mean - expected) / expected * 100
print(f" Samples: 1000")
print(f" Min: {min_val}, Max: {max_val}")
print(f" Mean: {mean:.2f} (expected ~{expected}, deviation: {deviation:.1f}%)")
# Допустимое отклонение до 30% (зависит от часа и случайности)
if deviation < 30:
print(f"{Colors.GREEN}PASS: Mean is within acceptable range{Colors.RESET}")
return True
else:
print(f"{Colors.YELLOW}WARNING: Mean deviation is high (maybe different hour?){Colors.RESET}")
return True
def test_generate_batch_format():
"""Тест формата сгенерированных событий."""
print("\n=== Test: Generated Event Format ===")
data_dir = Path(__file__).parent.parent / "data"
dictionary = EventDictionary.load(data_dir)
config = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=10,
jitter_pct=20,
min_events_per_tick=5,
max_events_per_tick=20,
data_dir=data_dir,
seed=42,
enabled=True,
)
generator = EventGenerator(dictionary, config)
batch = generator.generate_batch(10)
errors = []
# Проверяем структуру батча
required_topics = ["browser_events", "location_events", "device_events", "geo_events"]
for topic in required_topics:
if topic not in batch:
errors.append(f"Missing topic: {topic}")
# Проверяем формат browser_events
for i, event in enumerate(batch["browser_events"]):
required_fields = ["event_id", "event_timestamp", "event_type", "click_id",
"browser_name", "browser_user_agent", "browser_language"]
for field in required_fields:
if field not in event:
errors.append(f"browser_event[{i}] missing field: {field}")
# Проверяем UUID
try:
import uuid
uuid.UUID(event["event_id"])
uuid.UUID(event["click_id"])
except (ValueError, KeyError) as e:
errors.append(f"browser_event[{i}] invalid UUID: {e}")
# Проверяем timestamp
try:
datetime.fromisoformat(event["event_timestamp"].replace(" ", "T"))
except (ValueError, KeyError) as e:
errors.append(f"browser_event[{i}] invalid timestamp: {e}")
# Проверяем связи
browser_event_ids = {e["event_id"] for e in batch["browser_events"]}
for loc in batch["location_events"]:
if loc["event_id"] not in browser_event_ids:
errors.append(f"location event_id {loc['event_id'][:8]}... not in browser events")
browser_click_ids = {e["click_id"] for e in batch["browser_events"]}
for dev in batch["device_events"]:
if dev["click_id"] not in browser_click_ids:
errors.append(f"device click_id {dev['click_id'][:8]}... not in browser events")
for geo in batch["geo_events"]:
if geo["click_id"] not in browser_click_ids:
errors.append(f"geo click_id {geo['click_id'][:8]}... not in browser events")
if errors:
print(f"{Colors.RED}FAIL: Found {len(errors)} errors:{Colors.RESET}")
for e in errors[:5]:
print(f" - {e}")
return False
else:
print(f"{Colors.GREEN}PASS: All events have valid format and consistent links{Colors.RESET}")
return True
def test_batch_history():
"""Тест истории батчей."""
print("\n=== Test: Batch History ===")
history = BatchHistory()
# Добавляем записи
from datetime import datetime, timezone
for i in range(5):
history.add(BatchRecord(
batch_id=f"batch_{i}",
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
sent_total=100,
sent_browser=25,
sent_location=25,
sent_device=25,
sent_geo=25,
status="success" if i % 2 == 0 else "error",
error_message=None if i % 2 == 0 else "Test error",
))
stats = history.get_stats()
if stats["total_batches"] == 5:
print(f"{Colors.GREEN}PASS: History tracking works{Colors.RESET}")
return True
else:
print(f"{Colors.RED}FAIL: Expected 5 batches, got {stats['total_batches']}{Colors.RESET}")
return False
def test_reproducibility():
"""Тест воспроизводимости с одинаковым seed."""
print("\n=== Test: Reproducibility ===")
data_dir = Path(__file__).parent.parent / "data"
dictionary = EventDictionary.load(data_dir)
config1 = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=100,
jitter_pct=20,
min_events_per_tick=10,
max_events_per_tick=200,
data_dir=data_dir,
seed=12345,
enabled=True,
)
config2 = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=100,
jitter_pct=20,
min_events_per_tick=10,
max_events_per_tick=200,
data_dir=data_dir,
seed=12345,
enabled=True,
)
gen1 = EventGenerator(dictionary, config1)
gen2 = EventGenerator(dictionary, config2)
# Генерируем батчи
batch1 = gen1.generate_batch(10)
batch2 = gen2.generate_batch(10)
# Проверяем, что event_id разные (UUID всегда новые)
ids1 = [e["event_id"] for e in batch1["browser_events"]]
ids2 = [e["event_id"] for e in batch2["browser_events"]]
# UUID должны быть разными даже с одинаковым seed (uuid4 случайный)
if ids1 != ids2:
print(f"{Colors.GREEN}PASS: UUIDs are unique per generation{Colors.RESET}")
return True
else:
print(f"{Colors.RED}FAIL: UUIDs should be unique{Colors.RESET}")
return False
def test_large_lambda():
"""Тест с большим lambda (проверка на underflow)."""
print("\n=== Test: Large Lambda (Edge Case) ===")
data_dir = Path(__file__).parent.parent / "data"
dictionary = EventDictionary.load(data_dir)
config = Config(
kafka_bootstrap_servers="localhost:9092",
tick_seconds=60,
lambda_base_per_min=10000, # Очень большое значение
jitter_pct=20,
min_events_per_tick=100,
max_events_per_tick=500,
data_dir=data_dir,
seed=42,
enabled=True,
)
generator = EventGenerator(dictionary, config)
# Должно вернуть max_events_per_tick (ограничение)
count = generator._calculate_events_count()
if count == config.max_events_per_tick:
print(f"{Colors.GREEN}PASS: Large lambda correctly capped at max{Colors.RESET}")
return True
else:
print(f"{Colors.YELLOW}WARNING: Expected {config.max_events_per_tick}, got {count}{Colors.RESET}")
return True # Не критично
def run_all_tests():
"""Запускает все тесты."""
print("=" * 60)
print("COMPREHENSIVE GENERATOR TESTS")
print("=" * 60)
tests = [
test_config_validation,
test_empty_jsonl,
test_event_dictionary_consistency,
test_poisson_distribution,
test_generate_batch_format,
test_batch_history,
test_reproducibility,
test_large_lambda,
]
results = []
for test in tests:
try:
result = test()
results.append((test.__name__, result))
except Exception as e:
print(f"{Colors.RED}EXCEPTION in {test.__name__}: {e}{Colors.RESET}")
import traceback
traceback.print_exc()
results.append((test.__name__, False))
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
passed = sum(1 for _, r in results if r)
total = len(results)
for name, result in results:
status = f"{Colors.GREEN}PASS{Colors.RESET}" if result else f"{Colors.RED}FAIL{Colors.RESET}"
print(f" {name}: {status}")
print(f"\nTotal: {passed}/{total} tests passed")
return passed == total
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)