feat(generator): добавлен поток активных визитов по тикам
- Зачем: - генератор должен выпускать события визита по запланированным тикам, не приклеивая весь визит к одному запуску. - Что: - добавлен тиковый слой `TickStreamGenerator` с активными визитами и выпуском созревших событий. - добавлена валидация потолка активных визитов относительно потолка популяции. - сохранён событийный смысл `event_budget` и добавлен регрессионный тест против разгона интенсивности. - Проверка: - `uv run --with-requirements generator/requirements.txt pytest generator/tests -q` — 81 passed.
This commit is contained in:
@@ -30,6 +30,12 @@ class Config:
|
||||
max_session_events: int = field(
|
||||
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
|
||||
)
|
||||
max_active_sessions: int = field(
|
||||
default_factory=lambda: int(os.getenv("GEN_MAX_ACTIVE_SESSIONS", "200"))
|
||||
)
|
||||
population_max: int = field(
|
||||
default_factory=lambda: int(os.getenv("GEN_POPULATION_MAX", "300"))
|
||||
)
|
||||
data_dir: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data"))
|
||||
)
|
||||
@@ -58,5 +64,11 @@ class Config:
|
||||
raise ValueError("GEN_LAMBDA_BASE_PER_MIN must be >= 1")
|
||||
if self.max_session_events < 1:
|
||||
raise ValueError("GEN_MAX_SESSION_EVENTS must be >= 1")
|
||||
if self.max_active_sessions < 1:
|
||||
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be >= 1")
|
||||
if self.population_max < 1:
|
||||
raise ValueError("GEN_POPULATION_MAX must be >= 1")
|
||||
if self.max_active_sessions >= self.population_max:
|
||||
raise ValueError("GEN_MAX_ACTIVE_SESSIONS must be < GEN_POPULATION_MAX")
|
||||
if not self.data_dir.exists():
|
||||
raise ValueError(f"Data directory does not exist: {self.data_dir}")
|
||||
|
||||
@@ -129,7 +129,11 @@ class EventGenerator:
|
||||
"""Совместимый wrapper над расчётом событийного бюджета."""
|
||||
return calculate_events_count(self.config, self.rng)
|
||||
|
||||
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]:
|
||||
def generate_batch(
|
||||
self,
|
||||
batch_size: int,
|
||||
planned_start_at: datetime | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Генерирует один визит с сохранением связей."""
|
||||
if not self.dictionary.browser_events:
|
||||
return {
|
||||
@@ -176,7 +180,9 @@ class EventGenerator:
|
||||
base_device = self.dictionary.device_by_click_id.get(base_click_id)
|
||||
base_geo = self.dictionary.geo_by_click_id.get(base_click_id)
|
||||
new_click_id = self._new_uuid()
|
||||
planned_timestamp = datetime.now(timezone.utc)
|
||||
planned_timestamp = planned_start_at or datetime.now(timezone.utc)
|
||||
if planned_timestamp.tzinfo is not None:
|
||||
planned_timestamp = planned_timestamp.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
for base_browser, page_url_path in zip(base_browser_events, visit_path):
|
||||
base_location = self.dictionary.location_by_event_id.get(base_browser["event_id"])
|
||||
|
||||
@@ -1,30 +1,133 @@
|
||||
"""Переходный тиковый слой генератора."""
|
||||
"""Тиковый слой генератора с активными визитами между вызовами."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from clickstream_generator.generation import EventGenerator
|
||||
|
||||
|
||||
def generate_tick_batch(generator: EventGenerator, event_budget: int) -> dict[str, list[dict]]:
|
||||
"""Набирает тиковый батч из одного или нескольких полных визитов.
|
||||
TOPICS = ("browser_events", "location_events", "device_events", "geo_events")
|
||||
|
||||
Это временный механизм до задачи 03. В ней тиковый слой начнёт хранить
|
||||
активные визиты и выпускать только созревшие события.
|
||||
|
||||
@dataclass
|
||||
class ActiveVisit:
|
||||
"""Запланированный визит, который выпускается по тикам."""
|
||||
|
||||
batch: dict[str, list[dict]]
|
||||
timestamps: list[datetime]
|
||||
next_index: int = 0
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
return self.next_index >= len(self.timestamps)
|
||||
|
||||
|
||||
def _empty_batch() -> dict[str, list[dict]]:
|
||||
return {topic: [] for topic in TOPICS}
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace(" ", "T"))
|
||||
|
||||
|
||||
def _normalize_tick_time(tick_started_at: datetime | None) -> datetime:
|
||||
tick_time = tick_started_at or datetime.now(timezone.utc)
|
||||
if tick_time.tzinfo is not None:
|
||||
return tick_time.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return tick_time
|
||||
|
||||
|
||||
class TickStreamGenerator:
|
||||
"""Раскладывает визиты по тикам и хранит активные визиты."""
|
||||
|
||||
def __init__(self, generator: EventGenerator):
|
||||
self.generator = generator
|
||||
self.active_visits: list[ActiveVisit] = []
|
||||
self._pending_event_budget = 0
|
||||
|
||||
def generate_tick(
|
||||
self,
|
||||
event_budget: int,
|
||||
tick_started_at: datetime | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Возвращает события, созревшие к текущему тику."""
|
||||
tick_time = _normalize_tick_time(tick_started_at)
|
||||
tick_batch = _empty_batch()
|
||||
|
||||
self._release_due_events(tick_time, tick_batch)
|
||||
self._drop_finished_visits()
|
||||
self._birth_visits(event_budget, tick_time)
|
||||
self._release_due_events(tick_time, tick_batch)
|
||||
self._drop_finished_visits()
|
||||
|
||||
return tick_batch
|
||||
|
||||
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
|
||||
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
||||
self._pending_event_budget = 0
|
||||
return
|
||||
|
||||
self._pending_event_budget += max(0, event_budget)
|
||||
|
||||
while (
|
||||
self._pending_event_budget > 0
|
||||
and len(self.active_visits) < self.generator.config.max_active_sessions
|
||||
):
|
||||
visit_batch = self.generator.generate_batch(
|
||||
self.generator.config.max_session_events,
|
||||
planned_start_at=tick_time,
|
||||
)
|
||||
timestamps = [
|
||||
_parse_timestamp(event["event_timestamp"])
|
||||
for event in visit_batch["browser_events"]
|
||||
]
|
||||
if not timestamps:
|
||||
break
|
||||
|
||||
self.active_visits.append(ActiveVisit(batch=visit_batch, timestamps=timestamps))
|
||||
self._pending_event_budget -= len(timestamps)
|
||||
|
||||
if len(self.active_visits) >= self.generator.config.max_active_sessions:
|
||||
self._pending_event_budget = 0
|
||||
|
||||
def _release_due_events(
|
||||
self,
|
||||
tick_time: datetime,
|
||||
tick_batch: dict[str, list[dict]],
|
||||
) -> None:
|
||||
for visit in self.active_visits:
|
||||
while not visit.is_finished and visit.timestamps[visit.next_index] <= tick_time:
|
||||
event_index = visit.next_index
|
||||
for topic in TOPICS:
|
||||
tick_batch[topic].append(visit.batch[topic][event_index])
|
||||
visit.next_index += 1
|
||||
|
||||
def _drop_finished_visits(self) -> None:
|
||||
self.active_visits = [
|
||||
visit
|
||||
for visit in self.active_visits
|
||||
if not visit.is_finished
|
||||
]
|
||||
|
||||
|
||||
_STREAMS: WeakKeyDictionary[EventGenerator, TickStreamGenerator] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def generate_tick_batch(
|
||||
generator: EventGenerator,
|
||||
event_budget: int,
|
||||
tick_started_at: datetime | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Совместимый фасад тикового генератора.
|
||||
|
||||
`event_budget` здесь трактуется как бюджет событий за тик. Если потолок
|
||||
активных визитов достигнут, новые рождения пропускаются и бюджет не
|
||||
переносится на следующие тики.
|
||||
"""
|
||||
tick_batch = {
|
||||
"browser_events": [],
|
||||
"location_events": [],
|
||||
"device_events": [],
|
||||
"geo_events": [],
|
||||
}
|
||||
stream = _STREAMS.get(generator)
|
||||
if stream is None:
|
||||
stream = TickStreamGenerator(generator)
|
||||
_STREAMS[generator] = stream
|
||||
|
||||
remaining_events = event_budget
|
||||
while remaining_events > 0:
|
||||
visit_batch = generator.generate_batch(remaining_events)
|
||||
generated_events = len(visit_batch["browser_events"])
|
||||
if generated_events == 0:
|
||||
break
|
||||
|
||||
for topic, events in visit_batch.items():
|
||||
tick_batch[topic].extend(events)
|
||||
remaining_events -= generated_events
|
||||
|
||||
return tick_batch
|
||||
return stream.generate_tick(event_budget, tick_started_at=tick_started_at)
|
||||
|
||||
@@ -133,7 +133,7 @@ class GeneratorService:
|
||||
|
||||
try:
|
||||
events_count = self.generator._calculate_events_count()
|
||||
logger.info(f"Generating ~{events_count} base events")
|
||||
logger.info(f"Generating with event budget ~{events_count}")
|
||||
|
||||
gen_start = time.time()
|
||||
batch = generate_tick_batch(self.generator, events_count)
|
||||
|
||||
Reference in New Issue
Block a user