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
@@ -1,4 +1,4 @@
Status: ready-for-agent Status: ready-for-human
# Подключение к сервису и документации # Подключение к сервису и документации
@@ -18,17 +18,17 @@ Status: ready-for-agent
## Acceptance criteria ## Acceptance criteria
- [ ] Генератор в режиме steady-stream пишет связанные сообщения в - [x] Генератор в режиме steady-stream пишет связанные сообщения в
`browser_events`, `location_events`, `device_events`, `geo_events`. `browser_events`, `location_events`, `device_events`, `geo_events`.
- [ ] Существующие команды запуска и остановки генератора остаются рабочими или - [x] Существующие команды запуска и остановки генератора остаются рабочими или
документация явно описывает замену. документация явно описывает замену.
- [ ] Метрики Prometheus продолжают показывать успешные тики, ошибки публикации - [x] Метрики Prometheus продолжают показывать успешные тики, ошибки публикации
и объём отправленных событий. и объём отправленных событий.
- [ ] Интеграционный тест или проверка с мок-публикацией подтверждает, что - [x] Интеграционный тест или проверка с мок-публикацией подтверждает, что
сервисный контур использует новую модель, а не старую плоскую генерацию. сервисный контур использует новую модель, а не старую плоскую генерацию.
- [ ] `generator/README.md` описывает новые параметры и новую модель без старых - [x] `generator/README.md` описывает новые параметры и новую модель без старых
предупреждений о сломанном `click_id`. предупреждений о сломанном `click_id`.
- [ ] `generator/KNOWN_ISSUES.md` обновлён: старый дефект закрыт или перенесён в - [x] `generator/KNOWN_ISSUES.md` обновлён: старый дефект закрыт или перенесён в
исторический раздел, не как актуальный блокер. исторический раздел, не как актуальный блокер.
## Blocked by ## Blocked by
@@ -37,3 +37,16 @@ Status: ready-for-agent
## Comments ## Comments
- 2026-06-11: добавлена проверка сервисных тиков с мок-публикацией без Kafka:
steady-stream публикует несколько событий одного визита с общим `click_id`,
разными `event_id` и согласованными записями во всех четырёх топиках; история
тиков остаётся `success`.
- 2026-06-11: `docker-compose.yml` теперь пробрасывает переменные генератора
через `${VAR:-default}`, поэтому команды вида `GEN_STATE_RESET=true docker
compose up -d generator` реально меняют окружение контейнера. Внутренние
`KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` оставлены безопасными значениями
для контейнера.
- 2026-06-11: `generator/README.md` и `generator/KNOWN_ISSUES.md` синхронизированы
с новой моделью, состоянием v2 и историческим статусом старого дефекта.
- Проверка: `uv run --with-requirements generator/requirements.txt pytest
generator/tests -q` — 113 passed; `git diff --check` — без замечаний.
+16 -10
View File
@@ -292,17 +292,23 @@ services:
environment: environment:
KAFKA_BOOTSTRAP_SERVERS: kafka:29092 KAFKA_BOOTSTRAP_SERVERS: kafka:29092
# Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5) # Режим steady-stream: короткие тики 1-10 сек (по умолчанию 5)
GEN_TICK_SECONDS: "5" GEN_TICK_SECONDS: ${GEN_TICK_SECONDS:-5}
GEN_LAMBDA_BASE_PER_MIN: "30" GEN_LAMBDA_BASE_PER_MIN: ${GEN_LAMBDA_BASE_PER_MIN:-30}
GEN_JITTER_PCT: "20" GEN_JITTER_PCT: ${GEN_JITTER_PCT:-20}
GEN_MIN_EVENTS_PER_TICK: "1" GEN_MIN_EVENTS_PER_TICK: ${GEN_MIN_EVENTS_PER_TICK:-1}
GEN_MAX_EVENTS_PER_TICK: "50" GEN_MAX_EVENTS_PER_TICK: ${GEN_MAX_EVENTS_PER_TICK:-50}
GEN_MAX_SESSION_EVENTS: ${GEN_MAX_SESSION_EVENTS:-30}
GEN_MAX_ACTIVE_SESSIONS: ${GEN_MAX_ACTIVE_SESSIONS:-200}
GEN_POPULATION_MAX: ${GEN_POPULATION_MAX:-300}
GEN_P_NEW_USER: ${GEN_P_NEW_USER:-0.15}
GEN_MIN_RETURN_MINUTES: ${GEN_MIN_RETURN_MINUTES:-30}
GEN_DATA_DIR: /data GEN_DATA_DIR: /data
GEN_ENABLED: "true" GEN_SEED: ${GEN_SEED:-}
GEN_METRICS_PORT: "9109" GEN_ENABLED: ${GEN_ENABLED:-true}
# State management (сохранение состояния между рестартами) GEN_METRICS_PORT: ${GEN_METRICS_PORT:-9109}
GEN_STATE_ENABLED: "true" # Сохранение состояния между рестартами
GEN_STATE_RESET: "false" GEN_STATE_ENABLED: ${GEN_STATE_ENABLED:-true}
GEN_STATE_RESET: ${GEN_STATE_RESET:-false}
# PYTHONUNBUFFERED для сразу видеть логи # PYTHONUNBUFFERED для сразу видеть логи
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
ports: ports:
+19
View File
@@ -97,6 +97,23 @@ make generator-logs
| `GEN_JITTER_PCT` | Процент вариативности | `20` | | `GEN_JITTER_PCT` | Процент вариативности | `20` |
| `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` | | `GEN_MIN_EVENTS_PER_TICK` | Минимальный событийный бюджет тика | `1` |
| `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` | | `GEN_MAX_EVENTS_PER_TICK` | Максимальный событийный бюджет тика | `50` |
| `GEN_MAX_SESSION_EVENTS` | Потолок длины одного визита | `30` |
| `GEN_MAX_ACTIVE_SESSIONS` | Потолок одновременных активных визитов | `200` |
| `GEN_POPULATION_MAX` | Потолок активной популяции пользователей | `300` |
| `GEN_P_NEW_USER` | Доля визитов новых пользователей | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_STATE_ENABLED` | Сохранять state v2 между рестартами | `true` |
| `GEN_STATE_RESET` | Сбросить state при старте | `false` |
В `docker-compose.yml` через окружение переопределяются демо-параметры модели и
режима, например:
```bash
GEN_STATE_RESET=true GEN_LAMBDA_BASE_PER_MIN=60 docker compose up -d generator
```
Контейнерные `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose оставлены
внутренними значениями `kafka:29092` и `/data`.
### Топик истории ### Топик истории
@@ -118,6 +135,8 @@ Prometheus метрики доступны на `http://localhost:9109/metrics`:
- `generator_events_total` — счётчик отправленных событий - `generator_events_total` — счётчик отправленных событий
- `generator_publish_errors_total` — ошибки публикации - `generator_publish_errors_total` — ошибки публикации
- `generator_tick_duration_seconds` — длительность тика - `generator_tick_duration_seconds` — длительность тика
- `generator_last_success_timestamp` — время последнего успешного или частично
успешного тика
### Мониторинг через Grafana ### Мониторинг через Grafana
+53 -46
View File
@@ -1,35 +1,41 @@
# Генератор: известные проблемы и контекст для доработки # Генератор: известные проблемы и контекст для доработки
> **Статус (2026-06-06):** ветка `feature/data-generator` **не влита** в `main`. > **Статус (2026-06-11):** исторический дефект старой плоской генерации закрыт
> Генератор **не используется** как источник данных для витрин DM/дашборда. > для режима `steady-stream`. Генератор строит визиты с общим `click_id`,
> Витрины и Superset-дашборд строятся на **статическом сиде** (`data/*.jsonl`). > монотонным временем событий, путём по страницам воронки, популяцией
> Причина — ниже. Это не «сырой код по мелочи», а концептуальный дефект > возвращающихся пользователей и состоянием v2 для активных визитов.
> генеративной модели, который надо осознанно чинить перед использованием.
> >
> **Обновление (2026-06-11):** первый срез модели визита реализован в > Эта заметка больше не является предупреждением «генератор концептуально
> `generate_batch()`: один публичный вызов строит один `click_id` с несколькими > сломан». Она оставлена как учебный разбор старого дефекта и как место для
> событиями, марковским путём по страницам и запланированными строго растущими > небольших остаточных ограничений.
> метками времени. Остальные пункты ниже остаются полезным историческим
> контекстом и списком следующих шагов: популяция возвращающихся пользователей,
> межсессионные паузы и полноценное состояние активных визитов ещё не закрыты.
Заметка написана при дизайне Superset-дашборда (ветка Заметка написана при дизайне Superset-дашборда (ветка
`docs/advanced-clickstream-course`): разбирались, почему на дашборде `docs/advanced-clickstream-course`): разбирались, почему на дашборде
`Unique Users == Unique Sessions`, и по ходу вскрылось, что генератор `Unique Users == Unique Sessions`, и по ходу вскрылось, что старая реализация
семантику сессии не чинит, а ломает сильнее. Чтобы при возвращении к генератора на тот момент семантику сессии не чинила, а ломала сильнее. Разбор
генератору не переоткрывать это заново — фиксирую понимание целиком. оставлен, чтобы не переоткрывать этот дефект заново и показать, почему новая
модель устроена иначе.
## TL;DR ## TL;DR
- **Модель интенсивности потока (сколько событий и когда) — нормальная.** - **Модель интенсивности потока (сколько событий и когда) — нормальная.**
Poisson по тикам + дневной коэффициент + jitter. Её можно оставить. Poisson по тикам + дневной коэффициент + jitter сохранены.
- **Генеративная модель сущностей исправляется по шагам.** Срез одного визита - **Генеративная модель сущностей переписана.** Поток больше не штампует свежий
уже не штампует свежий `click_id` на каждое событие, но полная иерархия `click_id` на каждое событие: визит живёт несколько событий, пользователь
пользователь → несколько визитов → события ещё требует популяции может вернуться в новом визите после кулдауна.
возвращающихся пользователей. - **Вывод:** старый дефект `Sessions == Events` не считается актуальным
- **Вывод:** прежде чем использовать генератор как полноценный источник, блокером. Дальше генератор можно улучшать уже как работающую учебную модель,
доделать оставшиеся уровни модели сущностей. Математику интенсивности а не как концептуально сломанный источник.
трогать не обязательно.
## Текущие ограничения
- Тип события остаётся `pageview` для всех событий. Это осознанное ограничение:
текущий дашборд и уроки строят воронку по `page_url_path`, а не по
`event_type`.
- Device/geo-профиль пользователя стабилен между визитами. Смену устройства
генератор пока не моделирует.
- При долгом простое больше 30 минут активный визит закрывается без досылки
остатка. Популяция пользователей при этом сохраняется.
## Доменная модель (как задумано в DDL) ## Доменная модель (как задумано в DDL)
@@ -59,9 +65,9 @@ user_domain_id (постоянный пользователь, cookie)
Это адекватная модель *интенсивности во времени*. Претензий к ней нет. Это адекватная модель *интенсивности во времени*. Претензий к ней нет.
## Исторический корневой дефект: модель сущностей в `generate_batch()` ## Исторический корневой дефект: старая модель сущностей
До среза от 2026-06-11 прежняя реализация `generate_batch()` в монолитном До переработки 2026-06-11 прежняя реализация в монолитном
`generator/generator.py` на каждое событие в батче делала примерно следующее: `generator/generator.py` на каждое событие в батче делала примерно следующее:
```python ```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) | | `user_domain_id` | 1:1 с `click_id` | переиспользуется (потолок ~99) |
| Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) | | Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) |
Парадокс: **статический сид как учебная основа лучше**, потому что на нём Парадокс был таким: **статический сид как учебная основа был лучше**, потому что
`click_id` несёт осмысленную семантику визита. Генератор её ломает. на нём `click_id` нёс осмысленную семантику визита, а старый генератор её
ломал.
## Что перепроверить и переделать перед использованием ## Что сделано в новой модели
Чинить нужно **генеративную модель сущностей**, а не математику интенсивности: Чинили именно **генеративную модель сущностей**, не переписывая математику
интенсивности:
1. **Иерархическая генерация вместо плоской выборки:** 1. **Иерархическая генерация вместо плоской выборки:**
- поддерживать популяцию пользователей с *постоянным* `user_domain_id`; - есть ограниченная популяция пользователей с постоянным `user_domain_id`;
- пользователь со временем открывает 1..N **сессий** (новый `click_id` на - пользователь со временем открывает 1..N визитов;
сессию, с межсессионными паузами — модель «вернувшегося пользователя»); - визит порождает последовательность событий с одним `click_id`, общим
- сессия порождает последовательность из 1..M **событий**, разделяющих один device/geo-контекстом и путём по страницам.
`click_id` и общий device/geo, упорядоченных по времени (правдоподобный путь 2. **Тиковый слой:**
по страницам). - событийный бюджет тика превращается в рождения визитов через среднюю длину
2. **Распределения, требующие проверки математики:** визита;
- события на сессию (например, geometric/NB — длина визита); - активные визиты живут между тиками;
- сессии на пользователя за период (возвраты); - выпускаются только события, у которых наступила запланированная метка
- межсессионные интервалы (тайм-аут неактивности как граница сессии). времени.
3. **Время событий** внутри сессии должно расти монотонно, а не быть `now()` для 3. **Время событий** внутри визита строго растёт и не прилипает к одному
всего батча. `now()` для всего батча.
4. **`event_id`/`click_id`** уже всегда новые (`uuid4`) — при иерархической 4. **Состояние v2** сохраняет популяцию, активные визиты, накопленный бюджет
модели `click_id` должен переиспользоваться внутри сессии, а не на каждое рождения визитов, номер тика и состояние ГПСЧ.
событие (см. оговорку в `README.md`, раздел State Recovery).
После такой переделки на потоке естественно получится здоровая пирамида После этой переделки поток на длинном окне и при штатных параметрах даёт
`users < sessions < events`, и дашборд сможет показывать разницу здоровую пирамиду `users < sessions < events`, и дашборд может показывать
«пользователь vs сессия» честными числами. разницу «пользователь vs сессия» честными числами.
## Ссылки ## Ссылки
+33 -16
View File
@@ -1,13 +1,13 @@
# Генератор событий (MVP rev5) # Генератор событий (MVP rev5)
> Перед использованием как источник витрин прочитать
> [KNOWN_ISSUES.md](./KNOWN_ISSUES.md): часть старого дефекта уже исправлена
> (один `click_id` на визит, путь по страницам, монотонное время, активные
> визиты между тиками), но популяция возвращающихся пользователей и
> восстановление активных визитов после рестарта ещё остаются следующими шагами.
Автономный генератор событий для Kafka с режимом `steady-stream`. Автономный генератор событий для 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/intensity.py` | расчёт событийного бюджета тика |
| `src/clickstream_generator/runtime.py` | тиковый слой: активные визиты и выпуск созревших событий | | `src/clickstream_generator/runtime.py` | тиковый слой: активные визиты и выпуск созревших событий |
| `src/clickstream_generator/kafka_io.py` | Kafka publisher, история batch, Kafka-state и служебные топики | | `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/metrics.py` | Prometheus-метрики |
| `src/clickstream_generator/service.py` | основной цикл сервиса | | `src/clickstream_generator/service.py` | основной цикл сервиса |
| `generator.py` | запуск сервиса и совместимый фасад | | `generator.py` | запуск сервиса и совместимый фасад |
@@ -79,6 +79,16 @@ generator-service -> Kafka topics -> (потребители отдельно)
| `GEN_STATE_ENABLED` | Сохранять состояние между рестартами | `true` | | `GEN_STATE_ENABLED` | Сохранять состояние между рестартами | `true` |
| `GEN_STATE_RESET` | Сбросить состояние при старте | `false` | | `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 --from-beginning
``` ```
## State Recovery (восстановление состояния) ## Восстановление состояния
Генератор сохраняет своё состояние между перезапусками в Kafka-топик `generator_state` (compact topic). Это позволяет: Генератор сохраняет своё состояние между перезапусками в Kafka-топик `generator_state` (compact topic). Это позволяет:
- Продолжить нумерацию тиков с места остановки (continuity) - Продолжить нумерацию тиков с места остановки (continuity)
- Сохранить последовательность случайных чисел (RNG state) - Сохранить последовательность случайных чисел (RNG state)
- Восстановить интенсивность генерации после рестарта - Восстановить популяцию пользователей
- Продолжить активные визиты после короткого простоя
**Важно:** восстанавливается continuity по номеру тика и интенсивности, но не гарантируется отсутствие дублирования событий — `event_id` и `click_id` всегда генерируются заново (`uuid4()`).
### Как работает ### Как работает
1. После каждого успешного тика состояние сохраняется в `generator_state` 1. После каждого успешного тика состояние v2 сохраняется в `generator_state`.
2. При старте генератор читает последнее состояние из топика 2. При старте генератор читает последнее состояние из топика.
3. Если состояние найдено - продолжает с сохранённого tick 3. Если состояние найдено, сервис восстанавливает номер тика, состояние ГПСЧ,
4. Если нет - начинает с tick=1 популяцию пользователей, накопленный бюджет рождения визитов и активные
визиты.
4. Если состояния нет или оно невалидно, генератор начинает с чистого листа.
Активный визит после простоя до 30 минут продолжается со своими исходными
запланированными метками времени. Если следующий шаг визита просрочен больше
чем на 30 минут, визит закрывается без досылки остатка: для демо это выглядит
как пользователь, который ушёл, пока стенд был остановлен.
### Топик `generator_state` ### Топик `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 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 import random
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone from datetime import datetime, timezone
from time import sleep as real_sleep
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -13,6 +14,7 @@ from generator import (
Config, Config,
EventDictionary, EventDictionary,
EventGenerator, EventGenerator,
EXPECTED_VISIT_EVENTS,
GeneratorService, GeneratorService,
GeneratorState, GeneratorState,
KafkaBatchHistory, KafkaBatchHistory,
@@ -102,6 +104,83 @@ class TestGeneratorServiceDisabled:
assert "disabled" in caplog.text.lower() or "GEN_ENABLED" in caplog.text 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: class TestGeneratorServiceStateV2:
"""Тесты подключения state v2 к сервисному запуску.""" """Тесты подключения state v2 к сервисному запуску."""