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:
Dmitry Dementiev
2026-06-11 18:26:39 +03:00
parent 4a08465f7d
commit 6c4e0f4d16
6 changed files with 220 additions and 79 deletions
+53 -46
View File
@@ -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
View File
@@ -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` или случайно.
## Тестирование
+79
View File
@@ -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 к сервисному запуску."""