feat(generator): добавлено восстановление state по модельному времени

- Зачем:
  - рестарт генератора должен продолжать поток от модельной точки без дублей и смешивания state разных настроек.
- Что:
  - state v2 хранит модельную и настенную метки, скорость, timezone, T0 и seed.
  - live-восстановление считает модельную точку по настенной дельте и проверяет совместимость config.
  - добавлен путь восстановления от T_end и тесты короткого и долгого простоя.
- Проверка:
  - make generator-test.
  - ClickHouse-сценарии короткого и долгого восстановления state.
  - reviewer gate issue 04 пройден после исправления совместимости state.
This commit is contained in:
2026-06-14 18:47:34 +03:00
parent 589e2321b3
commit fcb06da16e
7 changed files with 540 additions and 35 deletions
+15
View File
@@ -340,7 +340,9 @@ class TestEventGeneration:
last_timestamp=tick_at,
)
visit_state = state.active_visits[0]
original_visit = stream.active_visits[0]
started_at = datetime.fromisoformat(visit_state["started_at"])
next_index = visit_state["next_index"]
planned_timestamps = {
(started_at + timedelta(microseconds=offset_us)).strftime(
"%Y-%m-%d %H:%M:%S.%f"
@@ -366,6 +368,19 @@ class TestEventGeneration:
assert resumed_timestamps
assert set(resumed_timestamps).issubset(planned_timestamps)
assert {event["click_id"] for event in resumed["browser_events"]} == {
visit_state["click_id"]
}
assert [
event["page_url_path"]
for event in resumed["location_events"]
] == visit_state["page_url_paths"][next_index:next_index + len(resumed_timestamps)]
assert resumed["device_events"] == original_visit.batch["device_events"][
next_index:next_index + len(resumed_timestamps)
]
assert resumed["geo_events"] == original_visit.batch["geo_events"][
next_index:next_index + len(resumed_timestamps)
]
assert all(
datetime.fromisoformat(timestamp.replace(" ", "T")) <= tick_at + timedelta(minutes=29)
for timestamp in resumed_timestamps
+153 -1
View File
@@ -5,7 +5,7 @@
import logging
import random
from dataclasses import replace
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from time import sleep as real_sleep
from unittest.mock import MagicMock, patch
@@ -315,6 +315,12 @@ class TestGeneratorServiceStateV2:
rng_state=source_generator.rng.getstate(),
last_batch_id="batch-3",
last_timestamp=tick_at,
model_timestamp=tick_at.replace(tzinfo=timezone.utc),
wall_timestamp=tick_at.replace(tzinfo=timezone.utc),
model_time_speed=base_config.model_time_speed,
model_timezone=base_config.model_timezone,
model_t0=base_config.model_t0,
gen_seed=base_config.seed,
)
state_manager = MagicMock()
@@ -337,6 +343,99 @@ class TestGeneratorServiceStateV2:
assert service.stream.population_user_ids == source_stream.population_user_ids
assert service.stream.active_visit_count == source_stream.active_visit_count
def test_start_restores_model_time_from_state_wall_delta(
self, base_config, event_dictionary
):
"""Live-восстановление считает точку модели из сохранённой wall-метки."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
wall_saved_at = datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
wall_restarted_at = wall_saved_at + timedelta(seconds=30)
config = replace(
base_config,
model_t0=model_t0,
model_time_speed=10,
tick_seconds=60,
)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=3,
rng_state=source_generator.rng.getstate(),
last_batch_id="batch-3",
last_timestamp=model_t0,
model_timestamp=model_t0,
wall_timestamp=wall_saved_at,
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
state_manager = MagicMock()
state_manager.load.return_value = state
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
if tz is None:
return wall_restarted_at.replace(tzinfo=None)
return wall_restarted_at.astimezone(tz)
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("clickstream_generator.service.datetime", FrozenDateTime), \
patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(config)
service.start()
assert service._tick == 3
assert service._model_time == model_t0 + timedelta(seconds=300)
assert service.stream.active_visit_count == source_stream.active_visit_count
def test_restore_from_startup_history_uses_passed_model_point_without_wall_delta(
self, base_config, event_dictionary
):
"""Стартовая история продолжает с T_end, а не с wall-простоя."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=5)
old_wall_saved_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
config = replace(
base_config,
model_t0=model_t0,
model_time_speed=3600,
tick_seconds=60,
max_session_events=5,
)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t_end)
state = source_stream.to_state(
tick=99,
rng_state=source_generator.rng.getstate(),
last_batch_id="history-end",
last_timestamp=model_t_end,
model_timestamp=model_t_end,
wall_timestamp=old_wall_saved_at,
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
service = GeneratorService(config)
service.restore_from_startup_history(state, model_t_end=model_t_end)
assert service._tick == 99
assert service._model_time == model_t_end
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)
@@ -344,15 +443,62 @@ class TestGeneratorServiceStateV2:
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._model_time = tick_at.replace(tzinfo=timezone.utc)
service._save_state("batch-1")
saved_state = service.state_manager.save.call_args.args[0]
assert saved_state.version == "2.0"
assert saved_state.model_timestamp == tick_at.replace(tzinfo=timezone.utc)
assert saved_state.wall_timestamp.tzinfo is not None
assert saved_state.model_time_speed == base_config.model_time_speed
assert saved_state.model_timezone == base_config.model_timezone
assert saved_state.model_t0 == base_config.model_t0
assert saved_state.gen_seed == base_config.seed
assert saved_state.population
assert saved_state.active_visits
service.state_manager.flush.assert_called_once()
def test_incompatible_seed_state_starts_fresh(self, base_config, event_dictionary, caplog):
"""State от другого GEN_SEED не смешивается с текущим запуском."""
source_config = replace(base_config, seed=7)
source_generator = EventGenerator(event_dictionary, source_config)
source_stream = TickStreamGenerator(source_generator)
tick_at = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
source_stream.generate_tick(event_budget=10, tick_started_at=tick_at)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="other-seed",
last_timestamp=tick_at,
model_timestamp=tick_at,
wall_timestamp=datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc),
model_time_speed=base_config.model_time_speed,
model_timezone=base_config.model_timezone,
model_t0=base_config.model_t0,
gen_seed=source_config.seed,
)
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), \
caplog.at_level(logging.WARNING, logger="generator"):
service = GeneratorService(base_config)
service.start()
assert service._tick == 0
assert service._model_time == base_config.model_t0
assert "state config mismatch: gen_seed" in caplog.text
def test_state_reset_skips_loading_saved_state(self, base_config):
"""GEN_STATE_RESET=true запускает сервис с чистого состояния."""
reset_config = replace(base_config, state_reset=True)
@@ -382,6 +528,12 @@ class TestGeneratorServiceStateV2:
rng_state=random.Random(42).getstate(),
last_batch_id="bad-v2",
last_timestamp=datetime.now(timezone.utc),
model_timestamp=base_config.model_t0,
wall_timestamp=datetime.now(timezone.utc),
model_time_speed=base_config.model_time_speed,
model_timezone=base_config.model_timezone,
model_t0=base_config.model_t0,
gen_seed=base_config.seed,
population=[
{
"user_domain_id": "user-unknown",
+108 -10
View File
@@ -25,6 +25,12 @@ def _make_valid_v2_state_data() -> dict:
"rng_state": list(_make_valid_rng_state(42)),
"last_batch_id": "v2",
"last_timestamp": "2026-06-11T12:00:00+00:00",
"model_timestamp": "2026-01-01T10:00:00+00:00",
"wall_timestamp": "2026-06-11T12:00:00+00:00",
"model_time_speed": 10,
"model_timezone": "UTC",
"model_t0": "2026-01-01T00:00:00+00:00",
"gen_seed": 42,
"version": "2.0",
"population": [
{
@@ -59,6 +65,19 @@ def _minimal_population() -> list[dict]:
]
def _with_resume_fields(data: dict) -> dict:
"""Добавляет обязательные поля state v2, не связанные с проверяемой ошибкой."""
return {
**data,
"model_timestamp": "2026-01-01T10:00:00+00:00",
"wall_timestamp": "2024-01-01T00:00:00+00:00",
"model_time_speed": 10,
"model_timezone": "UTC",
"model_t0": "2026-01-01T00:00:00+00:00",
"gen_seed": 42,
}
class TestGeneratorState:
"""Тесты структуры состояния генератора."""
@@ -72,6 +91,12 @@ class TestGeneratorState:
rng_state=rng_state,
last_batch_id="abc123",
last_timestamp=now,
model_timestamp=now,
wall_timestamp=now,
model_time_speed=10,
model_timezone="UTC",
model_t0=now,
gen_seed=42,
version="2.0",
)
@@ -79,6 +104,12 @@ class TestGeneratorState:
assert state.rng_state == rng_state
assert state.last_batch_id == "abc123"
assert state.last_timestamp == now
assert state.model_timestamp == now
assert state.wall_timestamp == now
assert state.model_time_speed == 10
assert state.model_timezone == "UTC"
assert state.model_t0 == now
assert state.gen_seed == 42
assert state.version == "2.0"
def test_default_version(self):
@@ -113,6 +144,12 @@ class TestGeneratorState:
assert data["tick"] == 42
assert data["last_batch_id"] == "abc123"
assert data["last_timestamp"] == now.isoformat()
assert data["model_timestamp"] == now.isoformat()
assert data["wall_timestamp"] == now.isoformat()
assert data["model_time_speed"] == 1.0
assert data["model_timezone"] == "UTC"
assert data["model_t0"] == now.isoformat()
assert data["gen_seed"] is None
assert data["version"] == "2.0"
# Проверяем что rng_state сериализован как tuple (JSON-safe, без pickle)
@@ -147,6 +184,12 @@ class TestGeneratorState:
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.model_timestamp == original.model_timestamp
assert restored.wall_timestamp == original.wall_timestamp
assert restored.model_time_speed == original.model_time_speed
assert restored.model_timezone == original.model_timezone
assert restored.model_t0 == original.model_t0
assert restored.gen_seed == original.gen_seed
assert restored.version == original.version
def test_roundtrip_with_real_random(self):
@@ -190,6 +233,12 @@ class TestGeneratorState:
rng_state=rng_state,
last_batch_id="batch-7",
last_timestamp=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc),
model_timestamp=datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc),
wall_timestamp=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc),
model_time_speed=10,
model_timezone="Europe/Moscow",
model_t0=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
gen_seed=42,
version="2.0",
population=[
{
@@ -220,6 +269,12 @@ class TestGeneratorState:
assert restored.population == state.population
assert restored.active_visits == state.active_visits
assert restored.pending_visit_births == 0.5
assert restored.model_timestamp == state.model_timestamp
assert restored.wall_timestamp == state.wall_timestamp
assert restored.model_time_speed == 10
assert restored.model_timezone == "Europe/Moscow"
assert restored.model_t0 == state.model_t0
assert restored.gen_seed == 42
class TestGeneratorStateValidation:
@@ -238,7 +293,7 @@ class TestGeneratorStateValidation:
def test_from_dict_invalid_rng_state_raises(self):
"""from_dict выбрасывает исключение при невалидном rng_state."""
data = {
data = _with_resume_fields({
"tick": 42,
"rng_state": "not_a_tuple",
"last_batch_id": "test",
@@ -246,14 +301,14 @@ class TestGeneratorStateValidation:
"version": "2.0",
"population": _minimal_population(),
"active_visits": [],
}
})
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_insufficient_rng_state_raises(self):
"""from_dict выбрасывает исключение при коротком rng_state."""
data = {
data = _with_resume_fields({
"tick": 42,
"rng_state": [1], # Слишком короткий
"last_batch_id": "test",
@@ -261,37 +316,37 @@ class TestGeneratorStateValidation:
"version": "2.0",
"population": _minimal_population(),
"active_visits": [],
}
})
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_invalid_setstate_raises(self):
"""from_dict выбрасывает исключение если setstate падает."""
data = {
data = _with_resume_fields({
"tick": 42,
"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": [],
"population": _minimal_population(),
"active_visits": [],
}
})
with pytest.raises(ValueError):
GeneratorState.from_dict(data)
def test_from_dict_safe_returns_none_on_invalid(self):
"""from_dict_safe возвращает None при невалидных данных."""
data = {
data = _with_resume_fields({
"tick": 42,
"rng_state": "invalid",
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
"version": "2.0",
"population": [],
"population": _minimal_population(),
"active_visits": [],
}
})
result = GeneratorState.from_dict_safe(data)
assert result is None
@@ -304,6 +359,12 @@ class TestGeneratorStateValidation:
"rng_state": list(rng.getstate()), # JSON сериализует tuple как list
"last_batch_id": "test",
"last_timestamp": "2024-01-01T00:00:00+00:00",
"model_timestamp": "2026-01-01T10:00:00+00:00",
"wall_timestamp": "2024-01-01T00:00:00+00:00",
"model_time_speed": 10,
"model_timezone": "UTC",
"model_t0": "2026-01-01T00:00:00+00:00",
"gen_seed": 42,
"version": "2.0",
"population": _minimal_population(),
"active_visits": [],
@@ -312,6 +373,43 @@ class TestGeneratorStateValidation:
result = GeneratorState.from_dict_safe(data)
assert result is not None
assert result.tick == 42
assert result.model_timestamp == datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
assert result.gen_seed == 42
def test_from_dict_safe_returns_none_on_invalid_gen_seed(self):
"""gen_seed в JSON state должен быть числом или null."""
data = _make_valid_v2_state_data()
data["gen_seed"] = "42"
result = GeneratorState.from_dict_safe(data)
assert result is None
def test_from_dict_safe_returns_none_on_bool_model_time_speed(self):
"""model_time_speed не принимает bool как числовую скорость."""
data = _make_valid_v2_state_data()
data["model_time_speed"] = True
result = GeneratorState.from_dict_safe(data)
assert result is None
def test_from_dict_safe_returns_none_without_model_resume_fields(self):
"""State v2 без связки модельного и настенного времени несовместим."""
rng = random.Random(42)
data = {
"tick": 42,
"rng_state": list(rng.getstate()),
"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 None
def test_from_dict_rejects_old_state_without_version(self):
"""from_dict не восстанавливает старое state v1 без версии."""