fix(generator): сохранена фактура визита при восстановлении
- Зачем: - визит после восстановления не должен менять браузер и источники перехода внутри одного click_id. - Что: - добавлен base_click_id в state v3 для восстановления донора фактуры. - исправлено восстановление timestamp offset без потери микросекунд. - расширены тесты и стыковая проверка browser/source и device/os/geo. - Проверка: - uv run --with-requirements generator/requirements.txt pytest generator/tests -q. - bash -n scripts/check_generated_analytics.sh. - git diff --cached --check.
This commit is contained in:
@@ -142,13 +142,27 @@ class EventGenerator:
|
||||
user_profile: dict[str, dict] | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Генерирует один визит с сохранением связей."""
|
||||
batch, _base_click_id = self.generate_visit_batch(
|
||||
batch_size,
|
||||
planned_start_at=planned_start_at,
|
||||
user_profile=user_profile,
|
||||
)
|
||||
return batch
|
||||
|
||||
def generate_visit_batch(
|
||||
self,
|
||||
batch_size: int,
|
||||
planned_start_at: datetime | None = None,
|
||||
user_profile: dict[str, dict] | None = None,
|
||||
) -> tuple[dict[str, list[dict]], str | None]:
|
||||
"""Генерирует визит и возвращает click_id донора фактуры."""
|
||||
if not self.dictionary.browser_events:
|
||||
return {
|
||||
"browser_events": [],
|
||||
"location_events": [],
|
||||
"device_events": [],
|
||||
"geo_events": [],
|
||||
}
|
||||
}, None
|
||||
|
||||
batch = {
|
||||
"browser_events": [],
|
||||
@@ -158,7 +172,7 @@ class EventGenerator:
|
||||
}
|
||||
|
||||
if batch_size <= 0:
|
||||
return batch
|
||||
return batch, None
|
||||
|
||||
max_visit_events = min(batch_size, self.config.max_session_events)
|
||||
min_visit_events = min(2, max_visit_events)
|
||||
@@ -181,8 +195,18 @@ class EventGenerator:
|
||||
base_browser_events = self.dictionary.browser_by_click_id[base_click_id][:len(visit_path)]
|
||||
else:
|
||||
base_browser = self.rng.choice(self.dictionary.browser_events)
|
||||
# Запасная ветка тоже восстановима: state хранит донора, а индекс идёт по кругу.
|
||||
base_click_id = base_browser["click_id"]
|
||||
base_browser_events = [base_browser for _ in range(len(visit_path))]
|
||||
source_events = self.dictionary.browser_by_click_id[base_click_id]
|
||||
if not all(
|
||||
event["event_id"] in self.dictionary.location_by_event_id
|
||||
for event in source_events
|
||||
):
|
||||
raise ValueError(f"Fixture base_click_id has incomplete locations: {base_click_id}")
|
||||
base_browser_events = [
|
||||
source_events[event_index % len(source_events)]
|
||||
for event_index in range(len(visit_path))
|
||||
]
|
||||
|
||||
base_device = (
|
||||
user_profile["device"]
|
||||
@@ -229,4 +253,4 @@ class EventGenerator:
|
||||
|
||||
planned_timestamp += timedelta(seconds=self._visit_pause_seconds())
|
||||
|
||||
return batch
|
||||
return batch, base_click_id
|
||||
|
||||
@@ -35,6 +35,7 @@ class ActiveVisit:
|
||||
|
||||
batch: dict[str, list[dict]]
|
||||
timestamps: list[datetime]
|
||||
base_click_id: str
|
||||
user: UserProfile | None = None
|
||||
next_index: int = 0
|
||||
|
||||
@@ -130,7 +131,12 @@ def _datetime_to_state(value: datetime | None) -> str | None:
|
||||
|
||||
|
||||
def _timestamp_to_state_offset(started_at: datetime, timestamp: datetime) -> int:
|
||||
return int((timestamp - started_at).total_seconds() * 1_000_000)
|
||||
delta = timestamp - started_at
|
||||
return (
|
||||
delta.days * 86_400_000_000
|
||||
+ delta.seconds * 1_000_000
|
||||
+ delta.microseconds
|
||||
)
|
||||
|
||||
|
||||
def _format_event_timestamp(timestamp: datetime) -> str:
|
||||
@@ -217,6 +223,7 @@ class TickStreamGenerator:
|
||||
return {
|
||||
"user_domain_id": visit.user.user_domain_id if visit.user else None,
|
||||
"click_id": browser_events[0]["click_id"],
|
||||
"base_click_id": visit.base_click_id,
|
||||
"next_index": visit.next_index,
|
||||
"started_at": started_at.isoformat(),
|
||||
"offsets_us": [
|
||||
@@ -235,7 +242,7 @@ class TickStreamGenerator:
|
||||
resume_model_at: datetime | None = None,
|
||||
restarted_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Восстанавливает популяцию и активные визиты из state v2."""
|
||||
"""Восстанавливает популяцию и активные визиты из state."""
|
||||
users = [
|
||||
self._user_from_state(item)
|
||||
for item in state.population
|
||||
@@ -302,6 +309,7 @@ class TickStreamGenerator:
|
||||
]
|
||||
batch = self._compact_visit_batch(
|
||||
click_id=item["click_id"],
|
||||
base_click_id=item["base_click_id"],
|
||||
user=user,
|
||||
timestamps=timestamps,
|
||||
page_url_paths=item["page_url_paths"],
|
||||
@@ -309,6 +317,7 @@ class TickStreamGenerator:
|
||||
return ActiveVisit(
|
||||
batch=batch,
|
||||
timestamps=timestamps,
|
||||
base_click_id=item["base_click_id"],
|
||||
user=user,
|
||||
next_index=item["next_index"],
|
||||
)
|
||||
@@ -316,24 +325,26 @@ class TickStreamGenerator:
|
||||
def _compact_visit_batch(
|
||||
self,
|
||||
click_id: str,
|
||||
base_click_id: str,
|
||||
user: UserProfile,
|
||||
timestamps: list[datetime],
|
||||
page_url_paths: list[str],
|
||||
) -> dict[str, list[dict]]:
|
||||
batch = _empty_batch()
|
||||
browser_templates = self.generator.dictionary.browser_by_click_id.get(
|
||||
user.seed_click_id,
|
||||
self.generator.dictionary.browser_events,
|
||||
)
|
||||
if base_click_id not in self.generator.dictionary.browser_by_click_id:
|
||||
raise ValueError(f"Unknown fixture base_click_id: {base_click_id}")
|
||||
browser_templates = self.generator.dictionary.browser_by_click_id[base_click_id]
|
||||
if not browser_templates:
|
||||
raise ValueError(f"Fixture base_click_id has no browser events: {base_click_id}")
|
||||
|
||||
for event_index, (timestamp, page_url_path) in enumerate(
|
||||
zip(timestamps, page_url_paths)
|
||||
):
|
||||
browser_template = browser_templates[event_index % len(browser_templates)]
|
||||
location_template = self.generator.dictionary.location_by_event_id.get(
|
||||
browser_template["event_id"],
|
||||
self.generator.dictionary.location_events[0],
|
||||
)
|
||||
source_event_id = browser_template["event_id"]
|
||||
if source_event_id not in self.generator.dictionary.location_by_event_id:
|
||||
raise ValueError(f"Unknown fixture location event_id: {source_event_id}")
|
||||
location_template = self.generator.dictionary.location_by_event_id[source_event_id]
|
||||
event_id = _stable_event_id(click_id, event_index)
|
||||
batch["browser_events"].append(
|
||||
{
|
||||
@@ -428,7 +439,7 @@ class TickStreamGenerator:
|
||||
self._pending_visit_births = 0.0
|
||||
return
|
||||
|
||||
visit_batch = self.generator.generate_batch(
|
||||
visit_batch, base_click_id = self.generator.generate_visit_batch(
|
||||
self.generator.config.max_session_events,
|
||||
planned_start_at=tick_time,
|
||||
user_profile={"device": user.device, "geo": user.geo},
|
||||
@@ -439,11 +450,18 @@ class TickStreamGenerator:
|
||||
]
|
||||
if not timestamps:
|
||||
break
|
||||
if base_click_id is None:
|
||||
raise ValueError("Generated active visit is missing fixture base_click_id")
|
||||
|
||||
click_id = visit_batch["browser_events"][0]["click_id"]
|
||||
self.population.start_visit(user, click_id)
|
||||
self.active_visits.append(
|
||||
ActiveVisit(batch=visit_batch, timestamps=timestamps, user=user)
|
||||
ActiveVisit(
|
||||
batch=visit_batch,
|
||||
timestamps=timestamps,
|
||||
base_click_id=base_click_id,
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
self._pending_visit_births -= 1.0
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ class GeneratorService:
|
||||
state,
|
||||
wall_now_utc: datetime,
|
||||
) -> datetime:
|
||||
"""Считает модельную точку live-восстановления по state v2."""
|
||||
"""Считает модельную точку live-восстановления по state."""
|
||||
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())
|
||||
|
||||
@@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
logger = logging.getLogger("generator")
|
||||
|
||||
|
||||
STATE_VERSION = "2.0"
|
||||
STATE_VERSION = "3.0"
|
||||
|
||||
|
||||
def _nested_list_to_tuple(obj):
|
||||
@@ -74,7 +74,7 @@ def _validate_resume_fields(data: dict) -> None:
|
||||
raise ValueError("gen_seed must be an integer or null")
|
||||
|
||||
|
||||
def _validate_v2_payload(data: dict) -> None:
|
||||
def _validate_v3_payload(data: dict) -> None:
|
||||
_validate_resume_fields(data)
|
||||
population = data.get("population")
|
||||
active_visits = data.get("active_visits")
|
||||
@@ -123,6 +123,7 @@ def _validate_v2_payload(data: dict) -> None:
|
||||
(
|
||||
"user_domain_id",
|
||||
"click_id",
|
||||
"base_click_id",
|
||||
"next_index",
|
||||
"started_at",
|
||||
"offsets_us",
|
||||
@@ -132,6 +133,7 @@ def _validate_v2_payload(data: dict) -> None:
|
||||
)
|
||||
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"]
|
||||
@@ -139,6 +141,8 @@ def _validate_v2_payload(data: dict) -> None:
|
||||
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):
|
||||
@@ -235,7 +239,7 @@ class GeneratorState:
|
||||
version,
|
||||
)
|
||||
raise ValueError(f"unsupported state version: {version}")
|
||||
_validate_v2_payload(data)
|
||||
_validate_v3_payload(data)
|
||||
model_timestamp = _parse_aware_utc(
|
||||
data["model_timestamp"],
|
||||
"model_timestamp",
|
||||
|
||||
Reference in New Issue
Block a user