fix(generator): обработка findings из финального ревью

- Изолированы ошибки history-канала: best-effort с логированием, не валят тик
- Добавлено явное создание топика generator_batch_history при старте
- Добавлена защита от пустого словаря (ValueError при загрузке)
- Обновлена документация о структуре тестов
- Исправлены тесты на пустой словарь

Ревью: изоляция ошибок history, явное создание топика, защита от пустых данных
This commit is contained in:
2026-06-09 17:27:16 +03:00
committed by Dmitry Dementiev
parent ffe81167c3
commit ea114153b9
3 changed files with 96 additions and 58 deletions
+6 -4
View File
@@ -132,10 +132,12 @@ docker run --rm -v $(PWD):/workspace -w /workspace/generator generator:test pyte
``` ```
generator/tests/ generator/tests/
├── conftest.py # Fixtures pytest ├── conftest.py # Fixtures pytest
├── test_config.py # Тесты конфигурации ├── test_config.py # Тесты конфигурации
├── test_generation.py # Тесты генерации событий ├── test_generation.py # Тесты генерации событий
── test_history.py # Тесты истории батчей ── test_history.py # Тесты структуры BatchRecord
├── test_kafka_history.py # Тесты KafkaBatchHistory
└── test_service.py # Тесты GeneratorService
``` ```
### Интеграционный тест ### Интеграционный тест
+86 -34
View File
@@ -174,11 +174,19 @@ class EventDictionary:
logger.info(f" Loaded {len(events)} events from {filename}") logger.info(f" Loaded {len(events)} events from {filename}")
return events return events
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")
if not browser_events:
raise ValueError("browser_events.jsonl is empty or missing")
return cls( return cls(
browser_events=load_jsonl("browser_events.jsonl"), browser_events=browser_events,
location_events=load_jsonl("location_events.jsonl"), location_events=location_events,
device_events=load_jsonl("device_events.jsonl"), device_events=device_events,
geo_events=load_jsonl("geo_events.jsonl"), geo_events=geo_events,
) )
@@ -249,6 +257,15 @@ class EventGenerator:
Возвращает словарь {topic: [events]} Возвращает словарь {topic: [events]}
""" """
if not self.dictionary.browser_events:
logger.warning("Event dictionary is empty, skipping batch generation")
return {
"browser_events": [],
"location_events": [],
"device_events": [],
"geo_events": [],
}
batch = { batch = {
"browser_events": [], "browser_events": [],
"location_events": [], "location_events": [],
@@ -338,6 +355,30 @@ class BatchRecord:
} }
# ---------------------------------------------------------------------------
# Создание топика для истории
# ---------------------------------------------------------------------------
def ensure_history_topic(bootstrap_servers: str) -> None:
"""Создаёт топик для истории батчей если он не существует."""
from kafka import KafkaAdminClient
from kafka.admin import NewTopic
from kafka.errors import TopicAlreadyExistsError
admin_client = KafkaAdminClient(bootstrap_servers=bootstrap_servers)
try:
new_topic = NewTopic(
name=KafkaBatchHistory.HISTORY_TOPIC,
num_partitions=1,
replication_factor=1,
)
admin_client.create_topics([new_topic])
logger.info(f"Created topic: {KafkaBatchHistory.HISTORY_TOPIC}")
except TopicAlreadyExistsError:
logger.debug(f"Topic already exists: {KafkaBatchHistory.HISTORY_TOPIC}")
finally:
admin_client.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Kafka history - пишет историю в отдельный топик # Kafka history - пишет историю в отдельный топик
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -486,6 +527,8 @@ class GeneratorService:
f"jitter={self.config.jitter_pct}%") f"jitter={self.config.jitter_pct}%")
# Подключаемся к Kafka для публикации событий и истории # Подключаемся к Kafka для публикации событий и истории
# Сначала создаём топик для истории если нужно
ensure_history_topic(self.config.kafka_bootstrap_servers)
self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers) self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers)
self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers) self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers)
@@ -554,26 +597,30 @@ class GeneratorService:
if status in ("success", "partial"): if status in ("success", "partial"):
METRICS_LAST_SUCCESS.set_to_current_time() METRICS_LAST_SUCCESS.set_to_current_time()
# Сохраняем в историю (с fallback на in-memory при деградации Kafka) # Флашим публикацию событий
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)
# Флашим публикацию и историю
self.publisher.flush() self.publisher.flush()
self.history.flush()
pub_duration = time.time() - pub_start pub_duration = time.time() - pub_start
# Сохраняем в историю (best-effort: ошибки не валят тик)
try:
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)
self.history.flush()
except Exception as hist_err:
logger.warning(f"Failed to write batch history: {hist_err}")
METRICS_ERRORS_TOTAL.labels(topic="history").inc()
# Логируем результат # Логируем результат
tick_duration = time.time() - tick_start tick_duration = time.time() - tick_start
logger.info( logger.info(
@@ -589,20 +636,25 @@ class GeneratorService:
except Exception as e: except Exception as e:
logger.exception(f"Error in tick {tick}: {e}") logger.exception(f"Error in tick {tick}: {e}")
self.history.add( # Пытаемся записать ошибку в историю (best-effort)
BatchRecord( try:
batch_id=batch_id, self.history.add(
started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc), BatchRecord(
finished_at=datetime.now(timezone.utc), batch_id=batch_id,
sent_total=0, started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc),
sent_browser=0, finished_at=datetime.now(timezone.utc),
sent_location=0, sent_total=0,
sent_device=0, sent_browser=0,
sent_geo=0, sent_location=0,
status="error", sent_device=0,
error_message=str(e), sent_geo=0,
status="error",
error_message=str(e),
)
) )
) self.history.flush()
except Exception as hist_err:
logger.warning(f"Failed to write error to history: {hist_err}")
# Ждём до следующего тика # Ждём до следующего тика
elapsed = time.time() - tick_start elapsed = time.time() - tick_start
+4 -20
View File
@@ -168,23 +168,7 @@ class TestPoissonDistribution:
class TestEmptyData: class TestEmptyData:
"""Тесты обработки пустых данных.""" """Тесты обработки пустых данных."""
def test_empty_jsonl_handled(self, empty_temp_dir): def test_empty_jsonl_raises_error(self, empty_temp_dir):
"""Пустые JSONL файлы обрабатываются корректно.""" """Пустые JSONL файлы вызывают ValueError при загрузке."""
dictionary = EventDictionary.load(empty_temp_dir) with pytest.raises(ValueError, match="browser_events.jsonl is empty"):
EventDictionary.load(empty_temp_dir)
assert len(dictionary.browser_events) == 0
assert len(dictionary.location_events) == 0
assert len(dictionary.device_events) == 0
assert len(dictionary.geo_events) == 0
def test_generate_batch_with_empty_dict(self, empty_temp_dir, base_config):
"""Генерация с пустым словарем (должна работать, но без событий)."""
from dataclasses import replace
dictionary = EventDictionary.load(empty_temp_dir)
config = replace(base_config, data_dir=empty_temp_dir)
generator = EventGenerator(dictionary, config)
# С пустым словарем генерация упадет при choice()
# Это ожидаемое поведение — проверяем что падает с IndexError
with pytest.raises(IndexError):
generator.generate_batch(1)