feat(generator): добавлено состояние версии 2 для рестартов
- Зачем: - генератор должен переживать рестарт без потери популяции пользователей и коротко прерванных активных визитов. - Что: - добавлен компактный state v2 для популяции, активных визитов и остатка бюджета рождений. - сервис генератора переведён на единый тиковый поток с сохранением и восстановлением состояния. - добавлена безопасная деградация для старого state v1 и битого state v2. - покрыты короткий и долгий простой, reset состояния и валидация вложенного state. - Проверка: - uv run --with-requirements generator/requirements.txt pytest generator/tests -q. - git diff --check.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
Тесты генерации событий.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from dataclasses import replace
|
||||
@@ -268,6 +269,147 @@ class TestEventGeneration:
|
||||
assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id}
|
||||
assert later_tick["browser_events"] == []
|
||||
|
||||
def test_tick_stream_state_roundtrip_keeps_population_and_active_visits(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""Снимок тикового слоя восстанавливает популяцию и активные визиты."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
|
||||
stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||
state = stream.to_state(
|
||||
tick=1,
|
||||
rng_state=generator.rng.getstate(),
|
||||
last_batch_id="batch-1",
|
||||
last_timestamp=tick_at,
|
||||
)
|
||||
restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict())))
|
||||
|
||||
restored_generator = EventGenerator(event_dictionary, base_config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
restored_stream.restore_state(restored_state, restarted_at=tick_at)
|
||||
|
||||
assert restored_stream.population_user_ids == stream.population_user_ids
|
||||
assert restored_stream.active_visit_count == stream.active_visit_count
|
||||
|
||||
def test_tick_stream_state_stays_compact_at_active_session_limit(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""State v2 не хранит полные события активных визитов."""
|
||||
config = replace(
|
||||
base_config,
|
||||
max_session_events=30,
|
||||
max_active_sessions=200,
|
||||
population_max=300,
|
||||
)
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
|
||||
stream.generate_tick(
|
||||
event_budget=int(EXPECTED_VISIT_EVENTS * config.max_active_sessions),
|
||||
tick_started_at=tick_at,
|
||||
)
|
||||
state = stream.to_state(
|
||||
tick=1,
|
||||
rng_state=generator.rng.getstate(),
|
||||
last_batch_id="batch-1",
|
||||
last_timestamp=tick_at,
|
||||
)
|
||||
state_bytes = len(json.dumps(state.to_dict()))
|
||||
|
||||
assert stream.active_visit_count == config.max_active_sessions
|
||||
assert state_bytes < 250_000
|
||||
|
||||
def test_tick_stream_restored_after_short_idle_releases_due_original_timestamps(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""После короткого простоя активный визит продолжается со старыми метками."""
|
||||
config = replace(base_config, max_session_events=5)
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
|
||||
stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||
state = stream.to_state(
|
||||
tick=1,
|
||||
rng_state=generator.rng.getstate(),
|
||||
last_batch_id="batch-1",
|
||||
last_timestamp=tick_at,
|
||||
)
|
||||
visit_state = state.active_visits[0]
|
||||
started_at = datetime.fromisoformat(visit_state["started_at"])
|
||||
planned_timestamps = {
|
||||
(started_at + timedelta(microseconds=offset_us)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S.%f"
|
||||
)
|
||||
for offset_us in visit_state["offsets_us"]
|
||||
}
|
||||
|
||||
restored_generator = EventGenerator(event_dictionary, config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
restored_stream.restore_state(
|
||||
type(state).from_dict(json.loads(json.dumps(state.to_dict()))),
|
||||
restarted_at=tick_at + timedelta(minutes=29),
|
||||
)
|
||||
|
||||
resumed = restored_stream.generate_tick(
|
||||
event_budget=0,
|
||||
tick_started_at=tick_at + timedelta(minutes=29),
|
||||
)
|
||||
resumed_timestamps = [
|
||||
event["event_timestamp"]
|
||||
for event in resumed["browser_events"]
|
||||
]
|
||||
|
||||
assert resumed_timestamps
|
||||
assert set(resumed_timestamps).issubset(planned_timestamps)
|
||||
assert all(
|
||||
datetime.fromisoformat(timestamp.replace(" ", "T")) <= tick_at + timedelta(minutes=29)
|
||||
for timestamp in resumed_timestamps
|
||||
)
|
||||
|
||||
def test_tick_stream_restored_after_long_idle_closes_overdue_visit_without_replay(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""После долгого простоя просроченный визит закрывается без досылки."""
|
||||
config = replace(base_config, max_session_events=5)
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
|
||||
stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||
population_ids = stream.population_user_ids
|
||||
state = stream.to_state(
|
||||
tick=1,
|
||||
rng_state=generator.rng.getstate(),
|
||||
last_batch_id="batch-1",
|
||||
last_timestamp=tick_at,
|
||||
)
|
||||
|
||||
restored_generator = EventGenerator(event_dictionary, config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
restored_stream.restore_state(
|
||||
type(state).from_dict(json.loads(json.dumps(state.to_dict()))),
|
||||
restarted_at=tick_at + timedelta(hours=1),
|
||||
)
|
||||
|
||||
resumed = restored_stream.generate_tick(
|
||||
event_budget=0,
|
||||
tick_started_at=tick_at + timedelta(hours=1),
|
||||
)
|
||||
restored_user = next(
|
||||
user for user in restored_stream.population.users
|
||||
if user.user_domain_id == state.active_visits[0]["user_domain_id"]
|
||||
)
|
||||
last_sent_at = datetime.fromisoformat(state.active_visits[0]["started_at"])
|
||||
|
||||
assert resumed["browser_events"] == []
|
||||
assert restored_stream.active_visit_count == 0
|
||||
assert restored_stream.population_user_ids == population_ids
|
||||
assert restored_user.last_finished_at == last_sent_at
|
||||
|
||||
def test_tick_stream_drops_births_when_active_limit_is_reached(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
Тесты для GeneratorService и интеграционных сценариев.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from generator import (
|
||||
Config, EventDictionary, GeneratorService, KafkaBatchHistory
|
||||
Config,
|
||||
EventDictionary,
|
||||
EventGenerator,
|
||||
GeneratorService,
|
||||
GeneratorState,
|
||||
KafkaBatchHistory,
|
||||
TickStreamGenerator,
|
||||
)
|
||||
|
||||
|
||||
@@ -90,3 +100,111 @@ class TestGeneratorServiceDisabled:
|
||||
service.start()
|
||||
|
||||
assert "disabled" in caplog.text.lower() or "GEN_ENABLED" in caplog.text
|
||||
|
||||
|
||||
class TestGeneratorServiceStateV2:
|
||||
"""Тесты подключения state v2 к сервисному запуску."""
|
||||
|
||||
def test_start_restores_tick_stream_state_v2(self, base_config, event_dictionary):
|
||||
"""Сервис восстанавливает популяцию и активные визиты из state v2."""
|
||||
source_generator = EventGenerator(event_dictionary, base_config)
|
||||
source_stream = TickStreamGenerator(source_generator)
|
||||
tick_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
source_stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||
state = source_stream.to_state(
|
||||
tick=3,
|
||||
rng_state=source_generator.rng.getstate(),
|
||||
last_batch_id="batch-3",
|
||||
last_timestamp=tick_at,
|
||||
)
|
||||
|
||||
state_manager = MagicMock()
|
||||
state_manager.load.return_value = state
|
||||
|
||||
with patch("clickstream_generator.service.start_http_server"), \
|
||||
patch("clickstream_generator.service.ensure_topics"), \
|
||||
patch("clickstream_generator.service.KafkaPublisher"), \
|
||||
patch("clickstream_generator.service.KafkaBatchHistory"), \
|
||||
patch(
|
||||
"clickstream_generator.service.KafkaStateManager",
|
||||
return_value=state_manager,
|
||||
), \
|
||||
patch.object(GeneratorService, "_main_loop", return_value=None):
|
||||
|
||||
service = GeneratorService(base_config)
|
||||
service.start()
|
||||
|
||||
assert service._tick == 3
|
||||
assert service.stream.population_user_ids == source_stream.population_user_ids
|
||||
assert service.stream.active_visit_count == source_stream.active_visit_count
|
||||
|
||||
def test_save_state_writes_tick_stream_state_v2(self, base_config):
|
||||
"""Сервис сохраняет v2-снимок тикового слоя."""
|
||||
service = GeneratorService(base_config)
|
||||
service.state_manager = MagicMock()
|
||||
tick_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
service.stream.generate_tick(event_budget=10, tick_started_at=tick_at)
|
||||
service._tick = 1
|
||||
|
||||
service._save_state("batch-1")
|
||||
|
||||
saved_state = service.state_manager.save.call_args.args[0]
|
||||
assert saved_state.version == "2.0"
|
||||
assert saved_state.population
|
||||
assert saved_state.active_visits
|
||||
service.state_manager.flush.assert_called_once()
|
||||
|
||||
def test_state_reset_skips_loading_saved_state(self, base_config):
|
||||
"""GEN_STATE_RESET=true запускает сервис с чистого состояния."""
|
||||
reset_config = replace(base_config, state_reset=True)
|
||||
state_manager = MagicMock()
|
||||
|
||||
with patch("clickstream_generator.service.start_http_server"), \
|
||||
patch("clickstream_generator.service.ensure_topics"), \
|
||||
patch("clickstream_generator.service.KafkaPublisher"), \
|
||||
patch("clickstream_generator.service.KafkaBatchHistory"), \
|
||||
patch(
|
||||
"clickstream_generator.service.KafkaStateManager",
|
||||
return_value=state_manager,
|
||||
), \
|
||||
patch.object(GeneratorService, "_main_loop", return_value=None):
|
||||
|
||||
service = GeneratorService(reset_config)
|
||||
service.start()
|
||||
|
||||
state_manager.load.assert_not_called()
|
||||
assert service._tick == 0
|
||||
|
||||
def test_invalid_restored_v2_state_starts_fresh(self, base_config, caplog):
|
||||
"""Сервис не падает, если v2 state ссылается на неизвестный профиль."""
|
||||
state_manager = MagicMock()
|
||||
state_manager.load.return_value = GeneratorState(
|
||||
tick=9,
|
||||
rng_state=random.Random(42).getstate(),
|
||||
last_batch_id="bad-v2",
|
||||
last_timestamp=datetime.now(timezone.utc),
|
||||
population=[
|
||||
{
|
||||
"user_domain_id": "user-unknown",
|
||||
"seed_click_id": "missing-click-id",
|
||||
}
|
||||
],
|
||||
active_visits=[],
|
||||
)
|
||||
|
||||
with patch("clickstream_generator.service.start_http_server"), \
|
||||
patch("clickstream_generator.service.ensure_topics"), \
|
||||
patch("clickstream_generator.service.KafkaPublisher"), \
|
||||
patch("clickstream_generator.service.KafkaBatchHistory"), \
|
||||
patch(
|
||||
"clickstream_generator.service.KafkaStateManager",
|
||||
return_value=state_manager,
|
||||
), \
|
||||
patch.object(GeneratorService, "_main_loop", return_value=None), \
|
||||
caplog.at_level(logging.WARNING, logger="generator"):
|
||||
|
||||
service = GeneratorService(base_config)
|
||||
service.start()
|
||||
|
||||
assert service._tick == 0
|
||||
assert "State data was invalid" in caplog.text
|
||||
|
||||
+316
-14
@@ -2,6 +2,7 @@
|
||||
Тесты сохранения и восстановления состояния генератора.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -17,6 +18,47 @@ def _make_valid_rng_state(seed: int = 42):
|
||||
return rng.getstate()
|
||||
|
||||
|
||||
def _make_valid_v2_state_data() -> dict:
|
||||
"""Создаёт минимальный валидный state v2 для тестов загрузки."""
|
||||
return {
|
||||
"tick": 42,
|
||||
"rng_state": list(_make_valid_rng_state(42)),
|
||||
"last_batch_id": "v2",
|
||||
"last_timestamp": "2026-06-11T12:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": [
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"seed_click_id": "seed-1",
|
||||
"active_click_id": "visit-1",
|
||||
"last_finished_at": None,
|
||||
}
|
||||
],
|
||||
"active_visits": [
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"click_id": "visit-1",
|
||||
"next_index": 1,
|
||||
"started_at": "2026-06-11T12:00:00",
|
||||
"offsets_us": [0, 60_000_000],
|
||||
"page_url_paths": ["/home", "/cart"],
|
||||
}
|
||||
],
|
||||
"pending_visit_births": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def _minimal_population() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"seed_click_id": "seed-1",
|
||||
"active_click_id": None,
|
||||
"last_finished_at": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestGeneratorState:
|
||||
"""Тесты структуры состояния генератора."""
|
||||
|
||||
@@ -30,17 +72,17 @@ class TestGeneratorState:
|
||||
rng_state=rng_state,
|
||||
last_batch_id="abc123",
|
||||
last_timestamp=now,
|
||||
version="1.0",
|
||||
version="2.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"
|
||||
assert state.version == "2.0"
|
||||
|
||||
def test_default_version(self):
|
||||
"""Версия по умолчанию."""
|
||||
"""Новые состояния по умолчанию пишутся в версии 2."""
|
||||
now = datetime.now(timezone.utc)
|
||||
rng_state = _make_valid_rng_state(42)
|
||||
|
||||
@@ -51,7 +93,7 @@ class TestGeneratorState:
|
||||
last_timestamp=now,
|
||||
)
|
||||
|
||||
assert state.version == "1.0"
|
||||
assert state.version == "2.0"
|
||||
|
||||
def test_to_dict_serialization(self):
|
||||
"""Сериализация в словарь (JSON-safe, без pickle)."""
|
||||
@@ -63,6 +105,7 @@ class TestGeneratorState:
|
||||
rng_state=rng_state,
|
||||
last_batch_id="abc123",
|
||||
last_timestamp=now,
|
||||
population=_minimal_population(),
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
@@ -70,7 +113,7 @@ class TestGeneratorState:
|
||||
assert data["tick"] == 42
|
||||
assert data["last_batch_id"] == "abc123"
|
||||
assert data["last_timestamp"] == now.isoformat()
|
||||
assert data["version"] == "1.0"
|
||||
assert data["version"] == "2.0"
|
||||
|
||||
# Проверяем что rng_state сериализован как tuple (JSON-safe, без pickle)
|
||||
assert "rng_state" in data
|
||||
@@ -93,6 +136,7 @@ class TestGeneratorState:
|
||||
rng_state=rng_state,
|
||||
last_batch_id="abc123",
|
||||
last_timestamp=now,
|
||||
population=_minimal_population(),
|
||||
)
|
||||
|
||||
# Сериализуем и десериализуем
|
||||
@@ -117,6 +161,7 @@ class TestGeneratorState:
|
||||
rng_state=rng.getstate(),
|
||||
last_batch_id="test",
|
||||
last_timestamp=datetime.now(timezone.utc),
|
||||
population=_minimal_population(),
|
||||
)
|
||||
|
||||
# Десериализуем
|
||||
@@ -137,19 +182,58 @@ class TestGeneratorState:
|
||||
|
||||
assert next_values == values_after
|
||||
|
||||
def test_version_2_roundtrip_keeps_population_and_active_visits(self):
|
||||
"""State v2 хранит популяцию и активные визиты в JSON."""
|
||||
rng_state = _make_valid_rng_state(42)
|
||||
state = GeneratorState(
|
||||
tick=7,
|
||||
rng_state=rng_state,
|
||||
last_batch_id="batch-7",
|
||||
last_timestamp=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc),
|
||||
version="2.0",
|
||||
population=[
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"seed_click_id": "seed-1",
|
||||
"active_click_id": "visit-1",
|
||||
"last_finished_at": "2026-06-11T11:30:00",
|
||||
}
|
||||
],
|
||||
active_visits=[
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"click_id": "visit-1",
|
||||
"next_index": 1,
|
||||
"started_at": "2026-06-11T12:00:00",
|
||||
"offsets_us": [0, 60_000_000],
|
||||
"page_url_paths": ["/home", "/cart"],
|
||||
}
|
||||
],
|
||||
pending_visit_births=0.5,
|
||||
)
|
||||
|
||||
restored = GeneratorState.from_dict(json.loads(json.dumps(state.to_dict())))
|
||||
|
||||
assert restored.version == "2.0"
|
||||
assert restored.tick == state.tick
|
||||
assert restored.rng_state == rng_state
|
||||
assert restored.population == state.population
|
||||
assert restored.active_visits == state.active_visits
|
||||
assert restored.pending_visit_births == 0.5
|
||||
|
||||
|
||||
class TestGeneratorStateValidation:
|
||||
"""Тесты валидации состояния и graceful degradation."""
|
||||
|
||||
def test_from_dict_missing_rng_state_raises(self):
|
||||
"""from_dict выбрасывает исключение при отсутствии rng_state."""
|
||||
def test_from_dict_missing_version_raises(self):
|
||||
"""from_dict выбрасывает исключение при отсутствии версии v2."""
|
||||
data = {
|
||||
"tick": 42,
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="rng_state"):
|
||||
with pytest.raises(ValueError, match="version"):
|
||||
GeneratorState.from_dict(data)
|
||||
|
||||
def test_from_dict_invalid_rng_state_raises(self):
|
||||
@@ -159,6 +243,9 @@ class TestGeneratorStateValidation:
|
||||
"rng_state": "not_a_tuple",
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": _minimal_population(),
|
||||
"active_visits": [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
@@ -171,6 +258,9 @@ class TestGeneratorStateValidation:
|
||||
"rng_state": [1], # Слишком короткий
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": _minimal_population(),
|
||||
"active_visits": [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
@@ -183,6 +273,9 @@ class TestGeneratorStateValidation:
|
||||
"rng_state": [999, [1, 2, 3], None], # Невалидный state
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": [],
|
||||
"active_visits": [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
@@ -195,6 +288,9 @@ class TestGeneratorStateValidation:
|
||||
"rng_state": "invalid",
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": [],
|
||||
"active_visits": [],
|
||||
}
|
||||
|
||||
result = GeneratorState.from_dict_safe(data)
|
||||
@@ -208,23 +304,24 @@ class TestGeneratorStateValidation:
|
||||
"rng_state": list(rng.getstate()), # JSON сериализует tuple как list
|
||||
"last_batch_id": "test",
|
||||
"last_timestamp": "2024-01-01T00:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": _minimal_population(),
|
||||
"active_visits": [],
|
||||
}
|
||||
|
||||
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 для отсутствующих полей."""
|
||||
def test_from_dict_rejects_old_state_without_version(self):
|
||||
"""from_dict не восстанавливает старое state v1 без версии."""
|
||||
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"
|
||||
with pytest.raises(ValueError, match="version"):
|
||||
GeneratorState.from_dict(data)
|
||||
|
||||
|
||||
class TestKafkaStateManager:
|
||||
@@ -302,6 +399,8 @@ class TestKafkaStateManager:
|
||||
rng_state=_make_valid_rng_state(100),
|
||||
last_batch_id="xyz789",
|
||||
last_timestamp=now,
|
||||
version="2.0",
|
||||
population=_minimal_population(),
|
||||
)
|
||||
|
||||
# Мокаем consumer с сообщением
|
||||
@@ -343,6 +442,208 @@ class TestKafkaStateManager:
|
||||
# Должно вернуть None из-за невалидного state
|
||||
assert result is None
|
||||
|
||||
def test_load_invalid_v2_nested_state_returns_none(self, caplog):
|
||||
"""Битое state v2 с валидным rng_state даёт чистый старт."""
|
||||
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)
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = {
|
||||
"tick": 42,
|
||||
"rng_state": list(_make_valid_rng_state(42)),
|
||||
"last_batch_id": "bad-v2",
|
||||
"last_timestamp": "2026-06-11T12:00:00+00:00",
|
||||
"version": "2.0",
|
||||
"population": [{"user_domain_id": "user-1"}],
|
||||
"active_visits": [
|
||||
{
|
||||
"user_domain_id": "user-1",
|
||||
"click_id": "visit-1",
|
||||
"next_index": 1,
|
||||
"started_at": "2026-06-11T12:00:00",
|
||||
"offsets_us": [0],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_empty_population_v2_returns_none(self, caplog):
|
||||
"""Пустая популяция в state v2 не восстанавливается."""
|
||||
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)
|
||||
|
||||
bad_state = _make_valid_v2_state_data()
|
||||
bad_state["population"] = []
|
||||
bad_state["active_visits"] = []
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = bad_state
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_bad_pending_births_v2_returns_none(self, caplog):
|
||||
"""Нечисловой pending_visit_births в state v2 не восстанавливается."""
|
||||
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)
|
||||
|
||||
bad_state = _make_valid_v2_state_data()
|
||||
bad_state["pending_visit_births"] = "bad"
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = bad_state
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_active_visit_with_unknown_user_v2_returns_none(self, caplog):
|
||||
"""Активный визит должен ссылаться на пользователя из популяции."""
|
||||
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)
|
||||
|
||||
bad_state = _make_valid_v2_state_data()
|
||||
bad_state["active_visits"][0]["user_domain_id"] = "missing-user"
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = bad_state
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_active_visit_with_conflicting_click_id_v2_returns_none(self, caplog):
|
||||
"""active_click_id пользователя не должен противоречить визиту."""
|
||||
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)
|
||||
|
||||
bad_state = _make_valid_v2_state_data()
|
||||
bad_state["population"][0]["active_click_id"] = "other-visit"
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = bad_state
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_population_ghost_active_click_id_v2_returns_none(self, caplog):
|
||||
"""active_click_id пользователя должен иметь соответствующий активный визит."""
|
||||
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)
|
||||
|
||||
bad_state = _make_valid_v2_state_data()
|
||||
bad_state["population"][0]["active_click_id"] = "ghost"
|
||||
bad_state["active_visits"] = []
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = bad_state
|
||||
|
||||
mock_consumer = MagicMock()
|
||||
mock_consumer.__iter__ = MagicMock(return_value=iter([mock_message]))
|
||||
mock_consumer_class.return_value = mock_consumer
|
||||
|
||||
manager = KafkaStateManager("kafka:29092")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "Invalid state" in caplog.text
|
||||
|
||||
def test_load_version_1_state_returns_none_with_warning(self, caplog):
|
||||
"""Старое state v1 не восстанавливается и даёт чистый старт."""
|
||||
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)
|
||||
|
||||
old_state = GeneratorState(
|
||||
tick=100,
|
||||
rng_state=_make_valid_rng_state(100),
|
||||
last_batch_id="old",
|
||||
last_timestamp=datetime.now(timezone.utc),
|
||||
version="1.0",
|
||||
)
|
||||
|
||||
mock_message = MagicMock()
|
||||
mock_message.key = b"default"
|
||||
mock_message.value = old_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")
|
||||
with caplog.at_level(logging.WARNING, logger="generator"):
|
||||
result = manager.load()
|
||||
|
||||
assert result is None
|
||||
assert "version" in caplog.text
|
||||
|
||||
def test_load_ignores_wrong_key(self):
|
||||
"""Загрузка игнорирует сообщения с другим ключом."""
|
||||
with patch("generator._import_kafka") as mock_import, \
|
||||
@@ -435,6 +736,7 @@ class TestJsonSafeState:
|
||||
rng_state=rng.getstate(),
|
||||
last_batch_id="test123",
|
||||
last_timestamp=datetime.now(timezone.utc),
|
||||
population=_minimal_population(),
|
||||
)
|
||||
|
||||
# Сериализуем через JSON (как в Kafka)
|
||||
|
||||
Reference in New Issue
Block a user