- Зачем:
- world_next_day перечитывал всю историю топиков Kafka ради
накопительных счётчиков — время прогона росло с возрастом мира
(issue #5, находка F9).
- Что:
- счётчики засеваются при import из уже прочитанного артефакта и при
backfill из потока; next-day продвигает их только событиями нового
дня, полного чтения Kafka больше нет;
- катящаяся контрольная сумма — сумма SHA-256 событий по модулю 2^256
(инкремент равен полному пересчёту), старый формат артефакта
принимается без изменений;
- точные множества click_id/user_domain_id вынесены из manifest в
цепочку контент-адресуемых фрагментов (<=10 000 ID, SHA-256-цепочка,
отдельный топик counter_chunks) — потолок сообщения Kafka не грозит,
предел 900 000 байт проверяется явно с понятной ошибкой;
- порядок записи всюду: фрагменты -> manifest -> state; старое локальное
состояние отклоняется с подсказкой перезапустить import;
- документация manifest/state обновлена (ARCHITECTURE, OPERATIONS,
runbook startup-history).
- Проверка:
- make test (216+31) и make lint зелёные;
- живая приёмка на чистом стенде: import 235 с; три прогона
world_next_day — 716/718/716 с (плоское время, O(нового дня));
мир 3->6 дней, 561 942 события; make generated-history-chain-check —
все порции и стыки однородны;
- тест равенства инкремента и полного пересчёта:
test_incremental_counters_equal_full_recompute.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""Сериализуемое состояние генератора."""
|
||
|
||
import logging
|
||
import random
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
|
||
logger = logging.getLogger("generator")
|
||
|
||
|
||
STATE_VERSION = "3.0"
|
||
|
||
|
||
class UnsupportedStateVersionError(ValueError):
|
||
"""State записан в старом формате и требует явного решения оператора."""
|
||
|
||
def __init__(self, found_version: str, expected_version: str = STATE_VERSION):
|
||
self.found_version = found_version
|
||
self.expected_version = expected_version
|
||
super().__init__(
|
||
f"unsupported state version {found_version}; "
|
||
f"current version is {expected_version}"
|
||
)
|
||
|
||
|
||
def _nested_list_to_tuple(obj):
|
||
"""Рекурсивно преобразует list в tuple для восстановления RNG state."""
|
||
if isinstance(obj, list):
|
||
return tuple(_nested_list_to_tuple(x) for x in obj)
|
||
return obj
|
||
|
||
|
||
def _require_keys(item: dict, keys: tuple[str, ...], label: str) -> None:
|
||
missing = [key for key in keys if key not in item]
|
||
if missing:
|
||
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_v3_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)
|
||
|
||
if not isinstance(population, list):
|
||
raise ValueError("population must be a list")
|
||
if not population:
|
||
raise ValueError("population must be non-empty")
|
||
if not isinstance(active_visits, list):
|
||
raise ValueError("active_visits must be a list")
|
||
if not isinstance(pending_visit_births, int | float):
|
||
raise ValueError("pending_visit_births must be a number")
|
||
if not 0 <= pending_visit_births < 1_000_000:
|
||
raise ValueError("pending_visit_births is out of range")
|
||
|
||
users_by_id = {}
|
||
for index, user in enumerate(population):
|
||
if not isinstance(user, dict):
|
||
raise ValueError(f"population[{index}] must be an object")
|
||
_require_keys(user, ("user_domain_id", "seed_click_id"), f"population[{index}]")
|
||
user_domain_id = user["user_domain_id"]
|
||
seed_click_id = user["seed_click_id"]
|
||
active_click_id = user.get("active_click_id")
|
||
last_finished_at = user.get("last_finished_at")
|
||
if not isinstance(user_domain_id, str) or not user_domain_id:
|
||
raise ValueError(f"population[{index}].user_domain_id must be a string")
|
||
if not isinstance(seed_click_id, str) or not seed_click_id:
|
||
raise ValueError(f"population[{index}].seed_click_id must be a string")
|
||
if active_click_id is not None and not isinstance(active_click_id, str):
|
||
raise ValueError(f"population[{index}].active_click_id must be a string or null")
|
||
if last_finished_at is not None:
|
||
if not isinstance(last_finished_at, str):
|
||
raise ValueError(f"population[{index}].last_finished_at must be a string or null")
|
||
datetime.fromisoformat(last_finished_at)
|
||
if user_domain_id in users_by_id:
|
||
raise ValueError(f"duplicate population user_domain_id: {user_domain_id}")
|
||
users_by_id[user_domain_id] = user
|
||
|
||
active_visit_pairs = set()
|
||
for index, visit in enumerate(active_visits):
|
||
if not isinstance(visit, dict):
|
||
raise ValueError(f"active_visits[{index}] must be an object")
|
||
_require_keys(
|
||
visit,
|
||
(
|
||
"user_domain_id",
|
||
"click_id",
|
||
"base_click_id",
|
||
"next_index",
|
||
"started_at",
|
||
"offsets_us",
|
||
"page_url_paths",
|
||
),
|
||
f"active_visits[{index}]",
|
||
)
|
||
user_domain_id = visit["user_domain_id"]
|
||
click_id = visit["click_id"]
|
||
base_click_id = visit["base_click_id"]
|
||
offsets = visit["offsets_us"]
|
||
page_url_paths = visit["page_url_paths"]
|
||
next_index = visit["next_index"]
|
||
if not isinstance(user_domain_id, str) or user_domain_id not in users_by_id:
|
||
raise ValueError(f"active_visits[{index}].user_domain_id is unknown")
|
||
if not isinstance(click_id, str) or not click_id:
|
||
raise ValueError(f"active_visits[{index}].click_id must be a string")
|
||
if not isinstance(base_click_id, str) or not base_click_id:
|
||
raise ValueError(f"active_visits[{index}].base_click_id must be a string")
|
||
if not isinstance(offsets, list) or not offsets:
|
||
raise ValueError(f"active_visits[{index}].offsets_us must be a non-empty list")
|
||
if not all(isinstance(offset, int) and offset >= 0 for offset in offsets):
|
||
raise ValueError(f"active_visits[{index}].offsets_us must contain non-negative integers")
|
||
if offsets != sorted(offsets):
|
||
raise ValueError(f"active_visits[{index}].offsets_us must be sorted")
|
||
if not isinstance(page_url_paths, list) or len(page_url_paths) != len(offsets):
|
||
raise ValueError(
|
||
f"active_visits[{index}].page_url_paths must match offsets_us length"
|
||
)
|
||
if not all(isinstance(path, str) and path.startswith("/") for path in page_url_paths):
|
||
raise ValueError(f"active_visits[{index}].page_url_paths must contain paths")
|
||
if not isinstance(next_index, int) or not 0 <= next_index <= len(offsets):
|
||
raise ValueError(f"active_visits[{index}].next_index is out of range")
|
||
datetime.fromisoformat(visit["started_at"])
|
||
|
||
user_active_click_id = users_by_id[user_domain_id].get("active_click_id")
|
||
if user_active_click_id != click_id:
|
||
raise ValueError(
|
||
f"population active_click_id conflicts with active_visits[{index}]"
|
||
)
|
||
active_visit_pairs.add((user_domain_id, click_id))
|
||
|
||
for user_domain_id, user in users_by_id.items():
|
||
active_click_id = user.get("active_click_id")
|
||
if active_click_id and (user_domain_id, active_click_id) not in active_visit_pairs:
|
||
raise ValueError(
|
||
f"population user {user_domain_id} has active_click_id without active visit"
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class GeneratorState:
|
||
"""Состояние генератора для восстановления после рестарта."""
|
||
|
||
tick: int
|
||
rng_state: tuple
|
||
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
|
||
cumulative_manifest_counters: dict | None = None
|
||
|
||
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-сериализации."""
|
||
payload = {
|
||
"tick": self.tick,
|
||
"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,
|
||
"pending_visit_births": self.pending_visit_births,
|
||
}
|
||
if self.cumulative_manifest_counters is not None:
|
||
payload["cumulative_manifest_counters"] = (
|
||
self.cumulative_manifest_counters
|
||
)
|
||
return payload
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict) -> "GeneratorState":
|
||
"""Создаёт состояние из словаря."""
|
||
try:
|
||
if not isinstance(data, dict):
|
||
raise ValueError("state must be an object")
|
||
version = str(data.get("version", "1.0"))
|
||
if version != STATE_VERSION:
|
||
raise UnsupportedStateVersionError(version)
|
||
_validate_v3_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:
|
||
logger.warning("State missing rng_state field")
|
||
raise ValueError("rng_state is missing")
|
||
|
||
rng_state = _nested_list_to_tuple(rng_state_raw)
|
||
|
||
if not isinstance(rng_state, tuple):
|
||
logger.warning(f"rng_state is not tuple: {type(rng_state)}")
|
||
raise ValueError("rng_state must be tuple")
|
||
|
||
if len(rng_state) < 2:
|
||
logger.warning(f"rng_state has insufficient length: {len(rng_state)}")
|
||
raise ValueError("rng_state has insufficient length")
|
||
|
||
test_rng = random.Random()
|
||
test_rng.setstate(rng_state)
|
||
|
||
return cls(
|
||
tick=data.get("tick", 0),
|
||
rng_state=rng_state,
|
||
last_batch_id=data.get("last_batch_id", ""),
|
||
last_timestamp=datetime.fromisoformat(
|
||
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),
|
||
cumulative_manifest_counters=data.get(
|
||
"cumulative_manifest_counters"
|
||
),
|
||
)
|
||
except UnsupportedStateVersionError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning(f"Invalid state format, will start fresh: {e}")
|
||
raise ValueError(f"Invalid state: {e}")
|
||
|
||
@classmethod
|
||
def from_dict_safe(cls, data: dict) -> "GeneratorState | None":
|
||
"""Безопасная загрузка state с graceful degradation."""
|
||
try:
|
||
return cls.from_dict(data)
|
||
except UnsupportedStateVersionError:
|
||
raise
|
||
except Exception:
|
||
return None
|