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:
@@ -18,6 +18,7 @@ from generator import (
|
||||
generate_tick_batch,
|
||||
hour_factor,
|
||||
)
|
||||
from clickstream_generator.runtime import _timestamp_to_state_offset
|
||||
|
||||
|
||||
ALLOWED_PAGE_PATHS = {
|
||||
@@ -37,6 +38,24 @@ def _parse_event_timestamps(batch):
|
||||
]
|
||||
|
||||
|
||||
def _without_fields(batch, excluded_fields):
|
||||
return {
|
||||
topic: [
|
||||
{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key not in excluded_fields
|
||||
}
|
||||
for event in events
|
||||
]
|
||||
for topic, events in batch.items()
|
||||
}
|
||||
|
||||
|
||||
def _without_event_ids(batch):
|
||||
return _without_fields(batch, {"event_id"})
|
||||
|
||||
|
||||
def _page_path_visits(generator, visits_count: int):
|
||||
return [
|
||||
[
|
||||
@@ -297,7 +316,7 @@ class TestEventGeneration:
|
||||
def test_tick_stream_state_stays_compact_at_active_session_limit(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""State v2 не хранит полные события активных визитов."""
|
||||
"""State не хранит полные события активных визитов."""
|
||||
config = replace(
|
||||
base_config,
|
||||
max_session_events=30,
|
||||
@@ -386,6 +405,102 @@ class TestEventGeneration:
|
||||
for timestamp in resumed_timestamps
|
||||
)
|
||||
|
||||
def test_tick_stream_restored_visit_keeps_per_event_fixture(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""Восстановленный визит продолжает ту же фактуру событий."""
|
||||
config = replace(base_config, max_session_events=8)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
resume_at = tick_at + timedelta(hours=2)
|
||||
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
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, config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
|
||||
|
||||
uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at)
|
||||
restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at)
|
||||
|
||||
assert restored["browser_events"]
|
||||
assert _without_event_ids(restored) == _without_event_ids(uninterrupted)
|
||||
|
||||
def test_tick_stream_restored_visit_keeps_cyclic_fallback_fixture(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""Короткий донор фактуры после восстановления повторяется так же."""
|
||||
config = replace(base_config, max_session_events=30)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
resume_at = tick_at + timedelta(hours=12)
|
||||
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
stream.generate_tick(event_budget=100, 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,
|
||||
)
|
||||
assert any(
|
||||
len(event_dictionary.browser_by_click_id[visit["base_click_id"]])
|
||||
< len(visit["offsets_us"])
|
||||
for visit in state.active_visits
|
||||
)
|
||||
restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict())))
|
||||
|
||||
restored_generator = EventGenerator(event_dictionary, config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
|
||||
|
||||
uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at)
|
||||
restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at)
|
||||
|
||||
assert restored["browser_events"]
|
||||
assert _without_event_ids(restored) == _without_event_ids(uninterrupted)
|
||||
|
||||
def test_state_offset_preserves_microseconds_without_float_rounding(self):
|
||||
"""Смещение state не теряет микросекунду на float-округлении."""
|
||||
started_at = datetime(2026, 6, 11, 12, 0, 0)
|
||||
timestamp = datetime(2026, 6, 11, 12, 8, 34, 130779)
|
||||
|
||||
assert _timestamp_to_state_offset(started_at, timestamp) == 514_130_779
|
||||
|
||||
def test_tick_stream_restore_rejects_unknown_fixture_donor(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
"""Неизвестный донор фактуры в state даёт ошибку восстановления."""
|
||||
config = replace(base_config, max_session_events=5)
|
||||
tick_at = datetime(2026, 6, 11, 12, 0)
|
||||
|
||||
generator = EventGenerator(event_dictionary, config)
|
||||
stream = TickStreamGenerator(generator)
|
||||
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,
|
||||
)
|
||||
state_data = json.loads(json.dumps(state.to_dict()))
|
||||
state_data["active_visits"][0]["base_click_id"] = "missing-click-id"
|
||||
restored_state = type(state).from_dict(state_data)
|
||||
|
||||
restored_generator = EventGenerator(event_dictionary, config)
|
||||
restored_stream = TickStreamGenerator(restored_generator)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown fixture base_click_id"):
|
||||
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
|
||||
|
||||
def test_tick_stream_restored_after_long_idle_closes_overdue_visit_without_replay(
|
||||
self, event_dictionary, base_config
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user