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:
@@ -175,6 +175,12 @@ class TickStreamGenerator:
|
||||
rng_state: tuple,
|
||||
last_batch_id: str,
|
||||
last_timestamp: datetime,
|
||||
model_timestamp: datetime | None = None,
|
||||
wall_timestamp: datetime | None = None,
|
||||
model_time_speed: float = 1.0,
|
||||
model_timezone: str = "UTC",
|
||||
model_t0: datetime | None = None,
|
||||
gen_seed: int | None = None,
|
||||
) -> GeneratorState:
|
||||
"""Возвращает JSON-сериализуемый снимок тикового слоя."""
|
||||
return GeneratorState(
|
||||
@@ -182,6 +188,12 @@ class TickStreamGenerator:
|
||||
rng_state=rng_state,
|
||||
last_batch_id=last_batch_id,
|
||||
last_timestamp=last_timestamp,
|
||||
model_timestamp=model_timestamp,
|
||||
wall_timestamp=wall_timestamp,
|
||||
model_time_speed=model_time_speed,
|
||||
model_timezone=model_timezone,
|
||||
model_t0=model_t0,
|
||||
gen_seed=gen_seed,
|
||||
population=[
|
||||
{
|
||||
"user_domain_id": user.user_domain_id,
|
||||
@@ -220,6 +232,7 @@ class TickStreamGenerator:
|
||||
def restore_state(
|
||||
self,
|
||||
state: GeneratorState,
|
||||
resume_model_at: datetime | None = None,
|
||||
restarted_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Восстанавливает популяцию и активные визиты из state v2."""
|
||||
@@ -231,17 +244,17 @@ class TickStreamGenerator:
|
||||
|
||||
self.population.users = users
|
||||
self.active_visits = []
|
||||
restarted_time = (
|
||||
_normalize_tick_time(restarted_at)
|
||||
if restarted_at is not None
|
||||
model_resume_time = (
|
||||
_normalize_tick_time(resume_model_at or restarted_at)
|
||||
if resume_model_at is not None or restarted_at is not None
|
||||
else None
|
||||
)
|
||||
for item in state.active_visits:
|
||||
visit = self._visit_from_state(item, users_by_id)
|
||||
if self._is_overdue_after_restart(visit, restarted_time):
|
||||
if self._is_overdue_after_restart(visit, model_resume_time):
|
||||
self.population.finish_visit(
|
||||
visit.user,
|
||||
self._last_released_at(visit, state.last_timestamp),
|
||||
self._last_released_at(visit, state.model_timestamp),
|
||||
)
|
||||
continue
|
||||
self.active_visits.append(visit)
|
||||
|
||||
@@ -73,14 +73,13 @@ class GeneratorService:
|
||||
restored_state = self.state_manager.load()
|
||||
if restored_state:
|
||||
try:
|
||||
self.generator.rng.setstate(restored_state.rng_state)
|
||||
self.stream.restore_state(
|
||||
self._restore_live_state(
|
||||
restored_state,
|
||||
restarted_at=datetime.now(timezone.utc),
|
||||
wall_now_utc=datetime.now(timezone.utc),
|
||||
)
|
||||
self._tick = restored_state.tick
|
||||
logger.info(
|
||||
f"Restored state: continuing from tick {self._tick}, "
|
||||
f"model_time={self._model_time.isoformat()}, "
|
||||
f"last_batch_id={restored_state.last_batch_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -115,21 +114,92 @@ class GeneratorService:
|
||||
if self.state_manager:
|
||||
self.state_manager.close()
|
||||
|
||||
def restore_from_startup_history(
|
||||
self,
|
||||
state,
|
||||
model_t_end: datetime,
|
||||
) -> None:
|
||||
"""Восстанавливает слепок стартовой истории ровно от T_end."""
|
||||
self._restore_state_snapshot(state, resume_model_at=model_t_end)
|
||||
|
||||
def _restore_live_state(self, state, wall_now_utc: datetime) -> None:
|
||||
"""Восстанавливает live-state с учётом прошедшего настенного времени."""
|
||||
self._validate_live_state_config(state)
|
||||
resume_model_at = self._calculate_live_resume_model_at(
|
||||
state,
|
||||
wall_now_utc=wall_now_utc,
|
||||
)
|
||||
self._restore_state_snapshot(state, resume_model_at=resume_model_at)
|
||||
|
||||
def _restore_state_snapshot(self, state, resume_model_at: datetime) -> None:
|
||||
"""Применяет state к генератору и тиковому слою."""
|
||||
resume_model_at = self._as_aware_utc(resume_model_at)
|
||||
self.generator.rng.setstate(state.rng_state)
|
||||
self.stream.restore_state(state, resume_model_at=resume_model_at)
|
||||
self._tick = state.tick
|
||||
self._model_time = resume_model_at
|
||||
|
||||
def _calculate_live_resume_model_at(
|
||||
self,
|
||||
state,
|
||||
wall_now_utc: datetime,
|
||||
) -> datetime:
|
||||
"""Считает модельную точку live-восстановления по state v2."""
|
||||
wall_now_utc = self._as_aware_utc(wall_now_utc)
|
||||
wall_saved_at = self._as_aware_utc(state.wall_timestamp)
|
||||
idle_seconds = max(0.0, (wall_now_utc - wall_saved_at).total_seconds())
|
||||
return self._as_aware_utc(state.model_timestamp) + timedelta(
|
||||
seconds=idle_seconds * state.model_time_speed,
|
||||
)
|
||||
|
||||
def _validate_live_state_config(self, state) -> None:
|
||||
"""Проверяет, что state относится к текущей конфигурации live-запуска."""
|
||||
mismatches = []
|
||||
if state.gen_seed != self.config.seed:
|
||||
mismatches.append("gen_seed")
|
||||
if self._as_aware_utc(state.model_t0) != self.config.model_t0:
|
||||
mismatches.append("model_t0")
|
||||
if state.model_timezone != self.config.model_timezone:
|
||||
mismatches.append("model_timezone")
|
||||
if abs(state.model_time_speed - self.config.model_time_speed) > 1e-9:
|
||||
mismatches.append("model_time_speed")
|
||||
|
||||
if mismatches:
|
||||
raise ValueError(
|
||||
"state config mismatch: " + ", ".join(mismatches)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
def _save_state(self, batch_id: str) -> None:
|
||||
"""Сохраняет текущее состояние генератора."""
|
||||
if not self.state_manager or not self.config.state_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
wall_now = datetime.now(timezone.utc)
|
||||
state = self.stream.to_state(
|
||||
tick=self._tick,
|
||||
rng_state=self.generator.rng.getstate(),
|
||||
last_batch_id=batch_id,
|
||||
last_timestamp=datetime.now(timezone.utc),
|
||||
last_timestamp=self._model_time,
|
||||
model_timestamp=self._model_time,
|
||||
wall_timestamp=wall_now,
|
||||
model_time_speed=self.config.model_time_speed,
|
||||
model_timezone=self.config.model_timezone,
|
||||
model_t0=self.config.model_t0,
|
||||
gen_seed=self.config.seed,
|
||||
)
|
||||
self.state_manager.save(state)
|
||||
self.state_manager.flush()
|
||||
logger.debug(f"Saved state: tick={self._tick}, batch_id={batch_id}")
|
||||
logger.debug(
|
||||
f"Saved state: tick={self._tick}, "
|
||||
f"model_time={self._model_time.isoformat()}, batch_id={batch_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save state: {e}")
|
||||
METRICS_ERRORS_TOTAL.labels(topic="state").inc()
|
||||
@@ -179,8 +249,8 @@ class GeneratorService:
|
||||
|
||||
if status in ("success", "partial"):
|
||||
METRICS_LAST_SUCCESS.set_to_current_time()
|
||||
self._save_state(batch_id)
|
||||
self._advance_model_time()
|
||||
self._save_state(batch_id)
|
||||
|
||||
self.publisher.flush()
|
||||
pub_duration = time.time() - pub_start
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
logger = logging.getLogger("generator")
|
||||
@@ -25,7 +26,56 @@ def _require_keys(item: dict, keys: tuple[str, ...], label: str) -> None:
|
||||
raise ValueError(f"{label} missing fields: {', '.join(missing)}")
|
||||
|
||||
|
||||
def _parse_aware_utc(value: str, label: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{label} must be a non-empty string")
|
||||
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if timestamp.tzinfo is None:
|
||||
raise ValueError(f"{label} must include timezone")
|
||||
return timestamp.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _validate_resume_fields(data: dict) -> None:
|
||||
_require_keys(
|
||||
data,
|
||||
(
|
||||
"model_timestamp",
|
||||
"wall_timestamp",
|
||||
"model_time_speed",
|
||||
"model_timezone",
|
||||
"model_t0",
|
||||
"gen_seed",
|
||||
),
|
||||
"state",
|
||||
)
|
||||
_parse_aware_utc(data["model_timestamp"], "model_timestamp")
|
||||
_parse_aware_utc(data["wall_timestamp"], "wall_timestamp")
|
||||
_parse_aware_utc(data["model_t0"], "model_t0")
|
||||
|
||||
model_time_speed = data["model_time_speed"]
|
||||
if (
|
||||
isinstance(model_time_speed, bool)
|
||||
or not isinstance(model_time_speed, int | float)
|
||||
or model_time_speed <= 0
|
||||
):
|
||||
raise ValueError("model_time_speed must be a positive number")
|
||||
|
||||
model_timezone = data["model_timezone"]
|
||||
if not isinstance(model_timezone, str) or not model_timezone:
|
||||
raise ValueError("model_timezone must be a string")
|
||||
try:
|
||||
ZoneInfo(model_timezone)
|
||||
except ZoneInfoNotFoundError as e:
|
||||
raise ValueError(f"unknown model_timezone: {model_timezone}") from e
|
||||
|
||||
gen_seed = data["gen_seed"]
|
||||
if gen_seed is not None:
|
||||
if isinstance(gen_seed, bool) or not isinstance(gen_seed, int):
|
||||
raise ValueError("gen_seed must be an integer or null")
|
||||
|
||||
|
||||
def _validate_v2_payload(data: dict) -> None:
|
||||
_validate_resume_fields(data)
|
||||
population = data.get("population")
|
||||
active_visits = data.get("active_visits")
|
||||
pending_visit_births = data.get("pending_visit_births", 0.0)
|
||||
@@ -129,10 +179,30 @@ class GeneratorState:
|
||||
last_batch_id: str
|
||||
last_timestamp: datetime
|
||||
version: str = STATE_VERSION
|
||||
model_timestamp: datetime | None = None
|
||||
wall_timestamp: datetime | None = None
|
||||
model_time_speed: float = 1.0
|
||||
model_timezone: str = "UTC"
|
||||
model_t0: datetime | None = None
|
||||
gen_seed: int | None = None
|
||||
population: list[dict] = field(default_factory=list)
|
||||
active_visits: list[dict] = field(default_factory=list)
|
||||
pending_visit_births: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.model_timestamp is None:
|
||||
self.model_timestamp = self._as_aware_utc(self.last_timestamp)
|
||||
if self.wall_timestamp is None:
|
||||
self.wall_timestamp = self._as_aware_utc(self.last_timestamp)
|
||||
if self.model_t0 is None:
|
||||
self.model_t0 = self.model_timestamp
|
||||
|
||||
@staticmethod
|
||||
def _as_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Конвертирует в словарь для JSON-сериализации."""
|
||||
return {
|
||||
@@ -140,6 +210,12 @@ class GeneratorState:
|
||||
"rng_state": self.rng_state,
|
||||
"last_batch_id": self.last_batch_id,
|
||||
"last_timestamp": self.last_timestamp.isoformat(),
|
||||
"model_timestamp": self.model_timestamp.isoformat(),
|
||||
"wall_timestamp": self.wall_timestamp.isoformat(),
|
||||
"model_time_speed": self.model_time_speed,
|
||||
"model_timezone": self.model_timezone,
|
||||
"model_t0": self.model_t0.isoformat(),
|
||||
"gen_seed": self.gen_seed,
|
||||
"version": self.version,
|
||||
"population": self.population,
|
||||
"active_visits": self.active_visits,
|
||||
@@ -160,6 +236,12 @@ class GeneratorState:
|
||||
)
|
||||
raise ValueError(f"unsupported state version: {version}")
|
||||
_validate_v2_payload(data)
|
||||
model_timestamp = _parse_aware_utc(
|
||||
data["model_timestamp"],
|
||||
"model_timestamp",
|
||||
)
|
||||
wall_timestamp = _parse_aware_utc(data["wall_timestamp"], "wall_timestamp")
|
||||
model_t0 = _parse_aware_utc(data["model_t0"], "model_t0")
|
||||
|
||||
rng_state_raw = data.get("rng_state")
|
||||
if not rng_state_raw:
|
||||
@@ -184,9 +266,15 @@ class GeneratorState:
|
||||
rng_state=rng_state,
|
||||
last_batch_id=data.get("last_batch_id", ""),
|
||||
last_timestamp=datetime.fromisoformat(
|
||||
data.get("last_timestamp", "1970-01-01T00:00:00+00:00")
|
||||
data.get("last_timestamp", data["model_timestamp"])
|
||||
),
|
||||
version=version,
|
||||
model_timestamp=model_timestamp,
|
||||
wall_timestamp=wall_timestamp,
|
||||
model_time_speed=float(data["model_time_speed"]),
|
||||
model_timezone=data["model_timezone"],
|
||||
model_t0=model_t0,
|
||||
gen_seed=data["gen_seed"],
|
||||
population=data.get("population", []),
|
||||
active_visits=data.get("active_visits", []),
|
||||
pending_visit_births=data.get("pending_visit_births", 0.0),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 без версии."""
|
||||
|
||||
Reference in New Issue
Block a user