feat(generator): подключена новая модель к steady-stream сервису
- Зачем: - после калибровки потока и state v2 генератор нужно принять как рабочий steady-stream источник, а не как исторически сломанный прототип. - Что: - добавлен сервисный тест multi-event визита с мок-публикацией во все четыре Kafka-топика. - compose позволяет переопределять демо-параметры генератора без правки файла, сохраняя внутренние контейнерные адреса. - README, OPERATIONS, KNOWN_ISSUES и карточка задачи синхронизированы с новой моделью и state v2. - Проверка: - uv run --with-requirements generator/requirements.txt pytest generator/tests -q. - git diff --check. - GEN_STATE_RESET=true GEN_POPULATION_MAX=123 docker compose config.
This commit is contained in:
+53
-46
@@ -1,35 +1,41 @@
|
||||
# Генератор: известные проблемы и контекст для доработки
|
||||
|
||||
> **Статус (2026-06-06):** ветка `feature/data-generator` **не влита** в `main`.
|
||||
> Генератор **не используется** как источник данных для витрин DM/дашборда.
|
||||
> Витрины и Superset-дашборд строятся на **статическом сиде** (`data/*.jsonl`).
|
||||
> Причина — ниже. Это не «сырой код по мелочи», а концептуальный дефект
|
||||
> генеративной модели, который надо осознанно чинить перед использованием.
|
||||
> **Статус (2026-06-11):** исторический дефект старой плоской генерации закрыт
|
||||
> для режима `steady-stream`. Генератор строит визиты с общим `click_id`,
|
||||
> монотонным временем событий, путём по страницам воронки, популяцией
|
||||
> возвращающихся пользователей и состоянием v2 для активных визитов.
|
||||
>
|
||||
> **Обновление (2026-06-11):** первый срез модели визита реализован в
|
||||
> `generate_batch()`: один публичный вызов строит один `click_id` с несколькими
|
||||
> событиями, марковским путём по страницам и запланированными строго растущими
|
||||
> метками времени. Остальные пункты ниже остаются полезным историческим
|
||||
> контекстом и списком следующих шагов: популяция возвращающихся пользователей,
|
||||
> межсессионные паузы и полноценное состояние активных визитов ещё не закрыты.
|
||||
> Эта заметка больше не является предупреждением «генератор концептуально
|
||||
> сломан». Она оставлена как учебный разбор старого дефекта и как место для
|
||||
> небольших остаточных ограничений.
|
||||
|
||||
Заметка написана при дизайне Superset-дашборда (ветка
|
||||
`docs/advanced-clickstream-course`): разбирались, почему на дашборде
|
||||
`Unique Users == Unique Sessions`, и по ходу вскрылось, что генератор
|
||||
семантику сессии не чинит, а ломает сильнее. Чтобы при возвращении к
|
||||
генератору не переоткрывать это заново — фиксирую понимание целиком.
|
||||
`Unique Users == Unique Sessions`, и по ходу вскрылось, что старая реализация
|
||||
генератора на тот момент семантику сессии не чинила, а ломала сильнее. Разбор
|
||||
оставлен, чтобы не переоткрывать этот дефект заново и показать, почему новая
|
||||
модель устроена иначе.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Модель интенсивности потока (сколько событий и когда) — нормальная.**
|
||||
Poisson по тикам + дневной коэффициент + jitter. Её можно оставить.
|
||||
- **Генеративная модель сущностей исправляется по шагам.** Срез одного визита
|
||||
уже не штампует свежий `click_id` на каждое событие, но полная иерархия
|
||||
пользователь → несколько визитов → события ещё требует популяции
|
||||
возвращающихся пользователей.
|
||||
- **Вывод:** прежде чем использовать генератор как полноценный источник,
|
||||
доделать оставшиеся уровни модели сущностей. Математику интенсивности
|
||||
трогать не обязательно.
|
||||
Poisson по тикам + дневной коэффициент + jitter сохранены.
|
||||
- **Генеративная модель сущностей переписана.** Поток больше не штампует свежий
|
||||
`click_id` на каждое событие: визит живёт несколько событий, пользователь
|
||||
может вернуться в новом визите после кулдауна.
|
||||
- **Вывод:** старый дефект `Sessions == Events` не считается актуальным
|
||||
блокером. Дальше генератор можно улучшать уже как работающую учебную модель,
|
||||
а не как концептуально сломанный источник.
|
||||
|
||||
## Текущие ограничения
|
||||
|
||||
- Тип события остаётся `pageview` для всех событий. Это осознанное ограничение:
|
||||
текущий дашборд и уроки строят воронку по `page_url_path`, а не по
|
||||
`event_type`.
|
||||
- Device/geo-профиль пользователя стабилен между визитами. Смену устройства
|
||||
генератор пока не моделирует.
|
||||
- При долгом простое больше 30 минут активный визит закрывается без досылки
|
||||
остатка. Популяция пользователей при этом сохраняется.
|
||||
|
||||
## Доменная модель (как задумано в DDL)
|
||||
|
||||
@@ -59,9 +65,9 @@ user_domain_id (постоянный пользователь, cookie)
|
||||
|
||||
Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
|
||||
|
||||
## Исторический корневой дефект: модель сущностей в `generate_batch()`
|
||||
## Исторический корневой дефект: старая модель сущностей
|
||||
|
||||
До среза от 2026-06-11 прежняя реализация `generate_batch()` в монолитном
|
||||
До переработки 2026-06-11 прежняя реализация в монолитном
|
||||
`generator/generator.py` на каждое событие в батче делала примерно следующее:
|
||||
|
||||
```python
|
||||
@@ -94,33 +100,34 @@ device_event = {**base_device, "click_id": new_click_id} # user_domain_i
|
||||
| `user_domain_id` | 1:1 с `click_id` | переиспользуется (потолок ~99) |
|
||||
| Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) |
|
||||
|
||||
Парадокс: **статический сид как учебная основа лучше**, потому что на нём
|
||||
`click_id` несёт осмысленную семантику визита. Генератор её ломает.
|
||||
Парадокс был таким: **статический сид как учебная основа был лучше**, потому что
|
||||
на нём `click_id` нёс осмысленную семантику визита, а старый генератор её
|
||||
ломал.
|
||||
|
||||
## Что перепроверить и переделать перед использованием
|
||||
## Что сделано в новой модели
|
||||
|
||||
Чинить нужно **генеративную модель сущностей**, а не математику интенсивности:
|
||||
Чинили именно **генеративную модель сущностей**, не переписывая математику
|
||||
интенсивности:
|
||||
|
||||
1. **Иерархическая генерация вместо плоской выборки:**
|
||||
- поддерживать популяцию пользователей с *постоянным* `user_domain_id`;
|
||||
- пользователь со временем открывает 1..N **сессий** (новый `click_id` на
|
||||
сессию, с межсессионными паузами — модель «вернувшегося пользователя»);
|
||||
- сессия порождает последовательность из 1..M **событий**, разделяющих один
|
||||
`click_id` и общий device/geo, упорядоченных по времени (правдоподобный путь
|
||||
по страницам).
|
||||
2. **Распределения, требующие проверки математики:**
|
||||
- события на сессию (например, geometric/NB — длина визита);
|
||||
- сессии на пользователя за период (возвраты);
|
||||
- межсессионные интервалы (тайм-аут неактивности как граница сессии).
|
||||
3. **Время событий** внутри сессии должно расти монотонно, а не быть `now()` для
|
||||
всего батча.
|
||||
4. **`event_id`/`click_id`** уже всегда новые (`uuid4`) — при иерархической
|
||||
модели `click_id` должен переиспользоваться внутри сессии, а не на каждое
|
||||
событие (см. оговорку в `README.md`, раздел State Recovery).
|
||||
- есть ограниченная популяция пользователей с постоянным `user_domain_id`;
|
||||
- пользователь со временем открывает 1..N визитов;
|
||||
- визит порождает последовательность событий с одним `click_id`, общим
|
||||
device/geo-контекстом и путём по страницам.
|
||||
2. **Тиковый слой:**
|
||||
- событийный бюджет тика превращается в рождения визитов через среднюю длину
|
||||
визита;
|
||||
- активные визиты живут между тиками;
|
||||
- выпускаются только события, у которых наступила запланированная метка
|
||||
времени.
|
||||
3. **Время событий** внутри визита строго растёт и не прилипает к одному
|
||||
`now()` для всего батча.
|
||||
4. **Состояние v2** сохраняет популяцию, активные визиты, накопленный бюджет
|
||||
рождения визитов, номер тика и состояние ГПСЧ.
|
||||
|
||||
После такой переделки на потоке естественно получится здоровая пирамида
|
||||
`users < sessions < events`, и дашборд сможет показывать разницу
|
||||
«пользователь vs сессия» честными числами.
|
||||
После этой переделки поток на длинном окне и при штатных параметрах даёт
|
||||
здоровую пирамиду `users < sessions < events`, и дашборд может показывать
|
||||
разницу «пользователь vs сессия» честными числами.
|
||||
|
||||
## Ссылки
|
||||
|
||||
|
||||
+33
-16
@@ -1,13 +1,13 @@
|
||||
# Генератор событий (MVP rev5)
|
||||
|
||||
> Перед использованием как источник витрин прочитать
|
||||
> [KNOWN_ISSUES.md](./KNOWN_ISSUES.md): часть старого дефекта уже исправлена
|
||||
> (один `click_id` на визит, путь по страницам, монотонное время, активные
|
||||
> визиты между тиками), но популяция возвращающихся пользователей и
|
||||
> восстановление активных визитов после рестарта ещё остаются следующими шагами.
|
||||
|
||||
Автономный генератор событий для Kafka с режимом `steady-stream`.
|
||||
|
||||
Генератор строит поток по иерархии `пользователь → визит → событие`: один
|
||||
`click_id` живёт весь визит, события визита идут по страницам воронки с
|
||||
монотонно растущим временем, а тиковый слой держит популяцию возвращающихся
|
||||
пользователей и активные визиты между тиками. Исторический дефект старой
|
||||
плоской генерации описан в [KNOWN_ISSUES.md](./KNOWN_ISSUES.md).
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
@@ -29,7 +29,7 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
||||
| `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/state.py` | сериализуемое состояние генератора v2 |
|
||||
| `src/clickstream_generator/metrics.py` | Prometheus-метрики |
|
||||
| `src/clickstream_generator/service.py` | основной цикл сервиса |
|
||||
| `generator.py` | запуск сервиса и совместимый фасад |
|
||||
@@ -79,6 +79,16 @@ generator-service -> Kafka topics -> (потребители отдельно)
|
||||
| `GEN_STATE_ENABLED` | Сохранять состояние между рестартами | `true` |
|
||||
| `GEN_STATE_RESET` | Сбросить состояние при старте | `false` |
|
||||
|
||||
В `docker-compose.yml` параметры генеративной модели и режима проброшены через
|
||||
подстановку окружения, то есть их можно менять без правки файла:
|
||||
|
||||
```bash
|
||||
GEN_LAMBDA_BASE_PER_MIN=60 GEN_POPULATION_MAX=500 docker compose up -d generator
|
||||
```
|
||||
|
||||
Контейнерные значения `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose
|
||||
оставлены безопасными внутренними значениями `kafka:29092` и `/data`.
|
||||
|
||||
### Режим "раз в минуту" (для демо)
|
||||
|
||||
Для контролируемых демо можно установить:
|
||||
@@ -197,22 +207,28 @@ docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
|
||||
--from-beginning
|
||||
```
|
||||
|
||||
## State Recovery (восстановление состояния)
|
||||
## Восстановление состояния
|
||||
|
||||
Генератор сохраняет своё состояние между перезапусками в Kafka-топик `generator_state` (compact topic). Это позволяет:
|
||||
|
||||
- Продолжить нумерацию тиков с места остановки (continuity)
|
||||
- Сохранить последовательность случайных чисел (RNG state)
|
||||
- Восстановить интенсивность генерации после рестарта
|
||||
|
||||
**Важно:** восстанавливается continuity по номеру тика и интенсивности, но не гарантируется отсутствие дублирования событий — `event_id` и `click_id` всегда генерируются заново (`uuid4()`).
|
||||
- Восстановить популяцию пользователей
|
||||
- Продолжить активные визиты после короткого простоя
|
||||
|
||||
### Как работает
|
||||
|
||||
1. После каждого успешного тика состояние сохраняется в `generator_state`
|
||||
2. При старте генератор читает последнее состояние из топика
|
||||
3. Если состояние найдено - продолжает с сохранённого tick
|
||||
4. Если нет - начинает с tick=1
|
||||
1. После каждого успешного тика состояние v2 сохраняется в `generator_state`.
|
||||
2. При старте генератор читает последнее состояние из топика.
|
||||
3. Если состояние найдено, сервис восстанавливает номер тика, состояние ГПСЧ,
|
||||
популяцию пользователей, накопленный бюджет рождения визитов и активные
|
||||
визиты.
|
||||
4. Если состояния нет или оно невалидно, генератор начинает с чистого листа.
|
||||
|
||||
Активный визит после простоя до 30 минут продолжается со своими исходными
|
||||
запланированными метками времени. Если следующий шаг визита просрочен больше
|
||||
чем на 30 минут, визит закрывается без досылки остатка: для демо это выглядит
|
||||
как пользователь, который ушёл, пока стенд был остановлен.
|
||||
|
||||
### Топик `generator_state`
|
||||
|
||||
@@ -250,7 +266,8 @@ docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
|
||||
GEN_STATE_ENABLED=false docker compose up -d generator
|
||||
```
|
||||
|
||||
При отключенном state management генератор всегда начинает с tick=1, RNG инициализируется с GEN_SEED (или случайно).
|
||||
При отключенном сохранении состояния генератор всегда начинает с `tick=1`, а
|
||||
ГПСЧ инициализируется с `GEN_SEED` или случайно.
|
||||
|
||||
## Тестирование
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import random
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from time import sleep as real_sleep
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -13,6 +14,7 @@ from generator import (
|
||||
Config,
|
||||
EventDictionary,
|
||||
EventGenerator,
|
||||
EXPECTED_VISIT_EVENTS,
|
||||
GeneratorService,
|
||||
GeneratorState,
|
||||
KafkaBatchHistory,
|
||||
@@ -102,6 +104,83 @@ class TestGeneratorServiceDisabled:
|
||||
assert "disabled" in caplog.text.lower() or "GEN_ENABLED" in caplog.text
|
||||
|
||||
|
||||
class TestGeneratorServiceSteadyStream:
|
||||
"""Проверки сервисного тика без настоящей Kafka."""
|
||||
|
||||
def test_service_ticks_publish_connected_multi_event_visit(self, base_config):
|
||||
"""Сервисные тики публикуют несколько связанных событий одного визита."""
|
||||
config = replace(base_config, tick_seconds=1, max_session_events=3)
|
||||
service = GeneratorService(config)
|
||||
service.publisher = MagicMock()
|
||||
service.publisher.publish.side_effect = (
|
||||
lambda topic, events: (len(events), 0)
|
||||
)
|
||||
service.history = MagicMock()
|
||||
service._running = True
|
||||
sleep_calls = 0
|
||||
|
||||
def stop_after_second_tick(sleep_seconds):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls == 1:
|
||||
real_sleep(sleep_seconds)
|
||||
else:
|
||||
service._running = False
|
||||
|
||||
with patch.object(
|
||||
service.generator,
|
||||
"_calculate_events_count",
|
||||
side_effect=[int(EXPECTED_VISIT_EVENTS), 0],
|
||||
), patch.object(
|
||||
service.generator,
|
||||
"_visit_pause_seconds",
|
||||
return_value=0.05,
|
||||
), patch("clickstream_generator.service.time.sleep") as sleep_mock:
|
||||
sleep_mock.side_effect = stop_after_second_tick
|
||||
|
||||
service._main_loop()
|
||||
|
||||
published = {}
|
||||
for call in service.publisher.publish.call_args_list:
|
||||
topic, events = call.args
|
||||
published.setdefault(topic, []).extend(events)
|
||||
|
||||
browser_events = published["browser_events"]
|
||||
location_events = published["location_events"]
|
||||
device_events = published["device_events"]
|
||||
geo_events = published["geo_events"]
|
||||
|
||||
assert set(published) == {
|
||||
"browser_events",
|
||||
"location_events",
|
||||
"device_events",
|
||||
"geo_events",
|
||||
}
|
||||
assert len(browser_events) >= 2
|
||||
assert len({event["click_id"] for event in browser_events}) == 1
|
||||
assert len({event["event_id"] for event in browser_events}) == len(browser_events)
|
||||
assert {event["event_id"] for event in location_events} == {
|
||||
event["event_id"]
|
||||
for event in browser_events
|
||||
}
|
||||
assert {event["click_id"] for event in device_events} == {
|
||||
browser_events[0]["click_id"]
|
||||
}
|
||||
assert {event["click_id"] for event in geo_events} == {
|
||||
browser_events[0]["click_id"]
|
||||
}
|
||||
|
||||
history_records = [
|
||||
call.args[0]
|
||||
for call in service.history.add.call_args_list
|
||||
]
|
||||
assert [record.status for record in history_records] == ["success", "success"]
|
||||
assert sum(record.sent_browser for record in history_records) == len(browser_events)
|
||||
assert sum(record.sent_location for record in history_records) == len(location_events)
|
||||
assert sum(record.sent_device for record in history_records) == len(device_events)
|
||||
assert sum(record.sent_geo for record in history_records) == len(geo_events)
|
||||
|
||||
|
||||
class TestGeneratorServiceStateV2:
|
||||
"""Тесты подключения state v2 к сервисному запуску."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user