refactor(generator): разнесён сервис генератора по src-пакету
- Зачем: - перед активными визитами нужно отделить генеративную модель от Kafka, состояния и сервисного цикла. - Что: - перенесены модули генератора в пакет `src/clickstream_generator`. - `generator.py` оставлен фасадом и точкой входа с совместимыми импортами. - обновлены Dockerfile, тесты, README, спека и issue 02.5. - Проверка: - `docker build -t generator:test generator`. - `docker run --rm -v /home/dmitry/sources/clickstream-ch-kafka-superset-demo:/workspace -w /workspace/generator generator:test pytest tests/ -q`. - `python -m py_compile generator.py src/clickstream_generator/*.py` в Docker.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
Status: ready-for-agent
|
Status: ready-for-human
|
||||||
|
|
||||||
# Уборка сервиса генератора перед активными визитами
|
# Уборка сервиса генератора перед активными визитами
|
||||||
|
|
||||||
@@ -13,20 +13,20 @@ Status: ready-for-agent
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Генеративная модель визита отделена от Kafka, состояния, метрик и
|
- [x] Генеративная модель визита отделена от Kafka, состояния, метрик и
|
||||||
сервисного цикла.
|
сервисного цикла.
|
||||||
- [ ] Код загрузки и индексации JSONL-сида вынесен из сервисного слоя.
|
- [x] Код загрузки и индексации JSONL-сида вынесен из сервисного слоя.
|
||||||
- [ ] Расчёт интенсивности отделён от генерации одного визита.
|
- [x] Расчёт интенсивности отделён от генерации одного визита.
|
||||||
- [ ] Kafka publisher, Kafka-state и создание служебных топиков не смешаны с
|
- [x] Kafka publisher, Kafka-state и создание служебных топиков не смешаны с
|
||||||
генеративной моделью.
|
генеративной моделью.
|
||||||
- [ ] `generator/generator.py` остаётся тонкой точкой входа или совместимым
|
- [x] `generator/generator.py` остаётся тонкой точкой входа или совместимым
|
||||||
фасадом, чтобы не ломать запуск и тесты без причины.
|
фасадом, чтобы не ломать запуск и тесты без причины.
|
||||||
- [ ] `generate_tick_batch()` не остаётся в чистой генеративной модели визита:
|
- [x] `generate_tick_batch()` не остаётся в чистой генеративной модели визита:
|
||||||
он вынесен в переходный тиковый слой и подготовлен к замене активными визитами
|
он вынесен в переходный тиковый слой и подготовлен к замене активными визитами
|
||||||
в задаче 03.
|
в задаче 03.
|
||||||
- [ ] Dockerfile и запуск контейнера обновлены под новую структуру файлов.
|
- [x] Dockerfile и запуск контейнера обновлены под новую структуру файлов.
|
||||||
- [ ] Все существующие тесты генератора проходят.
|
- [x] Все существующие тесты генератора проходят.
|
||||||
- [ ] README генератора кратко отражает новую структуру файлов.
|
- [x] README генератора кратко отражает новую структуру файлов.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
@@ -50,3 +50,7 @@ Status: ready-for-agent
|
|||||||
Задача появилась после разбора распухания сервиса: первые два среза уже внесли
|
Задача появилась после разбора распухания сервиса: первые два среза уже внесли
|
||||||
новую модель визита, а третий срез добавит состояние активных визитов. Перед ним
|
новую модель визита, а третий срез добавит состояние активных визитов. Перед ним
|
||||||
нужно отделить модель, тиковый слой и Kafka-интеграцию.
|
нужно отделить модель, тиковый слой и Kafka-интеграцию.
|
||||||
|
|
||||||
|
2026-06-11: исходники перенесены не россыпью в `generator/`, а в пакет
|
||||||
|
`generator/src/clickstream_generator/`. `generator/generator.py` оставлен тонким
|
||||||
|
фасадом и точкой входа для совместимости.
|
||||||
|
|||||||
@@ -71,22 +71,29 @@
|
|||||||
|
|
||||||
Целевая форма:
|
Целевая форма:
|
||||||
|
|
||||||
- `generator/config.py` — конфигурация и её валидация;
|
- `generator/generator.py` — тонкая точка входа и совместимый фасад для старых
|
||||||
- `generator/dictionary.py` — загрузка и индексы исходных JSONL;
|
импортов тестов;
|
||||||
- `generator/generation.py` — чистая генеративная модель визита, страниц и пауз;
|
- `generator/src/clickstream_generator/config.py` — конфигурация и её валидация;
|
||||||
- `generator/intensity.py` — расчёт бюджета активности: Пуассон, часовой
|
- `generator/src/clickstream_generator/dictionary.py` — загрузка и индексы
|
||||||
коэффициент, jitter;
|
исходных JSONL;
|
||||||
- `generator/kafka_io.py` — Kafka publisher, создание топиков, Kafka-state;
|
- `generator/src/clickstream_generator/generation.py` — чистая генеративная
|
||||||
- `generator/state.py` — сериализуемое состояние генератора;
|
модель визита, страниц и пауз;
|
||||||
- `generator/service.py` — основной сервисный цикл и метрики;
|
- `generator/src/clickstream_generator/intensity.py` — расчёт бюджета активности:
|
||||||
- `generator/generator.py` — тонкая точка входа или совместимый фасад для
|
Пуассон, часовой коэффициент, jitter;
|
||||||
старых импортов тестов.
|
- `generator/src/clickstream_generator/runtime.py` — переходный тиковый слой до
|
||||||
|
активных визитов;
|
||||||
|
- `generator/src/clickstream_generator/kafka_io.py` — Kafka publisher, создание
|
||||||
|
топиков, Kafka-state и история batch;
|
||||||
|
- `generator/src/clickstream_generator/state.py` — сериализуемое состояние
|
||||||
|
генератора;
|
||||||
|
- `generator/src/clickstream_generator/metrics.py` — Prometheus-метрики;
|
||||||
|
- `generator/src/clickstream_generator/service.py` — основной сервисный цикл.
|
||||||
|
|
||||||
Сейчас `generator/` не оформлен как Python-пакет: тесты импортируют
|
`generator.py` остаётся фасадом, потому что тесты и часть документации уже
|
||||||
`generator/generator.py` как модуль `generator`, а Dockerfile копирует только
|
используют импорт `from generator import ...`. Рабочий пакет называется
|
||||||
`generator.py`. В рамках этой уборки нужно сохранить совместимость импортов и
|
`clickstream_generator`, чтобы не путать его с каталогом `generator/` и файлом
|
||||||
обновить контейнерный запуск под новую структуру файлов; превращать каталог в
|
`generator.py`. Dockerfile должен копировать `src/` и выставлять
|
||||||
пакет через `__init__.py` не требуется.
|
`PYTHONPATH=/app/src`.
|
||||||
|
|
||||||
Точное разбиение может быть чуть проще, если это уменьшит шум, но граница между
|
Точное разбиение может быть чуть проще, если это уменьшит шум, но граница между
|
||||||
моделью генерации, тиковым runtime и Kafka-интеграцией должна быть явной.
|
моделью генерации, тиковым runtime и Kafka-интеграцией должна быть явной.
|
||||||
@@ -97,6 +104,9 @@
|
|||||||
новый слой состояния, и его нельзя удобно встроить в текущий монолит.
|
новый слой состояния, и его нельзя удобно встроить в текущий монолит.
|
||||||
- **Принято:** внешнее поведение генератора не меняется. Цель задачи — форма
|
- **Принято:** внешнее поведение генератора не меняется. Цель задачи — форма
|
||||||
кода, а не новая модель данных.
|
кода, а не новая модель данных.
|
||||||
|
- **Принято:** исходники живут в `generator/src/clickstream_generator/`, а не
|
||||||
|
россыпью в `generator/`. Причина: так структура ближе к обычному Python-пакету
|
||||||
|
и проще для учебного чтения.
|
||||||
- **Принято:** `generate_tick_batch()` считать временным переходным механизмом.
|
- **Принято:** `generate_tick_batch()` считать временным переходным механизмом.
|
||||||
После задачи 03 тик должен выпускать созревшие события активных визитов.
|
После задачи 03 тик должен выпускать созревшие события активных визитов.
|
||||||
- **Отклонено:** ограничиться перестановкой функций внутри `generator.py`.
|
- **Отклонено:** ограничиться перестановкой функций внутри `generator.py`.
|
||||||
@@ -118,13 +128,15 @@
|
|||||||
- Сервисный контур продолжает публиковать в те же четыре Kafka-топика:
|
- Сервисный контур продолжает публиковать в те же четыре Kafka-топика:
|
||||||
`browser_events`, `location_events`, `device_events`, `geo_events`.
|
`browser_events`, `location_events`, `device_events`, `geo_events`.
|
||||||
- Dockerfile и запуск контейнера учитывают новую структуру файлов.
|
- Dockerfile и запуск контейнера учитывают новую структуру файлов.
|
||||||
|
- Рабочий пакет импортируется как `clickstream_generator`, при этом старые
|
||||||
|
импорты через фасад `generator.py` продолжают работать.
|
||||||
- В коде есть ясное место, куда задача 03 добавит активные визиты без расширения
|
- В коде есть ясное место, куда задача 03 добавит активные визиты без расширения
|
||||||
Kafka-слоя и без переписывания генерации одного визита.
|
Kafka-слоя и без переписывания генерации одного визита.
|
||||||
|
|
||||||
## Documentation impact
|
## Documentation impact
|
||||||
|
|
||||||
- После реализации уборки обновить `generator/README.md`: описать новую
|
- После реализации уборки обновить `generator/README.md`: описать новую
|
||||||
структуру файлов и убрать формулировки, которые привязаны к одному
|
структуру `src/clickstream_generator` и убрать формулировки, которые привязаны
|
||||||
`generator.py`.
|
к одному `generator.py`.
|
||||||
- Если по ходу будет принято решение удалить или заменить `generator_batch_history`,
|
- Если по ходу будет принято решение удалить или заменить `generator_batch_history`,
|
||||||
это должно быть отдельно отражено в README и в задаче реализации.
|
это должно быть отдельно отражено в README и в задаче реализации.
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ COPY requirements.txt .
|
|||||||
RUN uv venv /app/.venv && \
|
RUN uv venv /app/.venv && \
|
||||||
uv pip install --python /app/.venv/bin/python -r requirements.txt
|
uv pip install --python /app/.venv/bin/python -r requirements.txt
|
||||||
|
|
||||||
# Копируем код
|
# Копируем точку входа и пакет генератора
|
||||||
COPY generator.py .
|
COPY generator.py .
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
# Не-root пользователь для безопасности
|
# Не-root пользователь для безопасности
|
||||||
RUN useradd -m -u 1000 generator && chown -R generator:generator /app
|
RUN useradd -m -u 1000 generator && chown -R generator:generator /app
|
||||||
@@ -21,6 +22,7 @@ USER generator
|
|||||||
|
|
||||||
# Активируем venv через PATH
|
# Активируем venv через PATH
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV PYTHONPATH="/app/src"
|
||||||
|
|
||||||
# Запуск генератора
|
# Запуск генератора
|
||||||
CMD ["python", "generator.py"]
|
CMD ["python", "generator.py"]
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ user_domain_id (постоянный пользователь, cookie)
|
|||||||
|
|
||||||
## Что генератор делает правильно
|
## Что генератор делает правильно
|
||||||
|
|
||||||
Файл `generator/generator.py`, `_calculate_events_count()` (стр. ~232–260):
|
Файл `generator/src/clickstream_generator/intensity.py`,
|
||||||
|
`calculate_events_count()`:
|
||||||
|
|
||||||
- **Poisson-процесс** прихода событий: λ на минуту → λ на тик → розыгрыш Пуассона.
|
- **Poisson-процесс** прихода событий: λ на минуту → λ на тик → розыгрыш Пуассона.
|
||||||
- **Дневной коэффициент** `_hour_factor()`: день (9–18) ×1.2, ночь (0–5) ×0.7.
|
- **Дневной коэффициент** `_hour_factor()`: день (9–18) ×1.2, ночь (0–5) ×0.7.
|
||||||
@@ -60,8 +61,8 @@ user_domain_id (постоянный пользователь, cookie)
|
|||||||
|
|
||||||
## Исторический корневой дефект: модель сущностей в `generate_batch()`
|
## Исторический корневой дефект: модель сущностей в `generate_batch()`
|
||||||
|
|
||||||
До среза от 2026-06-11 файл `generator/generator.py`, `generate_batch()` на
|
До среза от 2026-06-11 прежняя реализация `generate_batch()` в монолитном
|
||||||
каждое событие в батче делал примерно следующее:
|
`generator/generator.py` на каждое событие в батче делала примерно следующее:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
|
base_browser = self.rng.choice(self.dictionary.browser_events) # случайная сид-строка
|
||||||
@@ -123,8 +124,9 @@ device_event = {**base_device, "click_id": new_click_id} # user_domain_i
|
|||||||
|
|
||||||
## Ссылки
|
## Ссылки
|
||||||
|
|
||||||
- Код: `generator/generator.py` — `_calculate_events_count()` (стр. ~232–260),
|
- Код: `generator/src/clickstream_generator/intensity.py` —
|
||||||
`generate_batch()` (стр. ~262–330).
|
`calculate_events_count()`; `generator/src/clickstream_generator/generation.py`
|
||||||
|
— `EventGenerator.generate_batch()`.
|
||||||
- Доменная модель: `sql/ddl/dds/30_dds.sql`, `sql/ddl/dm/40_dm.sql`.
|
- Доменная модель: `sql/ddl/dds/30_dds.sql`, `sql/ddl/dm/40_dm.sql`.
|
||||||
- Контекст обсуждения: ветка `docs/advanced-clickstream-course`, дизайн
|
- Контекст обсуждения: ветка `docs/advanced-clickstream-course`, дизайн
|
||||||
Superset-дашборда (урок 6 курса).
|
Superset-дашборда (урок 6 курса).
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
|||||||
|
|
||||||
Генератор работает автономно и не зависит от потребителей (Airflow, ClickHouse).
|
Генератор работает автономно и не зависит от потребителей (Airflow, ClickHouse).
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
Код разнесён в пакет `src/clickstream_generator/`. Сам `generator.py` остаётся
|
||||||
|
точкой входа и совместимым фасадом для старых импортов из тестов.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `src/clickstream_generator/config.py` | переменные окружения и валидация настроек |
|
||||||
|
| `src/clickstream_generator/dictionary.py` | загрузка и индексы исходных JSONL |
|
||||||
|
| `src/clickstream_generator/generation.py` | генерация одного связанного визита |
|
||||||
|
| `src/clickstream_generator/intensity.py` | расчёт событийного бюджета тика |
|
||||||
|
| `src/clickstream_generator/runtime.py` | временный тиковый слой до активных визитов |
|
||||||
|
| `src/clickstream_generator/kafka_io.py` | Kafka publisher, история batch, Kafka-state и служебные топики |
|
||||||
|
| `src/clickstream_generator/state.py` | сериализуемое состояние генератора |
|
||||||
|
| `src/clickstream_generator/metrics.py` | Prometheus-метрики |
|
||||||
|
| `src/clickstream_generator/service.py` | основной цикл сервиса |
|
||||||
|
| `generator.py` | запуск сервиса и совместимый фасад |
|
||||||
|
|
||||||
## Режим работы: `steady-stream`
|
## Режим работы: `steady-stream`
|
||||||
|
|
||||||
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
- Публикуем постепенно, **короткими тиками** (по умолчанию каждые 5 секунд)
|
||||||
@@ -246,6 +264,7 @@ generator/tests/
|
|||||||
├── test_history.py # Тесты структуры BatchRecord
|
├── test_history.py # Тесты структуры BatchRecord
|
||||||
├── test_kafka_history.py # Тесты KafkaBatchHistory
|
├── test_kafka_history.py # Тесты KafkaBatchHistory
|
||||||
├── test_service.py # Тесты GeneratorService
|
├── test_service.py # Тесты GeneratorService
|
||||||
|
├── test_service_cleanup.py # Контракт разбиения сервиса на модули
|
||||||
└── test_state.py # Тесты GeneratorState и KafkaStateManager
|
└── test_state.py # Тесты GeneratorState и KafkaStateManager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+51
-1152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
"""Пакет генератора кликстрима."""
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Конфигурация генератора из переменных окружения."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Конфигурация генератора из переменных окружения."""
|
||||||
|
|
||||||
|
kafka_bootstrap_servers: str = field(
|
||||||
|
default_factory=lambda: os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:29092")
|
||||||
|
)
|
||||||
|
tick_seconds: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_TICK_SECONDS", "5"))
|
||||||
|
)
|
||||||
|
lambda_base_per_min: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_LAMBDA_BASE_PER_MIN", "200"))
|
||||||
|
)
|
||||||
|
jitter_pct: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_JITTER_PCT", "20"))
|
||||||
|
)
|
||||||
|
min_events_per_tick: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_MIN_EVENTS_PER_TICK", "5"))
|
||||||
|
)
|
||||||
|
max_events_per_tick: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_MAX_EVENTS_PER_TICK", "50"))
|
||||||
|
)
|
||||||
|
max_session_events: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_MAX_SESSION_EVENTS", "30"))
|
||||||
|
)
|
||||||
|
data_dir: Path = field(
|
||||||
|
default_factory=lambda: Path(os.getenv("GEN_DATA_DIR", "/data"))
|
||||||
|
)
|
||||||
|
seed: int | None = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_SEED"))
|
||||||
|
if os.getenv("GEN_SEED")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
enabled: bool = field(
|
||||||
|
default_factory=lambda: os.getenv("GEN_ENABLED", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
metrics_port: int = field(
|
||||||
|
default_factory=lambda: int(os.getenv("GEN_METRICS_PORT", "9109"))
|
||||||
|
)
|
||||||
|
state_enabled: bool = field(
|
||||||
|
default_factory=lambda: os.getenv("GEN_STATE_ENABLED", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
state_reset: bool = field(
|
||||||
|
default_factory=lambda: os.getenv("GEN_STATE_RESET", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.tick_seconds < 1:
|
||||||
|
raise ValueError("GEN_TICK_SECONDS must be >= 1")
|
||||||
|
if self.lambda_base_per_min < 1:
|
||||||
|
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 not self.data_dir.exists():
|
||||||
|
raise ValueError(f"Data directory does not exist: {self.data_dir}")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Загрузка и индексация исходных JSONL-событий."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("generator")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventDictionary:
|
||||||
|
"""Базовый словарь событий из JSONL файлов."""
|
||||||
|
|
||||||
|
browser_events: list[dict[str, Any]]
|
||||||
|
location_events: list[dict[str, Any]]
|
||||||
|
device_events: list[dict[str, Any]]
|
||||||
|
geo_events: list[dict[str, Any]]
|
||||||
|
browser_by_click_id: dict[str, list[dict]] = field(default_factory=dict)
|
||||||
|
location_by_event_id: dict[str, dict] = field(default_factory=dict)
|
||||||
|
device_by_click_id: dict[str, dict] = field(default_factory=dict)
|
||||||
|
geo_by_click_id: dict[str, dict] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
for browser in self.browser_events:
|
||||||
|
self.browser_by_click_id.setdefault(browser["click_id"], []).append(browser)
|
||||||
|
for loc in self.location_events:
|
||||||
|
self.location_by_event_id[loc["event_id"]] = loc
|
||||||
|
for dev in self.device_events:
|
||||||
|
self.device_by_click_id[dev["click_id"]] = dev
|
||||||
|
for geo in self.geo_events:
|
||||||
|
self.geo_by_click_id[geo["click_id"]] = geo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, data_dir: Path) -> "EventDictionary":
|
||||||
|
"""Загружает события из JSONL файлов."""
|
||||||
|
logger.info(f"Loading event dictionary from {data_dir}")
|
||||||
|
|
||||||
|
def load_jsonl(filename: str) -> list[dict]:
|
||||||
|
path = data_dir / filename
|
||||||
|
events = []
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
logger.info(f" Loaded {len(events)} events from {filename}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
browser_events = load_jsonl("browser_events.jsonl")
|
||||||
|
location_events = load_jsonl("location_events.jsonl")
|
||||||
|
device_events = load_jsonl("device_events.jsonl")
|
||||||
|
geo_events = load_jsonl("geo_events.jsonl")
|
||||||
|
|
||||||
|
if not browser_events:
|
||||||
|
raise ValueError("browser_events.jsonl is empty or missing")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
browser_events=browser_events,
|
||||||
|
location_events=location_events,
|
||||||
|
device_events=device_events,
|
||||||
|
geo_events=geo_events,
|
||||||
|
)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Чистая генеративная модель визита."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from clickstream_generator.config import Config
|
||||||
|
from clickstream_generator.dictionary import EventDictionary
|
||||||
|
from clickstream_generator.intensity import calculate_events_count, hour_factor
|
||||||
|
|
||||||
|
|
||||||
|
PAGE_START_DISTRIBUTION = [
|
||||||
|
("/home", 0.59),
|
||||||
|
("/product_a", 0.20),
|
||||||
|
("/product_b", 0.14),
|
||||||
|
("/cart", 0.04),
|
||||||
|
("/payment", 0.02),
|
||||||
|
("/confirmation", 0.01),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAGE_TRANSITIONS = {
|
||||||
|
"/home": [
|
||||||
|
("/home", 0.40),
|
||||||
|
("/product_a", 0.28),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.04),
|
||||||
|
(None, 0.10),
|
||||||
|
],
|
||||||
|
"/product_a": [
|
||||||
|
("/home", 0.30),
|
||||||
|
("/product_a", 0.16),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.27),
|
||||||
|
(None, 0.09),
|
||||||
|
],
|
||||||
|
"/product_b": [
|
||||||
|
("/home", 0.32),
|
||||||
|
("/product_a", 0.15),
|
||||||
|
("/product_b", 0.18),
|
||||||
|
("/cart", 0.27),
|
||||||
|
(None, 0.08),
|
||||||
|
],
|
||||||
|
"/cart": [
|
||||||
|
("/home", 0.20),
|
||||||
|
("/product_a", 0.12),
|
||||||
|
("/product_b", 0.10),
|
||||||
|
("/cart", 0.10),
|
||||||
|
("/payment", 0.42),
|
||||||
|
(None, 0.06),
|
||||||
|
],
|
||||||
|
"/payment": [
|
||||||
|
("/home", 0.12),
|
||||||
|
("/cart", 0.24),
|
||||||
|
("/payment", 0.12),
|
||||||
|
("/confirmation", 0.38),
|
||||||
|
(None, 0.14),
|
||||||
|
],
|
||||||
|
"/confirmation": [
|
||||||
|
("/home", 0.35),
|
||||||
|
("/product_a", 0.15),
|
||||||
|
("/product_b", 0.10),
|
||||||
|
("/confirmation", 0.05),
|
||||||
|
(None, 0.35),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventGenerator:
|
||||||
|
"""Генератор одного связанного визита."""
|
||||||
|
|
||||||
|
def __init__(self, dictionary: EventDictionary, config: Config):
|
||||||
|
self.dictionary = dictionary
|
||||||
|
self.config = config
|
||||||
|
self.rng = random.Random(config.seed)
|
||||||
|
|
||||||
|
def _new_uuid(self) -> str:
|
||||||
|
"""Генерирует новый UUID."""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
def _current_timestamp(self) -> str:
|
||||||
|
"""Возвращает текущую метку времени в формате JSONL."""
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
def _format_timestamp(self, timestamp: datetime) -> str:
|
||||||
|
"""Форматирует запланированную метку времени для JSONL."""
|
||||||
|
return timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||||
|
|
||||||
|
def _weighted_choice(self, choices: list[tuple[Any, float]]) -> Any:
|
||||||
|
"""Разыгрывает значение по списку весов."""
|
||||||
|
point = self.rng.random()
|
||||||
|
cumulative = 0.0
|
||||||
|
for value, weight in choices:
|
||||||
|
cumulative += weight
|
||||||
|
if point < cumulative:
|
||||||
|
return value
|
||||||
|
return choices[-1][0]
|
||||||
|
|
||||||
|
def _generate_visit_path(self, max_events: int, min_events: int = 1) -> list[str]:
|
||||||
|
"""Генерирует путь визита по страницам с защитой от бесконечных петель."""
|
||||||
|
page = self._weighted_choice(PAGE_START_DISTRIBUTION)
|
||||||
|
path = []
|
||||||
|
|
||||||
|
while page is not None and len(path) < max_events:
|
||||||
|
path.append(page)
|
||||||
|
page = self._weighted_choice(PAGE_TRANSITIONS[page])
|
||||||
|
|
||||||
|
while len(path) < min_events and len(path) < max_events:
|
||||||
|
transitions = [
|
||||||
|
(next_page, weight)
|
||||||
|
for next_page, weight in PAGE_TRANSITIONS[path[-1]]
|
||||||
|
if next_page is not None
|
||||||
|
]
|
||||||
|
path.append(self._weighted_choice(transitions))
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _visit_pause_seconds(self) -> float:
|
||||||
|
"""Разыгрывает паузу между событиями визита."""
|
||||||
|
pause = self.rng.lognormvariate(math.log(45.0), 1.0)
|
||||||
|
return max(1.0, min(pause, 29 * 60.0))
|
||||||
|
|
||||||
|
def _hour_factor(self) -> float:
|
||||||
|
"""Совместимый wrapper над расчётом часового коэффициента."""
|
||||||
|
return hour_factor()
|
||||||
|
|
||||||
|
def _calculate_events_count(self) -> int:
|
||||||
|
"""Совместимый wrapper над расчётом событийного бюджета."""
|
||||||
|
return calculate_events_count(self.config, self.rng)
|
||||||
|
|
||||||
|
def generate_batch(self, batch_size: int) -> dict[str, list[dict]]:
|
||||||
|
"""Генерирует один визит с сохранением связей."""
|
||||||
|
if not self.dictionary.browser_events:
|
||||||
|
return {
|
||||||
|
"browser_events": [],
|
||||||
|
"location_events": [],
|
||||||
|
"device_events": [],
|
||||||
|
"geo_events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = {
|
||||||
|
"browser_events": [],
|
||||||
|
"location_events": [],
|
||||||
|
"device_events": [],
|
||||||
|
"geo_events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if batch_size <= 0:
|
||||||
|
return batch
|
||||||
|
|
||||||
|
max_visit_events = min(batch_size, self.config.max_session_events)
|
||||||
|
min_visit_events = min(2, max_visit_events)
|
||||||
|
visit_path = self._generate_visit_path(max_visit_events, min_visit_events)
|
||||||
|
|
||||||
|
visit_candidates = [
|
||||||
|
click_id for click_id, browser_events in self.dictionary.browser_by_click_id.items()
|
||||||
|
if (
|
||||||
|
len(browser_events) >= len(visit_path)
|
||||||
|
and click_id in self.dictionary.device_by_click_id
|
||||||
|
and click_id in self.dictionary.geo_by_click_id
|
||||||
|
and all(
|
||||||
|
event["event_id"] in self.dictionary.location_by_event_id
|
||||||
|
for event in browser_events[:len(visit_path)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if visit_candidates:
|
||||||
|
base_click_id = self.rng.choice(visit_candidates)
|
||||||
|
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)
|
||||||
|
base_click_id = base_browser["click_id"]
|
||||||
|
base_browser_events = [base_browser for _ in range(len(visit_path))]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
new_event_id = self._new_uuid()
|
||||||
|
new_timestamp = self._format_timestamp(planned_timestamp)
|
||||||
|
|
||||||
|
browser_event = {
|
||||||
|
**base_browser,
|
||||||
|
"event_id": new_event_id,
|
||||||
|
"click_id": new_click_id,
|
||||||
|
"event_timestamp": new_timestamp,
|
||||||
|
}
|
||||||
|
batch["browser_events"].append(browser_event)
|
||||||
|
|
||||||
|
if base_location:
|
||||||
|
location_event = {
|
||||||
|
**base_location,
|
||||||
|
"event_id": new_event_id,
|
||||||
|
"page_url": f"http://www.dummywebsite.com{page_url_path}",
|
||||||
|
"page_url_path": page_url_path,
|
||||||
|
}
|
||||||
|
batch["location_events"].append(location_event)
|
||||||
|
|
||||||
|
if base_device:
|
||||||
|
batch["device_events"].append({**base_device, "click_id": new_click_id})
|
||||||
|
|
||||||
|
if base_geo:
|
||||||
|
batch["geo_events"].append({**base_geo, "click_id": new_click_id})
|
||||||
|
|
||||||
|
planned_timestamp += timedelta(seconds=self._visit_pause_seconds())
|
||||||
|
|
||||||
|
return batch
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Расчёт событийного бюджета тика."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from clickstream_generator.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def hour_factor(now: datetime | None = None) -> float:
|
||||||
|
"""Возвращает коэффициент интенсивности в зависимости от часа дня."""
|
||||||
|
current = now or datetime.now(timezone.utc)
|
||||||
|
hour = current.hour
|
||||||
|
if 9 <= hour <= 18:
|
||||||
|
return 1.2
|
||||||
|
if 0 <= hour <= 5:
|
||||||
|
return 0.7
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_events_count(config: Config, rng: random.Random) -> int:
|
||||||
|
"""Вычисляет количество событий для текущего тика (Poisson + jitter)."""
|
||||||
|
lambda_minute = config.lambda_base_per_min * hour_factor()
|
||||||
|
lambda_tick = lambda_minute * (config.tick_seconds / 60.0)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
threshold = math.exp(-lambda_tick)
|
||||||
|
product = 1.0
|
||||||
|
while product > threshold:
|
||||||
|
product *= rng.random()
|
||||||
|
count += 1
|
||||||
|
count -= 1
|
||||||
|
|
||||||
|
if config.jitter_pct > 0:
|
||||||
|
jitter_factor = 1.0 + rng.uniform(
|
||||||
|
-config.jitter_pct / 100.0,
|
||||||
|
config.jitter_pct / 100.0,
|
||||||
|
)
|
||||||
|
count = int(count * jitter_factor)
|
||||||
|
|
||||||
|
return max(config.min_events_per_tick, min(count, config.max_events_per_tick))
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""Kafka-интеграция генератора."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from clickstream_generator.metrics import METRICS_ERRORS_TOTAL, METRICS_EVENTS_TOTAL
|
||||||
|
from clickstream_generator.state import GeneratorState
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("generator")
|
||||||
|
|
||||||
|
_kafka_imported = False
|
||||||
|
KafkaProducer = None
|
||||||
|
KafkaError = None
|
||||||
|
|
||||||
|
|
||||||
|
def _import_kafka():
|
||||||
|
"""Лениво импортирует Kafka-клиент, чтобы тесты могли работать без Kafka."""
|
||||||
|
global _kafka_imported, KafkaProducer, KafkaError
|
||||||
|
if not _kafka_imported:
|
||||||
|
from kafka import KafkaProducer
|
||||||
|
from kafka.errors import KafkaError
|
||||||
|
_kafka_imported = True
|
||||||
|
return KafkaProducer, KafkaError
|
||||||
|
|
||||||
|
|
||||||
|
def _with_retry(operation, max_retries: int = 5, base_delay: float = 1.0, max_delay: float = 30.0):
|
||||||
|
"""Выполняет операцию с экспоненциальным backoff."""
|
||||||
|
last_exception = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return operation()
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||||
|
logger.warning(
|
||||||
|
f"Operation failed (attempt {attempt + 1}/{max_retries}): "
|
||||||
|
f"{e}. Retrying in {delay:.1f}s..."
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
logger.error(f"Operation failed after {max_retries} attempts: {e}")
|
||||||
|
raise last_exception
|
||||||
|
|
||||||
|
|
||||||
|
def _facade_attr(name: str, fallback):
|
||||||
|
"""Берёт совместимый mock из фасада generator, если тест его подменил."""
|
||||||
|
facade = sys.modules.get("generator")
|
||||||
|
return getattr(facade, name, fallback) if facade else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _retry(operation, **kwargs):
|
||||||
|
return _facade_attr("_with_retry", _with_retry)(operation, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _kafka_importer():
|
||||||
|
return _facade_attr("_import_kafka", _import_kafka)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchRecord:
|
||||||
|
"""Запись об отправленном батче."""
|
||||||
|
|
||||||
|
batch_id: str
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime
|
||||||
|
sent_total: int
|
||||||
|
sent_browser: int
|
||||||
|
sent_location: int
|
||||||
|
sent_device: int
|
||||||
|
sent_geo: int
|
||||||
|
status: str
|
||||||
|
error_message: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Конвертирует в словарь для сериализации."""
|
||||||
|
return {
|
||||||
|
"batch_id": self.batch_id,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"finished_at": self.finished_at.isoformat(),
|
||||||
|
"sent_total": self.sent_total,
|
||||||
|
"sent_browser": self.sent_browser,
|
||||||
|
"sent_location": self.sent_location,
|
||||||
|
"sent_device": self.sent_device,
|
||||||
|
"sent_geo": self.sent_geo,
|
||||||
|
"status": self.status,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class KafkaStateManager:
|
||||||
|
"""Управление состоянием генератора в Kafka compact topic."""
|
||||||
|
|
||||||
|
STATE_TOPIC = "generator_state"
|
||||||
|
STATE_KEY = "default"
|
||||||
|
|
||||||
|
def __init__(self, bootstrap_servers: str):
|
||||||
|
self.bootstrap_servers = bootstrap_servers
|
||||||
|
KafkaProducerCls, _ = _kafka_importer()()
|
||||||
|
|
||||||
|
logger.info(f"Connecting to Kafka for state management at {self.bootstrap_servers}")
|
||||||
|
self.producer = KafkaProducerCls(
|
||||||
|
bootstrap_servers=self.bootstrap_servers,
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||||
|
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
||||||
|
retries=3,
|
||||||
|
retry_backoff_ms=1000,
|
||||||
|
)
|
||||||
|
logger.info("Connected to Kafka for state management successfully")
|
||||||
|
|
||||||
|
def save(self, state: GeneratorState) -> None:
|
||||||
|
"""Сохраняет состояние в топик."""
|
||||||
|
value = state.to_dict()
|
||||||
|
|
||||||
|
def _do_send():
|
||||||
|
self.producer.send(self.STATE_TOPIC, key=self.STATE_KEY, value=value)
|
||||||
|
|
||||||
|
_retry(_do_send, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
"""Сбрасывает буфер с retry."""
|
||||||
|
def _do_flush():
|
||||||
|
self.producer.flush()
|
||||||
|
|
||||||
|
_retry(_do_flush, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Закрывает соединение."""
|
||||||
|
try:
|
||||||
|
self.producer.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error closing producer (ignored): {e}")
|
||||||
|
|
||||||
|
def load(self) -> GeneratorState | None:
|
||||||
|
"""Загружает последнее состояние из топика."""
|
||||||
|
from kafka import KafkaConsumer
|
||||||
|
|
||||||
|
logger.info(f"Loading state from topic {self.STATE_TOPIC}")
|
||||||
|
|
||||||
|
def _do_load():
|
||||||
|
consumer = KafkaConsumer(
|
||||||
|
self.STATE_TOPIC,
|
||||||
|
bootstrap_servers=self.bootstrap_servers,
|
||||||
|
auto_offset_reset="earliest",
|
||||||
|
enable_auto_commit=False,
|
||||||
|
consumer_timeout_ms=5000,
|
||||||
|
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
|
||||||
|
)
|
||||||
|
|
||||||
|
last_state = None
|
||||||
|
for message in consumer:
|
||||||
|
if message.key and message.key.decode("utf-8") == self.STATE_KEY:
|
||||||
|
last_state = message.value
|
||||||
|
|
||||||
|
consumer.close()
|
||||||
|
return last_state
|
||||||
|
|
||||||
|
try:
|
||||||
|
last_state = _retry(_do_load, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
if last_state:
|
||||||
|
logger.info(
|
||||||
|
f"Restored state: tick={last_state.get('tick')}, "
|
||||||
|
f"last_batch_id={last_state.get('last_batch_id')}"
|
||||||
|
)
|
||||||
|
restored = GeneratorState.from_dict_safe(last_state)
|
||||||
|
if restored is None:
|
||||||
|
logger.warning("State data was invalid, starting fresh")
|
||||||
|
return restored
|
||||||
|
|
||||||
|
logger.info("No previous state found, starting fresh")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load state: {e}, starting fresh")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_topics(bootstrap_servers: str) -> None:
|
||||||
|
"""Создаёт служебные топики, если их ещё нет."""
|
||||||
|
from kafka import KafkaAdminClient
|
||||||
|
from kafka.admin import NewTopic
|
||||||
|
from kafka.errors import TopicAlreadyExistsError
|
||||||
|
|
||||||
|
def _create_topics():
|
||||||
|
admin_client = KafkaAdminClient(bootstrap_servers=bootstrap_servers)
|
||||||
|
try:
|
||||||
|
history_topic = NewTopic(
|
||||||
|
name=KafkaBatchHistory.HISTORY_TOPIC,
|
||||||
|
num_partitions=1,
|
||||||
|
replication_factor=1,
|
||||||
|
)
|
||||||
|
state_topic = NewTopic(
|
||||||
|
name=KafkaStateManager.STATE_TOPIC,
|
||||||
|
num_partitions=1,
|
||||||
|
replication_factor=1,
|
||||||
|
topic_configs={
|
||||||
|
"cleanup.policy": "compact",
|
||||||
|
"min.cleanable.dirty.ratio": "0.1",
|
||||||
|
"delete.retention.ms": "100",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for topic in [history_topic, state_topic]:
|
||||||
|
try:
|
||||||
|
admin_client.create_topics([topic])
|
||||||
|
logger.info(f"Created topic: {topic.name}")
|
||||||
|
except TopicAlreadyExistsError:
|
||||||
|
logger.debug(f"Topic already exists: {topic.name}")
|
||||||
|
finally:
|
||||||
|
admin_client.close()
|
||||||
|
|
||||||
|
_retry(_create_topics, max_retries=5, base_delay=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class KafkaBatchHistory:
|
||||||
|
"""Хранение истории batch в Kafka."""
|
||||||
|
|
||||||
|
HISTORY_TOPIC = "generator_batch_history"
|
||||||
|
|
||||||
|
def __init__(self, bootstrap_servers: str):
|
||||||
|
self.bootstrap_servers = bootstrap_servers
|
||||||
|
self.producer = None
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""Устанавливает соединение с Kafka с retry."""
|
||||||
|
KafkaProducerCls, _ = _kafka_importer()()
|
||||||
|
|
||||||
|
def _do_connect():
|
||||||
|
logger.info(f"Connecting to Kafka for history at {self.bootstrap_servers}")
|
||||||
|
self.producer = KafkaProducerCls(
|
||||||
|
bootstrap_servers=self.bootstrap_servers,
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||||
|
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
||||||
|
retries=3,
|
||||||
|
retry_backoff_ms=1000,
|
||||||
|
)
|
||||||
|
logger.info("Connected to Kafka for history successfully")
|
||||||
|
|
||||||
|
_retry(_do_connect, max_retries=5, base_delay=1.0)
|
||||||
|
|
||||||
|
def add(self, record: BatchRecord):
|
||||||
|
"""Добавляет запись в историю."""
|
||||||
|
key = record.batch_id
|
||||||
|
value = record.to_dict()
|
||||||
|
|
||||||
|
def _do_send():
|
||||||
|
self.producer.send(self.HISTORY_TOPIC, key=key, value=value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_retry(_do_send, max_retries=3, base_delay=0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send history record after retries: {e}, attempting reconnect")
|
||||||
|
self._connect()
|
||||||
|
_retry(_do_send, max_retries=2, base_delay=0.5)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""Сбрасывает буфер с retry."""
|
||||||
|
def _do_flush():
|
||||||
|
self.producer.flush()
|
||||||
|
|
||||||
|
_retry(_do_flush, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Закрывает соединение."""
|
||||||
|
try:
|
||||||
|
if self.producer:
|
||||||
|
self.producer.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error closing history producer (ignored): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class KafkaPublisher:
|
||||||
|
"""Публикация событий в Kafka с retry и реконнектом."""
|
||||||
|
|
||||||
|
def __init__(self, bootstrap_servers: str):
|
||||||
|
self.bootstrap_servers = bootstrap_servers
|
||||||
|
self.producer = None
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""Устанавливает соединение с Kafka с retry."""
|
||||||
|
KafkaProducerCls, _ = _kafka_importer()()
|
||||||
|
|
||||||
|
def _do_connect():
|
||||||
|
logger.info(f"Connecting to Kafka at {self.bootstrap_servers}")
|
||||||
|
self.producer = KafkaProducerCls(
|
||||||
|
bootstrap_servers=self.bootstrap_servers,
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||||
|
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
||||||
|
batch_size=16384,
|
||||||
|
linger_ms=100,
|
||||||
|
retries=3,
|
||||||
|
retry_backoff_ms=1000,
|
||||||
|
)
|
||||||
|
logger.info("Connected to Kafka successfully")
|
||||||
|
|
||||||
|
_retry(_do_connect, max_retries=5, base_delay=1.0)
|
||||||
|
|
||||||
|
def _publish_with_retry(self, topic: str, events: list[dict]) -> tuple[int, int]:
|
||||||
|
"""Внутренняя функция публикации с retry на уровне batch."""
|
||||||
|
if not self.producer:
|
||||||
|
raise RuntimeError("Producer not connected")
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
errors = 0
|
||||||
|
futures = []
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
key = event.get("event_id") or event.get("click_id")
|
||||||
|
try:
|
||||||
|
future = self.producer.send(topic, key=key, value=event)
|
||||||
|
futures.append(future)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send message to {topic}: {e}")
|
||||||
|
errors += 1
|
||||||
|
METRICS_ERRORS_TOTAL.labels(topic=topic).inc()
|
||||||
|
|
||||||
|
for future in futures:
|
||||||
|
try:
|
||||||
|
future.get(timeout=10)
|
||||||
|
sent += 1
|
||||||
|
METRICS_EVENTS_TOTAL.labels(topic=topic).inc()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to confirm message delivery: {e}")
|
||||||
|
errors += 1
|
||||||
|
METRICS_ERRORS_TOTAL.labels(topic=topic).inc()
|
||||||
|
|
||||||
|
return sent, errors
|
||||||
|
|
||||||
|
def publish(self, topic: str, events: list[dict]) -> tuple[int, int]:
|
||||||
|
"""Публикует события в топик с retry и автоматическим реконнектом."""
|
||||||
|
def _do_publish():
|
||||||
|
return self._publish_with_retry(topic, events)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _retry(_do_publish, max_retries=3, base_delay=0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Publish failed after retries: {e}, attempting reconnect")
|
||||||
|
self._connect()
|
||||||
|
return _retry(_do_publish, max_retries=2, base_delay=0.5)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""Сбрасывает буфер с retry."""
|
||||||
|
def _do_flush():
|
||||||
|
if self.producer:
|
||||||
|
self.producer.flush()
|
||||||
|
|
||||||
|
_retry(_do_flush, max_retries=3, base_delay=0.5)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Закрывает соединение."""
|
||||||
|
try:
|
||||||
|
if self.producer:
|
||||||
|
self.producer.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error closing producer (ignored): {e}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Prometheus-метрики генератора."""
|
||||||
|
|
||||||
|
from prometheus_client import Counter, Gauge, Histogram
|
||||||
|
|
||||||
|
|
||||||
|
METRICS_EVENTS_TOTAL = Counter(
|
||||||
|
"generator_events_total",
|
||||||
|
"Total number of events sent to Kafka",
|
||||||
|
["topic"],
|
||||||
|
)
|
||||||
|
METRICS_ERRORS_TOTAL = Counter(
|
||||||
|
"generator_publish_errors_total",
|
||||||
|
"Total number of publish errors",
|
||||||
|
["topic"],
|
||||||
|
)
|
||||||
|
METRICS_TICK_DURATION = Histogram(
|
||||||
|
"generator_tick_duration_seconds",
|
||||||
|
"Duration of generator tick in seconds",
|
||||||
|
)
|
||||||
|
METRICS_LAST_SUCCESS = Gauge(
|
||||||
|
"generator_last_success_timestamp",
|
||||||
|
"Unix timestamp of last successful tick",
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Переходный тиковый слой генератора."""
|
||||||
|
|
||||||
|
from clickstream_generator.generation import EventGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tick_batch(generator: EventGenerator, event_budget: int) -> dict[str, list[dict]]:
|
||||||
|
"""Набирает тиковый батч из одного или нескольких полных визитов.
|
||||||
|
|
||||||
|
Это временный механизм до задачи 03. В ней тиковый слой начнёт хранить
|
||||||
|
активные визиты и выпускать только созревшие события.
|
||||||
|
"""
|
||||||
|
tick_batch = {
|
||||||
|
"browser_events": [],
|
||||||
|
"location_events": [],
|
||||||
|
"device_events": [],
|
||||||
|
"geo_events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Основной сервисный цикл генератора."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from prometheus_client import start_http_server
|
||||||
|
|
||||||
|
from clickstream_generator.config import Config
|
||||||
|
from clickstream_generator.dictionary import EventDictionary
|
||||||
|
from clickstream_generator.generation import EventGenerator
|
||||||
|
from clickstream_generator.kafka_io import (
|
||||||
|
BatchRecord,
|
||||||
|
KafkaBatchHistory,
|
||||||
|
KafkaPublisher,
|
||||||
|
KafkaStateManager,
|
||||||
|
ensure_topics,
|
||||||
|
)
|
||||||
|
from clickstream_generator.metrics import (
|
||||||
|
METRICS_ERRORS_TOTAL,
|
||||||
|
METRICS_LAST_SUCCESS,
|
||||||
|
METRICS_TICK_DURATION,
|
||||||
|
)
|
||||||
|
from clickstream_generator.runtime import generate_tick_batch
|
||||||
|
from clickstream_generator.state import GeneratorState
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("generator")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorService:
|
||||||
|
"""Основной сервис генератора."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self.dictionary = EventDictionary.load(config.data_dir)
|
||||||
|
self.generator = EventGenerator(self.dictionary, config)
|
||||||
|
self.publisher: KafkaPublisher | None = None
|
||||||
|
self.history: KafkaBatchHistory | None = None
|
||||||
|
self.state_manager: KafkaStateManager | None = None
|
||||||
|
self._running = False
|
||||||
|
self._tick = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Запускает основной цикл."""
|
||||||
|
if not self.config.enabled:
|
||||||
|
logger.warning("Generator is disabled (GEN_ENABLED=false)")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting metrics server on port {self.config.metrics_port}")
|
||||||
|
start_http_server(self.config.metrics_port)
|
||||||
|
|
||||||
|
logger.info("Starting generator service...")
|
||||||
|
logger.info(
|
||||||
|
f"Configuration: tick={self.config.tick_seconds}s, "
|
||||||
|
f"lambda_base={self.config.lambda_base_per_min}/min, "
|
||||||
|
f"jitter={self.config.jitter_pct}%, "
|
||||||
|
f"state_enabled={self.config.state_enabled}, "
|
||||||
|
f"state_reset={self.config.state_reset}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_topics(self.config.kafka_bootstrap_servers)
|
||||||
|
self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers)
|
||||||
|
self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers)
|
||||||
|
|
||||||
|
if self.config.state_enabled:
|
||||||
|
self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers)
|
||||||
|
|
||||||
|
if not self.config.state_reset:
|
||||||
|
restored_state = self.state_manager.load()
|
||||||
|
if restored_state:
|
||||||
|
self._tick = restored_state.tick
|
||||||
|
self.generator.rng.setstate(restored_state.rng_state)
|
||||||
|
logger.info(
|
||||||
|
f"Restored state: continuing from tick {self._tick}, "
|
||||||
|
f"last_batch_id={restored_state.last_batch_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("State reset requested, starting fresh")
|
||||||
|
else:
|
||||||
|
logger.info("State management disabled")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._main_loop()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received shutdown signal")
|
||||||
|
finally:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Останавливает сервис."""
|
||||||
|
logger.info("Stopping generator service...")
|
||||||
|
self._running = False
|
||||||
|
if self.publisher:
|
||||||
|
self.publisher.close()
|
||||||
|
if self.history:
|
||||||
|
self.history.close()
|
||||||
|
if self.state_manager:
|
||||||
|
self.state_manager.close()
|
||||||
|
|
||||||
|
def _save_state(self, batch_id: str) -> None:
|
||||||
|
"""Сохраняет текущее состояние генератора."""
|
||||||
|
if not self.state_manager or not self.config.state_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = GeneratorState(
|
||||||
|
tick=self._tick,
|
||||||
|
rng_state=self.generator.rng.getstate(),
|
||||||
|
last_batch_id=batch_id,
|
||||||
|
last_timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.state_manager.save(state)
|
||||||
|
self.state_manager.flush()
|
||||||
|
logger.debug(f"Saved state: tick={self._tick}, batch_id={batch_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to save state: {e}")
|
||||||
|
METRICS_ERRORS_TOTAL.labels(topic="state").inc()
|
||||||
|
|
||||||
|
def _main_loop(self):
|
||||||
|
"""Основной цикл тиков."""
|
||||||
|
while self._running:
|
||||||
|
self._tick += 1
|
||||||
|
tick_start = time.time()
|
||||||
|
batch_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
with METRICS_TICK_DURATION.time():
|
||||||
|
logger.info(f"=== Tick {self._tick} (batch_id={batch_id}) ===")
|
||||||
|
|
||||||
|
try:
|
||||||
|
events_count = self.generator._calculate_events_count()
|
||||||
|
logger.info(f"Generating ~{events_count} base events")
|
||||||
|
|
||||||
|
gen_start = time.time()
|
||||||
|
batch = generate_tick_batch(self.generator, events_count)
|
||||||
|
gen_duration = time.time() - gen_start
|
||||||
|
|
||||||
|
pub_start = time.time()
|
||||||
|
total_sent = 0
|
||||||
|
total_errors = 0
|
||||||
|
|
||||||
|
sent_counts = {}
|
||||||
|
for topic, events in batch.items():
|
||||||
|
if events:
|
||||||
|
sent, errors = self.publisher.publish(topic, events)
|
||||||
|
sent_counts[topic] = {"sent": sent, "errors": errors}
|
||||||
|
total_sent += sent
|
||||||
|
total_errors += errors
|
||||||
|
|
||||||
|
if total_errors == 0:
|
||||||
|
status = "success"
|
||||||
|
elif total_sent > 0:
|
||||||
|
status = "partial"
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
|
||||||
|
if status in ("success", "partial"):
|
||||||
|
METRICS_LAST_SUCCESS.set_to_current_time()
|
||||||
|
self._save_state(batch_id)
|
||||||
|
|
||||||
|
self.publisher.flush()
|
||||||
|
pub_duration = time.time() - pub_start
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch_record = BatchRecord(
|
||||||
|
batch_id=batch_id,
|
||||||
|
started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc),
|
||||||
|
finished_at=datetime.now(timezone.utc),
|
||||||
|
sent_total=total_sent,
|
||||||
|
sent_browser=sent_counts.get("browser_events", {}).get("sent", 0),
|
||||||
|
sent_location=sent_counts.get("location_events", {}).get("sent", 0),
|
||||||
|
sent_device=sent_counts.get("device_events", {}).get("sent", 0),
|
||||||
|
sent_geo=sent_counts.get("geo_events", {}).get("sent", 0),
|
||||||
|
status=status,
|
||||||
|
error_message=None if status == "success" else f"Errors: {total_errors}",
|
||||||
|
)
|
||||||
|
self.history.add(batch_record)
|
||||||
|
self.history.flush()
|
||||||
|
except Exception as hist_err:
|
||||||
|
logger.warning(f"Failed to write batch history: {hist_err}")
|
||||||
|
METRICS_ERRORS_TOTAL.labels(topic="history").inc()
|
||||||
|
|
||||||
|
tick_duration = time.time() - tick_start
|
||||||
|
logger.info(
|
||||||
|
f"Batch {batch_id} completed: "
|
||||||
|
f"sent={total_sent}, errors={total_errors}, "
|
||||||
|
f"gen_time={gen_duration:.3f}s, pub_time={pub_duration:.3f}s, "
|
||||||
|
f"total_time={tick_duration:.3f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
for topic, counts in sent_counts.items():
|
||||||
|
if counts["sent"] > 0:
|
||||||
|
logger.info(f" {topic}: {counts['sent']} sent")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error in tick {self._tick}: {e}")
|
||||||
|
try:
|
||||||
|
self.history.add(
|
||||||
|
BatchRecord(
|
||||||
|
batch_id=batch_id,
|
||||||
|
started_at=datetime.fromtimestamp(tick_start, tz=timezone.utc),
|
||||||
|
finished_at=datetime.now(timezone.utc),
|
||||||
|
sent_total=0,
|
||||||
|
sent_browser=0,
|
||||||
|
sent_location=0,
|
||||||
|
sent_device=0,
|
||||||
|
sent_geo=0,
|
||||||
|
status="error",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.history.flush()
|
||||||
|
except Exception as hist_err:
|
||||||
|
logger.warning(f"Failed to write error to history: {hist_err}")
|
||||||
|
|
||||||
|
elapsed = time.time() - tick_start
|
||||||
|
sleep_time = max(0, self.config.tick_seconds - elapsed)
|
||||||
|
if sleep_time > 0:
|
||||||
|
logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick")
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Точка входа сервиса."""
|
||||||
|
try:
|
||||||
|
config = Config()
|
||||||
|
service = GeneratorService(config)
|
||||||
|
service.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Fatal error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Сериализуемое состояние генератора."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("generator")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeneratorState:
|
||||||
|
"""Состояние генератора для восстановления после рестарта."""
|
||||||
|
|
||||||
|
tick: int
|
||||||
|
rng_state: tuple
|
||||||
|
last_batch_id: str
|
||||||
|
last_timestamp: datetime
|
||||||
|
version: str = "1.0"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Конвертирует в словарь для JSON-сериализации."""
|
||||||
|
return {
|
||||||
|
"tick": self.tick,
|
||||||
|
"rng_state": self.rng_state,
|
||||||
|
"last_batch_id": self.last_batch_id,
|
||||||
|
"last_timestamp": self.last_timestamp.isoformat(),
|
||||||
|
"version": self.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "GeneratorState":
|
||||||
|
"""Создаёт состояние из словаря."""
|
||||||
|
try:
|
||||||
|
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", "1970-01-01T00:00:00+00:00")
|
||||||
|
),
|
||||||
|
version=data.get("version", "1.0"),
|
||||||
|
)
|
||||||
|
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 Exception:
|
||||||
|
return None
|
||||||
@@ -5,8 +5,11 @@ Pytest fixtures для тестирования генератора.
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Добавляем родительскую директорию в путь
|
GENERATOR_DIR = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
# Добавляем фасад generator.py и src-пакет в путь
|
||||||
|
sys.path.insert(0, str(GENERATOR_DIR))
|
||||||
|
sys.path.insert(0, str(GENERATOR_DIR / "src"))
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from generator import Config, EventDictionary
|
from generator import Config, EventDictionary
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import replace
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from generator import EventGenerator, EventDictionary
|
from generator import EventGenerator, EventDictionary, generate_tick_batch
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_PAGE_PATHS = {
|
ALLOWED_PAGE_PATHS = {
|
||||||
@@ -95,7 +95,7 @@ class TestEventGeneration:
|
|||||||
"""Тиковый батч набирает целевой бюджет из одного или нескольких визитов."""
|
"""Тиковый батч набирает целевой бюджет из одного или нескольких визитов."""
|
||||||
generator = EventGenerator(event_dictionary, base_config)
|
generator = EventGenerator(event_dictionary, base_config)
|
||||||
|
|
||||||
batch = generator.generate_tick_batch(20)
|
batch = generate_tick_batch(generator, 20)
|
||||||
|
|
||||||
assert len(batch["browser_events"]) == 20
|
assert len(batch["browser_events"]) == 20
|
||||||
assert len(batch["location_events"]) == 20
|
assert len(batch["location_events"]) == 20
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Контракт уборки сервиса генератора.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from generator import (
|
||||||
|
Config,
|
||||||
|
EventDictionary,
|
||||||
|
EventGenerator,
|
||||||
|
GeneratorService,
|
||||||
|
KafkaPublisher,
|
||||||
|
GeneratorState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_copies_split_python_modules():
|
||||||
|
"""Контейнерный запуск видит все модули генератора после разбиения."""
|
||||||
|
dockerfile = (Path(__file__).parent.parent / "Dockerfile").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "COPY src ./src" in dockerfile
|
||||||
|
assert 'ENV PYTHONPATH="/app/src"' in dockerfile
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_facade_imports_without_pythonpath():
|
||||||
|
"""Локальный фасад сам находит src-пакет без внешнего PYTHONPATH."""
|
||||||
|
generator_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import generator"],
|
||||||
|
cwd=generator_dir,
|
||||||
|
env={"PATH": ""},
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_facade_preserves_public_imports_after_split():
|
||||||
|
"""Старый импорт из generator работает, но классы живут в отдельных модулях."""
|
||||||
|
assert Config.__module__ == "clickstream_generator.config"
|
||||||
|
assert EventDictionary.__module__ == "clickstream_generator.dictionary"
|
||||||
|
assert EventGenerator.__module__ == "clickstream_generator.generation"
|
||||||
|
assert KafkaPublisher.__module__ == "clickstream_generator.kafka_io"
|
||||||
|
assert GeneratorState.__module__ == "clickstream_generator.state"
|
||||||
|
assert GeneratorService.__module__ == "clickstream_generator.service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_batch_is_not_part_of_pure_visit_generation():
|
||||||
|
"""Временная сборка тика вынесена из чистой модели одного визита."""
|
||||||
|
assert not hasattr(EventGenerator, "generate_tick_batch")
|
||||||
Reference in New Issue
Block a user