Files
clickstream-ch-kafka-supers…/generator/tests/test_state.py
T
ddadminandDmitry Dementiev a70999d77a fix(generator): устранены риски стабильности из code review
- Зачем:

  - ревью выявило: фатальный старт при битом state, отсутствие retry в runtime

  - нужна graceful degradation и самовосстановление при сбоях Kafka

- Что:

  - GeneratorState.from_dict() теперь валидирует rng_state через setstate()

  - добавлен from_dict_safe() для graceful degradation (лог + start fresh)

  - KafkaPublisher: retry + reconnect в publish(), retry в flush()

  - KafkaBatchHistory: retry + reconnect в add(), retry в flush()

  - KafkaStateManager: retry в save(), load(), flush()

  - тесты: валидация state, graceful degradation, retry поведение

- Проверка:

  - make generator-test: 59 тестов проходят

  - docker compose restart generator - продолжает с tick=8 (was 6)

  - GEN_STATE_RESET=true - начинает с tick=1
2026-06-09 17:27:17 +03:00

496 lines
19 KiB
Python

"""
Тесты сохранения и восстановления состояния генератора.
"""
import json
import random
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from generator import GeneratorState, KafkaStateManager
def _make_valid_rng_state(seed: int = 42):
"""Создаёт валидный RNG state для тестов."""
rng = random.Random(seed)
return rng.getstate()
class TestGeneratorState:
"""Тесты структуры состояния генератора."""
def test_state_creation(self):
"""Создание состояния с всеми полями."""
now = datetime.now(timezone.utc)
rng_state = _make_valid_rng_state(42)
state = GeneratorState(
tick=42,
rng_state=rng_state,
last_batch_id="abc123",
last_timestamp=now,
version="1.0",
)
assert state.tick == 42
assert state.rng_state == rng_state
assert state.last_batch_id == "abc123"
assert state.last_timestamp == now
assert state.version == "1.0"
def test_default_version(self):
"""Версия по умолчанию."""
now = datetime.now(timezone.utc)
rng_state = _make_valid_rng_state(42)
state = GeneratorState(
tick=1,
rng_state=rng_state,
last_batch_id="test",
last_timestamp=now,
)
assert state.version == "1.0"
def test_to_dict_serialization(self):
"""Сериализация в словарь (JSON-safe, без pickle)."""
now = datetime.now(timezone.utc)
rng_state = _make_valid_rng_state(42)
state = GeneratorState(
tick=42,
rng_state=rng_state,
last_batch_id="abc123",
last_timestamp=now,
)
data = state.to_dict()
assert data["tick"] == 42
assert data["last_batch_id"] == "abc123"
assert data["last_timestamp"] == now.isoformat()
assert data["version"] == "1.0"
# Проверяем что rng_state сериализован как tuple (JSON-safe, без pickle)
assert "rng_state" in data
# После to_dict rng_state должен быть tuple
assert isinstance(data["rng_state"], tuple)
# Проверяем что можно сериализовать в JSON и восстановить
json_str = json.dumps(data)
restored_data = json.loads(json_str)
restored_state = GeneratorState.from_dict(restored_data)
assert restored_state.rng_state == rng_state
def test_from_dict_deserialization(self):
"""Десериализация из словаря."""
now = datetime.now(timezone.utc)
rng_state = _make_valid_rng_state(42)
# Создаём исходное состояние
original = GeneratorState(
tick=42,
rng_state=rng_state,
last_batch_id="abc123",
last_timestamp=now,
)
# Сериализуем и десериализуем
data = original.to_dict()
restored = GeneratorState.from_dict(data)
assert restored.tick == original.tick
assert restored.rng_state == original.rng_state
assert restored.last_batch_id == original.last_batch_id
assert restored.last_timestamp == original.last_timestamp
assert restored.version == original.version
def test_roundtrip_with_real_random(self):
"""Проверка что RNG state действительно восстанавливает последовательность."""
# Создаём генератор и делаем несколько вызовов
rng = random.Random(12345)
values_before = [rng.random() for _ in range(5)]
# Сохраняем состояние
state = GeneratorState(
tick=10,
rng_state=rng.getstate(),
last_batch_id="test",
last_timestamp=datetime.now(timezone.utc),
)
# Десериализуем
data = state.to_dict()
restored_state = GeneratorState.from_dict(data)
# Создаём новый генератор с восстановленным состоянием
new_rng = random.Random()
new_rng.setstate(restored_state.rng_state)
# Проверяем что следующие значения совпадают
values_after = [new_rng.random() for _ in range(5)]
# Если state восстановлен корректно, values должны совпадать
# Но т.к. rng уже "прокручен" на 5 значений, берём следующие
rng.setstate(state.rng_state)
next_values = [rng.random() for _ in range(5)]
assert next_values == values_after
class TestGeneratorStateValidation:
"""Тесты валидации состояния и graceful degradation."""
def test_from_dict_missing_rng_state_raises(self):
"""from_dict выбрасывает исключение при отсутствии rng_state."""
data = {
"tick": 42,
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError, match="rng_state"):
GeneratorState.from_dict(data)
def test_from_dict_invalid_rng_state_raises(self):
"""from_dict выбрасывает исключение при невалидном rng_state."""
data = {
"tick": 42,
"rng_state": "not_a_tuple",
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_insufficient_rng_state_raises(self):
"""from_dict выбрасывает исключение при коротком rng_state."""
data = {
"tick": 42,
"rng_state": [1], # Слишком короткий
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_invalid_setstate_raises(self):
"""from_dict выбрасывает исключение если setstate падает."""
data = {
"tick": 42,
"rng_state": [999, [1, 2, 3], None], # Невалидный state
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_safe_returns_none_on_invalid(self):
"""from_dict_safe возвращает None при невалидных данных."""
data = {
"tick": 42,
"rng_state": "invalid",
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
result = GeneratorState.from_dict_safe(data)
assert result is None
def test_from_dict_safe_returns_state_on_valid(self):
"""from_dict_safe возвращает state при валидных данных."""
rng = random.Random(42)
data = {
"tick": 42,
"rng_state": list(rng.getstate()), # JSON сериализует tuple как list
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
}
result = GeneratorState.from_dict_safe(data)
assert result is not None
assert result.tick == 42
def test_from_dict_uses_defaults_for_missing_fields(self):
"""from_dict использует defaults для отсутствующих полей."""
rng = random.Random(42)
data = {
"rng_state": list(rng.getstate()),
}
result = GeneratorState.from_dict(data)
assert result.tick == 0
assert result.last_batch_id == ""
assert result.version == "1.0"
class TestKafkaStateManager:
"""Тесты менеджера состояния."""
def test_init(self):
"""Инициализация менеджера."""
with patch("generator._import_kafka") as mock_import:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
manager = KafkaStateManager("kafka:29092")
assert manager.bootstrap_servers == "kafka:29092"
assert manager.STATE_TOPIC == "generator_state"
assert manager.STATE_KEY == "default"
mock_producer_class.assert_called_once()
def test_save(self):
"""Сохранение состояния."""
with patch("generator._import_kafka") as mock_import:
mock_producer = MagicMock()
mock_producer_class = MagicMock(return_value=mock_producer)
mock_import.return_value = (mock_producer_class, None)
manager = KafkaStateManager("kafka:29092")
now = datetime.now(timezone.utc)
state = GeneratorState(
tick=42,
rng_state=_make_valid_rng_state(42),
last_batch_id="abc123",
last_timestamp=now,
)
manager.save(state)
# Проверяем что producer.send был вызван
mock_producer.send.assert_called_once()
call_args = mock_producer.send.call_args
assert call_args[0][0] == "generator_state" # topic
assert call_args[1]["key"] == "default" # key
assert call_args[1]["value"]["tick"] == 42
assert call_args[1]["value"]["last_batch_id"] == "abc123"
def test_load_no_messages(self):
"""Загрузка при отсутствии состояния."""
with patch("generator._import_kafka") as mock_import, \
patch("kafka.KafkaConsumer") as mock_consumer_class:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
# Мокаем пустой consumer (нет сообщений)
mock_consumer = MagicMock()
mock_consumer.__iter__ = MagicMock(return_value=iter([]))
mock_consumer_class.return_value = mock_consumer
manager = KafkaStateManager("kafka:29092")
result = manager.load()
assert result is None
def test_load_with_messages(self):
"""Загрузка существующего состояния."""
with patch("generator._import_kafka") as mock_import, \
patch("kafka.KafkaConsumer") as mock_consumer_class:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
now = datetime.now(timezone.utc)
state = GeneratorState(
tick=100,
rng_state=_make_valid_rng_state(100),
last_batch_id="xyz789",
last_timestamp=now,
)
# Мокаем consumer с сообщением
mock_message = MagicMock()
mock_message.key = b"default"
mock_message.value = state.to_dict()
mock_consumer = MagicMock()
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
mock_consumer_class.return_value = mock_consumer
manager = KafkaStateManager("kafka:29092")
result = manager.load()
assert result is not None
assert result.tick == 100
assert result.last_batch_id == "xyz789"
def test_load_invalid_state_returns_none(self):
"""Загрузка невалидного state возвращает None (graceful degradation)."""
with patch("generator._import_kafka") as mock_import, \
patch("kafka.KafkaConsumer") as mock_consumer_class:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
# Мокаем consumer с невалидным сообщением
mock_message = MagicMock()
mock_message.key = b"default"
mock_message.value = {"tick": 42, "rng_state": "invalid"}
mock_consumer = MagicMock()
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
mock_consumer_class.return_value = mock_consumer
manager = KafkaStateManager("kafka:29092")
result = manager.load()
# Должно вернуть None из-за невалидного state
assert result is None
def test_load_ignores_wrong_key(self):
"""Загрузка игнорирует сообщения с другим ключом."""
with patch("generator._import_kafka") as mock_import, \
patch("kafka.KafkaConsumer") as mock_consumer_class:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
# Мокаем consumer с сообщением с неправильным ключом
mock_message = MagicMock()
mock_message.key = b"other_generator" # Другой ключ
mock_message.value = {"tick": 999}
mock_consumer = MagicMock()
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
mock_consumer_class.return_value = mock_consumer
manager = KafkaStateManager("kafka:29092")
result = manager.load()
# Не должно быть загружено, т.к. ключ не совпадает
assert result is None
def test_load_exception_returns_none(self):
"""При ошибке загрузки возвращается None."""
with patch("generator._import_kafka") as mock_import, \
patch("kafka.KafkaConsumer") as mock_consumer_class:
mock_producer_class = MagicMock()
mock_import.return_value = (mock_producer_class, None)
# Симулируем ошибку при создании consumer
mock_consumer_class.side_effect = Exception("Kafka unavailable")
manager = KafkaStateManager("kafka:29092")
result = manager.load()
assert result is None
def test_flush(self):
"""Сброс буфера."""
with patch("generator._import_kafka") as mock_import:
mock_producer = MagicMock()
mock_producer_class = MagicMock(return_value=mock_producer)
mock_import.return_value = (mock_producer_class, None)
manager = KafkaStateManager("kafka:29092")
manager.flush()
mock_producer.flush.assert_called_once()
def test_close(self):
"""Закрытие соединения."""
with patch("generator._import_kafka") as mock_import:
mock_producer = MagicMock()
mock_producer_class = MagicMock(return_value=mock_producer)
mock_import.return_value = (mock_producer_class, None)
manager = KafkaStateManager("kafka:29092")
manager.close()
mock_producer.close.assert_called_once()
class TestJsonSafeState:
"""Тесты JSON-safe сериализации state (без pickle)."""
def test_json_roundtrip_with_nested_tuples(self):
"""Проверка что nested tuple корректно восстанавливается после JSON."""
from generator import _nested_list_to_tuple
# Симулируем что получаем после json.loads() - все tuple становятся list
json_loaded = [3, [1, 2, 3], None]
result = _nested_list_to_tuple(json_loaded)
assert result == (3, (1, 2, 3), None)
assert isinstance(result, tuple)
assert isinstance(result[1], tuple)
def test_json_roundtrip_rng_state(self):
"""Полный цикл: rng.getstate() -> JSON -> from_dict -> setstate."""
rng = random.Random(42)
# Делаем несколько вызовов
values_before = [rng.random() for _ in range(10)]
# Сохраняем state
state = GeneratorState(
tick=100,
rng_state=rng.getstate(),
last_batch_id="test123",
last_timestamp=datetime.now(timezone.utc),
)
# Сериализуем через JSON (как в Kafka)
data = state.to_dict()
json_str = json.dumps(data)
restored_data = json.loads(json_str)
# Восстанавливаем
restored_state = GeneratorState.from_dict(restored_data)
# Проверяем что RNG state восстановлен корректно
rng2 = random.Random()
rng2.setstate(restored_state.rng_state)
# Проверяем что следующие значения совпадают
values_after = [rng2.random() for _ in range(5)]
# Оригинальный RNG должен дать те же значения
values_expected = [rng.random() for _ in range(5)]
assert values_after == values_expected
class TestWithRetry:
"""Тесты функции _with_retry."""
def test_success_on_first_attempt(self):
"""Успех с первой попытки."""
from generator import _with_retry
operation = MagicMock(return_value="success")
result = _with_retry(operation, max_retries=3, base_delay=0.01)
assert result == "success"
assert operation.call_count == 1
def test_success_after_retries(self):
"""Успех после нескольких попыток."""
from generator import _with_retry
operation = MagicMock(side_effect=[Exception("fail1"), Exception("fail2"), "success"])
result = _with_retry(operation, max_retries=3, base_delay=0.01)
assert result == "success"
assert operation.call_count == 3
def test_failure_after_all_retries(self):
"""Исчерпание всех попыток."""
from generator import _with_retry
operation = MagicMock(side_effect=Exception("always fails"))
with pytest.raises(Exception, match="always fails"):
_with_retry(operation, max_retries=3, base_delay=0.01)
assert operation.call_count == 3