feat(generator): добавлена стартовая история через backfill

- Зачем:
  - стенду нужна повторяемая история с живым продолжением от модельной границы без дублей и разрыва визитов.
- Что:
  - добавлен backfill-режим с `GEN_MODEL_T_END`, manifest и state на `T_end`.
  - live-запуск восстанавливается из manifest без настенной дельты и проверяет совместимость state.
  - добавлены SQL-проверки формы данных, повторяемости и стыка backfill с live.
- Проверка:
  - make generator-test.
  - два чистых ClickHouse-прогона backfill дали одинаковые manifest checksums и digest.
  - reviewer gate issue 05 пройден после исправлений state/manifest.
This commit is contained in:
2026-06-14 19:45:58 +03:00
parent fcb06da16e
commit d5408f28e9
11 changed files with 1509 additions and 21 deletions
@@ -1,4 +1,4 @@
Status: ready-for-agent Status: ready-for-human
# Стартовая история до ClickHouse # Стартовая история до ClickHouse
@@ -19,30 +19,200 @@ Status: ready-for-agent
## Acceptance criteria ## Acceptance criteria
- [ ] Промотка прошлого создаёт события за `[T0, T_end)`, слепок состояния и - [x] Промотка прошлого создаёт события за `[T0, T_end)`, слепок состояния и
манифест с контрольными данными. манифест с контрольными данными.
- [ ] При одинаковых `GEN_SEED`, `T0`, `T_end` и настройках артефакт промотки - [x] При одинаковых `GEN_SEED`, `T0`, `T_end` и настройках артефакт промотки
прошлого повторяем точно; проверки в ClickHouse следуют правилам допуска из прошлого повторяем точно; проверки в ClickHouse следуют правилам допуска из
контракта задачи 1. контракта задачи 1.
- [ ] История загружается в ClickHouse штатной или явно описанной командой. - [x] История загружается в ClickHouse штатной или явно описанной командой.
- [ ] SQL-проверка показывает здоровую пирамиду: пользователей меньше, чем - [x] SQL-проверка показывает здоровую пирамиду: пользователей меньше, чем
визитов, визитов меньше, чем событий. визитов, визитов меньше, чем событий.
- [ ] SQL-проверка показывает возвраты: у части пользователей больше одного - [x] SQL-проверка показывает возвраты: у части пользователей больше одного
визита. визита.
- [ ] SQL-проверка длины визита проверяет форму, а не только среднее: долю - [x] SQL-проверка длины визита проверяет форму, а не только среднее: долю
коротких визитов, медиану и долю срезов о потолок. коротких визитов, медиану и долю срезов о потолок.
- [ ] SQL-проверка воронки `/home -> товары -> /cart -> /payment -> - [x] SQL-проверка воронки `/home -> товары -> /cart -> /payment ->
/confirmation` монотонно убывает, а доля дошедших до `/confirmation` в /confirmation` монотонно убывает, а доля дошедших до `/confirmation` в
согласованном коридоре. согласованном коридоре.
- [ ] Живое продолжение после `T_end` не создаёт дублей на границе и не выглядит - [x] Живое продолжение после `T_end` не создаёт дублей на границе и не выглядит
как независимый второй мир. как независимый второй мир.
- [ ] Визиты, которые переходят через `T_end`, остаются однородными: контекст - [x] Визиты, которые переходят через `T_end`, остаются однородными: контекст
визита не меняется на стыке стартовой истории и живого продолжения. визита не меняется на стыке стартовой истории и живого продолжения.
- [ ] Подготовлены данные, команды и SQL-проверки, достаточные для внешнего - [x] Подготовлены данные, команды и SQL-проверки, достаточные для внешнего
review gate по распределениям и двум путям генерации из `PRD.md`. review gate по распределениям и двум путям генерации из `PRD.md`.
- [ ] Worker даёт промежуточный статус, если промотка, загрузка в ClickHouse или - [x] Worker даёт промежуточный статус, если промотка, загрузка в ClickHouse или
распределительные проверки занимают заметное время. распределительные проверки занимают заметное время.
## Blocked by ## Blocked by
- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md` - `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md`
## Фактический прогон worker-а
Дата проверки: 2026-06-14.
Настройки стенда:
- `GEN_SEED=4242`
- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`
- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00`
- `GEN_MODEL_TIMEZONE=UTC`
- `GEN_MODEL_TIME_SPEED=1`
- `GEN_TICK_SECONDS=60`
- `GEN_LAMBDA_BASE_PER_MIN=120`
- `GEN_JITTER_PCT=0`
- `GEN_MIN_EVENTS_PER_TICK=1`
- `GEN_MAX_EVENTS_PER_TICK=1000`
Команды проверки:
```bash
make clean
docker compose up -d clickhouse kafka
make ddl
docker compose build generator
docker compose run --rm --no-deps \
-e GEN_RUN_MODE=backfill \
-e GEN_STATE_RESET=true \
-e GEN_SEED=4242 \
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
-e GEN_MODEL_TIMEZONE=UTC \
-e GEN_MODEL_TIME_SPEED=1 \
-e GEN_TICK_SECONDS=60 \
-e GEN_LAMBDA_BASE_PER_MIN=120 \
-e GEN_JITTER_PCT=0 \
-e GEN_MIN_EVENTS_PER_TICK=1 \
-e GEN_MAX_EVENTS_PER_TICK=1000 \
generator
sleep 10
bash scripts/run_batch.sh
```
Повторяемость проверена двумя чистыми прогонами с полным сбросом
ClickHouse/Kafka/state через `make clean`. В обоих прогонах manifest и ClickHouse
дали одинаковые контрольные числа:
- manifest `browser_events.checksum_sha256`:
`3b1946bd7fd3d669e2461fcbae88d8aed6980bb5f126779fb4e7480ce15a3269`
- manifest `location_events.checksum_sha256`:
`370816c8d0b0311592282b9b75372e18862db9ef4984f7032885e935f9436522`
- manifest `device_events.checksum_sha256`:
`37e157ac7d5a3203c1c27b2a11abc1b9cb9fc7f74f79c77645faa09412e08b33`
- manifest `geo_events.checksum_sha256`:
`ec0e36e4613e05b9a5e72490c7565cf7e5f767d39a7a58b383e56e2eb191fc20`
- ClickHouse digest:
`CC1A73E65E897D1F1FC982CFA4237A07`
Backfill в ClickHouse:
- `events=31825`
- `unique_events=31825`
- `visits=3020`
- `users=1048`
- `min_event_ts=2026-01-01 00:00:00.000000`
- `max_event_ts=2026-01-01 05:59:59.521599`
- `pyramid_ok=1`
- `half_open_ok=1`
Проверка возвратов:
- `users=1048`
- `returning_users=708`
- `returning_share=0.6755725190839694`
- `max_visits_per_user=11`
Форма длины визита:
- `visits=3020`
- `short_visit_share=0.16490066225165562`
- `median_events_per_visit=8`
- `avg_events_per_visit=10.538079470198676`
- `capped_visit_share=0.0619205298013245`
- `median_duration_sec=202`
- `p95_duration_sec=823`
- `max_events_per_visit=30`
Воронка:
- строгая упорядоченная:
`home=2743`, `products=1626`, `cart=995`, `payment=673`,
`confirmation=394`, `monotonic_ok=1`,
`ordered_confirmation_share=0.14363835216915785`;
- калибровочная по наличию страницы в визите:
`home=2743`, `products=2706`, `cart=2003`, `payment=1368`,
`confirmation=824`, `monotonic_ok=1`,
`confirmation_share_all_visits=0.2728476821192053`,
`confirmation_share_from_home=0.3004010207801677`.
Live-продолжение:
```bash
docker compose run -d --name issue05-live --no-deps \
-e GEN_RUN_MODE=live \
-e GEN_STATE_RESET=false \
-e GEN_SEED=4242 \
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
-e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \
-e GEN_MODEL_TIMEZONE=UTC \
-e GEN_MODEL_TIME_SPEED=1 \
-e GEN_TICK_SECONDS=60 \
-e GEN_LAMBDA_BASE_PER_MIN=120 \
-e GEN_JITTER_PCT=0 \
-e GEN_MIN_EVENTS_PER_TICK=1 \
-e GEN_MAX_EVENTS_PER_TICK=1000 \
generator
```
Лог live-запуска подтвердил восстановление из стартовой истории:
`model_time=2026-01-01T06:00:00+00:00`, затем прошли тики `361`-`364`.
После live и повторного `bash scripts/run_batch.sh`:
- `users=1079`
- `visits=3069`
- `events=32145`
- `min_event_ts=2026-01-01 00:00:00.000000`
- `max_event_ts=2026-01-01 06:03:00.000000`
- `history_events=31825`
- `live_events=320`
- `duplicate_events=0`
- `boundary_events=13`
`boundary_events=13` — это live-события ровно на `T_end`; они не входили в
backfill `[T0, T_end)`.
Визиты через `T_end`:
- `crossing_visits=36`
- `visits_with_both_sides=36`
- `homogeneous_visits=36`
- `context_ok=1`
- `min_before_events=2`
- `min_after_events=1`
- `max_after_events=10`
## Риски для review gate
- Manifest, state и события связаны метаданными (`GEN_SEED`, `T0`, `T_end`,
настройки генерации, `last_batch_id`) и checksum событий по топикам. Полной
криптографической связки `state + events + manifest` пока нет.
- Для воронки есть две SQL-формы. Строгая ordered-форма проверяет порядок
`/home -> товары -> /cart -> /payment -> /confirmation`. Калибровочная
contains-форма проверяет, была ли страница в визите. Для review gate
`confirmation_share` сравнивается с коридором мат-модели по contains-форме.
## Review gate
- Саморевью worker-а нашло блокирующий риск: backfill не должен сохранять
state/manifest при частичной публикации. Исправлено fail-fast поведением и
тестом.
- Первый reviewer gate нашёл два блокера: startup-history state мог остаться без
manifest и восстановиться как live-state, а startup-history restore не сверял
поля самого state. Оба пункта исправлены до коммита.
- Повторный reviewer gate: `gate pass`, новых блокирующих находок нет.
- Проверки после исправлений: `make generator-test` — 134 passed.
- Остаточный риск процесса: reviewer был со свежим контекстом, но той же
родословной, а не внешней моделью.
+1
View File
@@ -303,6 +303,7 @@ services:
GEN_P_NEW_USER: ${GEN_P_NEW_USER:-0.15} GEN_P_NEW_USER: ${GEN_P_NEW_USER:-0.15}
GEN_MIN_RETURN_MINUTES: ${GEN_MIN_RETURN_MINUTES:-30} GEN_MIN_RETURN_MINUTES: ${GEN_MIN_RETURN_MINUTES:-30}
GEN_MODEL_T0: ${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00} GEN_MODEL_T0: ${GEN_MODEL_T0:-2026-01-01T00:00:00+00:00}
GEN_MODEL_T_END: ${GEN_MODEL_T_END:-}
GEN_MODEL_TIMEZONE: ${GEN_MODEL_TIMEZONE:-UTC} GEN_MODEL_TIMEZONE: ${GEN_MODEL_TIMEZONE:-UTC}
GEN_MODEL_TIME_SPEED: ${GEN_MODEL_TIME_SPEED:-1} GEN_MODEL_TIME_SPEED: ${GEN_MODEL_TIME_SPEED:-1}
GEN_RUN_MODE: ${GEN_RUN_MODE:-live} GEN_RUN_MODE: ${GEN_RUN_MODE:-live}
+282
View File
@@ -103,6 +103,7 @@ make generator-logs
| `GEN_P_NEW_USER` | Доля визитов новых пользователей | `0.15` | | `GEN_P_NEW_USER` | Доля визитов новых пользователей | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` | | `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` | | `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_T_END` | Правая граница стартовой истории для `backfill` | пусто |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим генератора | `live` | | `GEN_RUN_MODE` | Режим генератора | `live` |
@@ -119,6 +120,287 @@ GEN_STATE_RESET=true GEN_LAMBDA_BASE_PER_MIN=60 docker compose up -d generator
Контейнерные `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose оставлены Контейнерные `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose оставлены
внутренними значениями `kafka:29092` и `/data`. внутренними значениями `kafka:29092` и `/data`.
### Стартовая история через backfill
`GEN_RUN_MODE=backfill` быстро проматывает модельное прошлое от `GEN_MODEL_T0`
до `GEN_MODEL_T_END` без сна. В Kafka попадают события только за полуоткрытый
отрезок `[T0, T_end)`. В compact-topic `generator_state` сохраняется state на
`T_end`, а в `generator_startup_history_manifest` — manifest с настройками и
контрольными числами. При live-запуске с теми же настройками генератор видит,
что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты.
Для чистого повтора проще всего пересоздать volumes. Это сбрасывает ClickHouse,
Kafka-топики данных и compact-topic state.
```bash
make clean
docker compose up -d clickhouse kafka
make ddl
GEN_RUN_MODE=backfill \
GEN_STATE_RESET=true \
GEN_SEED=4242 \
GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 \
GEN_MODEL_TIMEZONE=UTC \
GEN_MODEL_TIME_SPEED=1 \
GEN_TICK_SECONDS=60 \
GEN_LAMBDA_BASE_PER_MIN=60 \
GEN_JITTER_PCT=0 \
docker compose run --rm --no-deps generator
# Materialized View слоя STG читает Kafka сама; даём ей коротко догнать.
sleep 10
bash scripts/run_batch.sh
```
Manifest можно посмотреть так:
```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server kafka:29092 \
--topic generator_startup_history_manifest \
--from-beginning \
--property print.key=true \
--timeout-ms 5000
```
Live-продолжение стартует с этого state. Используйте те же `GEN_SEED`, `T0`,
`T_end`, часовой пояс и настройки генерации. `GEN_STATE_RESET=false` важен: иначе
слепок стартовой истории будет проигнорирован.
```bash
docker compose run -d --name startup-history-live --no-deps \
-e GEN_RUN_MODE=live \
-e GEN_STATE_RESET=false \
-e GEN_SEED=4242 \
-e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \
-e GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 \
-e GEN_MODEL_TIMEZONE=UTC \
-e GEN_MODEL_TIME_SPEED=1 \
-e GEN_TICK_SECONDS=60 \
-e GEN_LAMBDA_BASE_PER_MIN=60 \
-e GEN_JITTER_PCT=0 \
generator
sleep 130
docker stop startup-history-live
docker rm startup-history-live
sleep 10
bash scripts/run_batch.sh
```
Базовая сверка формы данных после backfill:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end
SELECT
uniqExact(user_domain_id) AS users,
uniqExact(click_id) AS visits,
count() AS events,
users < visits AND visits < events AS pyramid_ok,
min(event_ts) AS min_event_ts,
max(event_ts) AS max_event_ts,
min_event_ts >= t0 AND max_event_ts < t_end AS half_open_ok
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end;
```
Повторяемость чистого прогона удобно сверять коротким digest по ключевым полям
ClickHouse. Запускайте запрос после `bash scripts/run_batch.sh`; при одинаковых
`GEN_SEED`, `T0`, `T_end` и настройках значение должно повторяться.
```bash
docker compose exec -T clickhouse clickhouse-client \
--user=default \
--password=123456 \
--query "
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end
SELECT hex(sipHash128(groupArray(tuple(
event_id,
click_id,
user_domain_id,
event_ts,
page_url_path
)))) AS digest
FROM (
SELECT
event_id,
click_id,
user_domain_id,
event_ts,
page_url_path
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
ORDER BY event_id
)"
```
Возвраты пользователей:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
users AS (
SELECT user_domain_id, uniqExact(click_id) AS visits
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
AND user_domain_id IS NOT NULL
GROUP BY user_domain_id
)
SELECT
count() AS users,
countIf(visits > 1) AS returning_users,
returning_users / users AS returning_share
FROM users;
```
Форма длины визита: проверяем не только среднее, а долю коротких визитов,
медиану и долю визитов, срезанных потолком `GEN_MAX_SESSION_EVENTS`.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
30 AS max_session_events,
sessions AS (
SELECT
click_id,
count() AS events_count,
dateDiff('second', min(event_ts), max(event_ts)) AS duration_sec
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
count() AS visits,
countIf(events_count <= 2) / visits AS short_visit_share,
quantileExact(0.5)(events_count) AS median_events_per_visit,
avg(events_count) AS avg_events_per_visit,
countIf(events_count = max_session_events) / visits AS capped_visit_share,
quantileExact(0.5)(duration_sec) AS median_duration_sec
FROM sessions;
```
Воронка должна монотонно убывать, а доля дошедших до `/confirmation` должна быть
в согласованном коридоре для текущих настроек генератора. Для review gate
`confirmation_share` сравнивается по калибровочной форме «страница была в
визите». Строгий SQL ниже проверяет отдельное свойство: упорядоченный путь
`/home -> товары -> /cart -> /payment -> /confirmation`.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
sessions AS (
SELECT
click_id,
minIf(event_ts, page_url_path = '/home') AS home_ts,
minIf(event_ts, page_url_path IN ('/product_a', '/product_b')) AS product_ts,
minIf(event_ts, page_url_path = '/cart') AS cart_ts,
minIf(event_ts, page_url_path = '/payment') AS payment_ts,
minIf(event_ts, page_url_path = '/confirmation') AS confirmation_ts
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
countIf(home_ts IS NOT NULL) AS home,
countIf(home_ts IS NOT NULL AND product_ts > home_ts) AS products,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts) AS cart,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts) AS payment,
countIf(home_ts IS NOT NULL AND product_ts > home_ts AND cart_ts > product_ts AND payment_ts > cart_ts AND confirmation_ts > payment_ts) AS confirmation,
products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
confirmation / home AS confirmation_share
FROM sessions;
```
Калибровочная форма воронки проверяет, что страница была в визите, без строгого
порядка событий. Именно эту форму используем для сравнения `confirmation_share`
в review gate.
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
sessions AS (
SELECT
click_id,
countIf(page_url_path = '/home') > 0 AS has_home,
countIf(page_url_path IN ('/product_a', '/product_b')) > 0 AS has_product,
countIf(page_url_path = '/cart') > 0 AS has_cart,
countIf(page_url_path = '/payment') > 0 AS has_payment,
countIf(page_url_path = '/confirmation') > 0 AS has_confirmation
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_end
GROUP BY click_id
)
SELECT
countIf(has_home) AS home,
countIf(has_home AND has_product) AS products,
countIf(has_home AND has_product AND has_cart) AS cart,
countIf(has_home AND has_product AND has_cart AND has_payment) AS payment,
countIf(has_home AND has_product AND has_cart AND has_payment AND has_confirmation) AS confirmation,
products <= home AND cart <= products AND payment <= cart AND confirmation <= payment AS monotonic_ok,
confirmation / home AS confirmation_share
FROM sessions;
```
Стык backfill + live проверяется после короткого live-продолжения:
```sql
WITH
toDateTime64('2026-01-01 00:00:00', 6) AS t0,
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
toDateTime64('2026-01-02 00:10:00', 6) AS t_live_end
SELECT
count() AS events,
uniqExact(event_id) AS unique_events,
events - unique_events AS duplicate_events,
countIf(event_ts = t_end) AS boundary_events,
min(event_ts) AS min_event_ts,
max(event_ts) AS max_event_ts
FROM dm.v_events_enriched
WHERE event_ts >= t0 AND event_ts < t_live_end;
```
Однородность визитов, переходящих через `T_end`:
```sql
WITH
toDateTime64('2026-01-02 00:00:00', 6) AS t_end,
crossing AS (
SELECT
click_id,
min(event_ts) AS first_ts,
max(event_ts) AS last_ts,
groupUniqArray(user_domain_id) AS users,
groupUniqArray(device_type) AS devices,
groupUniqArray(os_name) AS os_names,
groupUniqArray(geo_country) AS countries
FROM dm.v_events_enriched
WHERE event_ts >= t_end - INTERVAL 30 MINUTE
AND event_ts < t_end + INTERVAL 30 MINUTE
GROUP BY click_id
HAVING first_ts < t_end AND last_ts >= t_end
)
SELECT
count() AS crossing_visits,
countIf(
length(users) = 1
AND length(devices) = 1
AND length(os_names) = 1
AND length(countries) = 1
) AS homogeneous_visits,
crossing_visits = homogeneous_visits AS context_ok
FROM crossing;
```
### Проверка модельного времени в ClickHouse ### Проверка модельного времени в ClickHouse
Для повторяемой проверки используйте чистый стенд и явный сброс состояния Для повторяемой проверки используйте чистый стенд и явный сброс состояния
@@ -165,22 +165,29 @@ resume_model_at =
#### Манифест стартовой истории #### Манифест стартовой истории
Стартовая история состоит из трёх частей: события, слепок состояния и манифест. Стартовая история состоит из трёх частей: события, слепок состояния и манифест.
Манифест хранится как JSON и минимум содержит: Манифест хранится как JSON в Kafka compact-topic
`generator_startup_history_manifest`, ключ `default`. Слепок состояния хранится
в `generator_state`, ключ `default`. Манифест минимум содержит:
- `manifest_version`; - `manifest_version`;
- `generated_at` — настенная UTC-метка создания артефакта; - `generated_at` — настенная UTC-метка создания артефакта;
- `gen_seed`; - `gen_seed`;
- `model_t0`, `model_t_end`, `model_timezone`; - `model_t0`, `model_t_end`, `model_timezone`;
- `run_mode = "backfill"`; - `run_mode = "backfill"`;
- настройки генерации, влияющие на поток; - настройки генерации, влияющие на поток, в `generation_settings`;
- `state_version` и ссылку на файл или запись слепка состояния; - `state_version` и ссылку на запись слепка состояния;
- контрольные числа по каждому топику: количество строк, минимум и максимум - контрольные числа по каждому топику: количество строк, минимум и максимум
`event_timestamp`, контрольная сумма; `event_timestamp`, контрольная сумма;
- итоговые контрольные числа для проверки в ClickHouse: события, визиты, - итоговые контрольные числа для проверки в ClickHouse: события, визиты,
пользователи и диапазон модельного времени. пользователи и диапазон модельного времени.
Нельзя смешивать события, state и манифест от разных `GEN_SEED`, `T0`, `T_end` Нельзя смешивать события, state и манифест от разных `GEN_SEED`, `T0`, `T_end`
или настроек генерации. Такое смешивание считается ошибкой запуска. или настроек генерации. Live-запуск использует manifest как стартовую историю
только если `state.last_batch_id`, `state.model_timestamp`, `GEN_SEED`,
`GEN_MODEL_T0`, `GEN_MODEL_T_END`, `GEN_MODEL_TIMEZONE`, `GEN_MODEL_TIME_SPEED`
и `generation_settings` совпадают. Startup-history state без подходящего
manifest считается несовместимым и ведёт к чистому старту, а не к восстановлению
по правилу live-сбоя.
#### Повторяемая проверка в ClickHouse #### Повторяемая проверка в ClickHouse
+10
View File
@@ -73,6 +73,7 @@ generator-service -> Kafka topics -> (потребители отдельно)
| `GEN_P_NEW_USER` | Вероятность отдать новый визит новому пользователю | `0.15` | | `GEN_P_NEW_USER` | Вероятность отдать новый визит новому пользователю | `0.15` |
| `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` | | `GEN_MIN_RETURN_MINUTES` | Минимальная пауза перед возвратом пользователя | `30` |
| `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` | | `GEN_MODEL_T0` | Стартовая модельная точка, ISO 8601 с часовым поясом | `2026-01-01T00:00:00+00:00` |
| `GEN_MODEL_T_END` | Правая граница стартовой истории для `backfill` | — |
| `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` |
| `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` |
| `GEN_RUN_MODE` | Режим генератора | `live` | | `GEN_RUN_MODE` | Режим генератора | `live` |
@@ -97,6 +98,15 @@ GEN_LAMBDA_BASE_PER_MIN=60 GEN_POPULATION_MAX=500 docker compose up -d generator
реальный прогон. Дневной коэффициент считается по `GEN_MODEL_TIMEZONE`, а не по реальный прогон. Дневной коэффициент считается по `GEN_MODEL_TIMEZONE`, а не по
реальному часу запуска процесса. реальному часу запуска процесса.
В режиме `backfill` генератор без сна проходит от `GEN_MODEL_T0` до
`GEN_MODEL_T_END`, публикует события только за `[T0, T_end)`, сохраняет state v2
на `T_end` в `generator_state` и пишет manifest в compact-topic
`generator_startup_history_manifest`. Live-запуск с теми же настройками
использует этот manifest, чтобы продолжить ровно с `T_end` без настенной дельты.
Для одноразового backfill-запуска через compose используйте `docker compose run
--rm generator`, а не `docker compose up generator`: у штатного сервиса включён
restart policy.
Контейнерные значения `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose Контейнерные значения `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` в compose
оставлены безопасными внутренними значениями `kafka:29092` и `/data`. оставлены безопасными внутренними значениями `kafka:29092` и `/data`.
+2
View File
@@ -24,6 +24,7 @@ from clickstream_generator.kafka_io import (
KafkaBatchHistory, KafkaBatchHistory,
KafkaPublisher, KafkaPublisher,
KafkaStateManager, KafkaStateManager,
KafkaStartupHistoryManifest,
_import_kafka, _import_kafka,
_with_retry, _with_retry,
ensure_topics, ensure_topics,
@@ -56,6 +57,7 @@ __all__ = [
"KafkaBatchHistory", "KafkaBatchHistory",
"KafkaPublisher", "KafkaPublisher",
"KafkaStateManager", "KafkaStateManager",
"KafkaStartupHistoryManifest",
"METRICS_ERRORS_TOTAL", "METRICS_ERRORS_TOTAL",
"METRICS_EVENTS_TOTAL", "METRICS_EVENTS_TOTAL",
"METRICS_LAST_SUCCESS", "METRICS_LAST_SUCCESS",
@@ -78,6 +78,11 @@ class Config:
os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00") os.getenv("GEN_MODEL_T0", "2026-01-01T00:00:00+00:00")
) )
) )
model_t_end: datetime | None = field(
default_factory=lambda: _parse_optional_model_timestamp(
os.getenv("GEN_MODEL_T_END")
)
)
model_timezone: str = field( model_timezone: str = field(
default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC") default_factory=lambda: os.getenv("GEN_MODEL_TIMEZONE", "UTC")
) )
@@ -122,3 +127,21 @@ class Config:
raise ValueError("GEN_MODEL_TIME_SPEED must be > 0") raise ValueError("GEN_MODEL_TIME_SPEED must be > 0")
if self.run_mode not in {"live", "backfill"}: if self.run_mode not in {"live", "backfill"}:
raise ValueError("GEN_RUN_MODE must be live or backfill") raise ValueError("GEN_RUN_MODE must be live or backfill")
if self.model_t_end is not None:
object.__setattr__(
self,
"model_t_end",
self.model_t_end.astimezone(timezone.utc),
)
if self.run_mode == "backfill":
if self.model_t_end is None:
raise ValueError("GEN_MODEL_T_END is required for backfill")
if self.model_t_end <= self.model_t0:
raise ValueError("GEN_MODEL_T_END must be after GEN_MODEL_T0")
def _parse_optional_model_timestamp(value: str | None) -> datetime | None:
"""Разбирает необязательную ISO-метку модельного времени."""
if not value:
return None
return _parse_model_timestamp(value)
@@ -181,6 +181,84 @@ class KafkaStateManager:
return None return None
class KafkaStartupHistoryManifest:
"""Хранение манифеста стартовой истории в Kafka compact topic."""
MANIFEST_TOPIC = "generator_startup_history_manifest"
MANIFEST_KEY = "default"
def __init__(self, bootstrap_servers: str):
self.bootstrap_servers = bootstrap_servers
KafkaProducerCls, _ = _kafka_importer()()
logger.info(
f"Connecting to Kafka for startup history manifest 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 startup history manifest successfully")
def save(self, manifest: dict) -> None:
"""Сохраняет манифест стартовой истории."""
def _do_send():
self.producer.send(
self.MANIFEST_TOPIC,
key=self.MANIFEST_KEY,
value=manifest,
)
_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 manifest producer (ignored): {e}")
def load(self) -> dict | None:
"""Загружает последний манифест стартовой истории."""
from kafka import KafkaConsumer
logger.info(f"Loading startup history manifest from topic {self.MANIFEST_TOPIC}")
def _do_load():
consumer = KafkaConsumer(
self.MANIFEST_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_manifest = None
for message in consumer:
if message.key and message.key.decode("utf-8") == self.MANIFEST_KEY:
last_manifest = message.value
consumer.close()
return last_manifest
try:
return _retry(_do_load, max_retries=3, base_delay=0.5)
except Exception as e:
logger.warning(f"Failed to load startup history manifest: {e}")
return None
def ensure_topics(bootstrap_servers: str) -> None: def ensure_topics(bootstrap_servers: str) -> None:
"""Создаёт служебные топики, если их ещё нет.""" """Создаёт служебные топики, если их ещё нет."""
from kafka import KafkaAdminClient from kafka import KafkaAdminClient
@@ -205,8 +283,18 @@ def ensure_topics(bootstrap_servers: str) -> None:
"delete.retention.ms": "100", "delete.retention.ms": "100",
}, },
) )
manifest_topic = NewTopic(
name=KafkaStartupHistoryManifest.MANIFEST_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]: for topic in [history_topic, state_topic, manifest_topic]:
try: try:
admin_client.create_topics([topic]) admin_client.create_topics([topic])
logger.info(f"Created topic: {topic.name}") logger.info(f"Created topic: {topic.name}")
+27 -1
View File
@@ -394,6 +394,24 @@ class TickStreamGenerator:
return tick_batch return tick_batch
def drain_until(
self,
cutoff_at: datetime,
include_boundary: bool = False,
) -> dict[str, list[dict]]:
"""Выпускает созревшие события без рождения новых визитов."""
cutoff_time = _normalize_tick_time(cutoff_at)
tick_batch = _empty_batch()
self._release_due_events(
cutoff_time,
tick_batch,
include_boundary=include_boundary,
)
self._drop_finished_visits()
return tick_batch
def _birth_visits(self, event_budget: int, tick_time: datetime) -> None: def _birth_visits(self, event_budget: int, tick_time: datetime) -> None:
if len(self.active_visits) >= self.generator.config.max_active_sessions: if len(self.active_visits) >= self.generator.config.max_active_sessions:
self._pending_visit_births = 0.0 self._pending_visit_births = 0.0
@@ -436,9 +454,17 @@ class TickStreamGenerator:
self, self,
tick_time: datetime, tick_time: datetime,
tick_batch: dict[str, list[dict]], tick_batch: dict[str, list[dict]],
include_boundary: bool = True,
) -> None: ) -> None:
for visit in self.active_visits: for visit in self.active_visits:
while not visit.is_finished and visit.timestamps[visit.next_index] <= tick_time: while not visit.is_finished:
next_timestamp = visit.timestamps[visit.next_index]
if include_boundary:
is_due = next_timestamp <= tick_time
else:
is_due = next_timestamp < tick_time
if not is_due:
break
event_index = visit.next_index event_index = visit.next_index
for topic in TOPICS: for topic in TOPICS:
tick_batch[topic].append(visit.batch[topic][event_index]) tick_batch[topic].append(visit.batch[topic][event_index])
@@ -1,5 +1,7 @@
"""Основной сервисный цикл генератора.""" """Основной сервисный цикл генератора."""
import hashlib
import json
import logging import logging
import sys import sys
import time import time
@@ -16,6 +18,7 @@ from clickstream_generator.kafka_io import (
KafkaBatchHistory, KafkaBatchHistory,
KafkaPublisher, KafkaPublisher,
KafkaStateManager, KafkaStateManager,
KafkaStartupHistoryManifest,
ensure_topics, ensure_topics,
) )
from clickstream_generator.metrics import ( from clickstream_generator.metrics import (
@@ -29,6 +32,105 @@ from clickstream_generator.runtime import TickStreamGenerator
logger = logging.getLogger("generator") logger = logging.getLogger("generator")
class _TopicManifestStats:
"""Накопительные счётчики одного Kafka-топика для manifest."""
def __init__(self):
self.rows = 0
self.min_event_timestamp: str | None = None
self.max_event_timestamp: str | None = None
self._checksum = hashlib.sha256()
@property
def checksum(self) -> str:
return self._checksum.hexdigest()
def add(self, event: dict, event_timestamp: str | None = None) -> None:
self.rows += 1
self._checksum.update(
json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8")
)
timestamp = event_timestamp if event_timestamp is not None else event.get("event_timestamp")
self.add_timestamp(timestamp)
def add_timestamp(self, timestamp: str | None) -> None:
if timestamp is None:
return
if self.min_event_timestamp is None or timestamp < self.min_event_timestamp:
self.min_event_timestamp = timestamp
if self.max_event_timestamp is None or timestamp > self.max_event_timestamp:
self.max_event_timestamp = timestamp
def to_dict(self) -> dict:
return {
"rows": self.rows,
"min_event_timestamp": self.min_event_timestamp,
"max_event_timestamp": self.max_event_timestamp,
"checksum_sha256": self.checksum,
}
class _ManifestCounters:
"""Счётчики стартовой истории для manifest."""
def __init__(self):
self.topic_stats = {
topic: _TopicManifestStats()
for topic in ("browser_events", "location_events", "device_events", "geo_events")
}
self.click_ids: set[str] = set()
self.user_ids: set[str] = set()
def add_batch(self, batch: dict[str, list[dict]]) -> None:
browser_events = batch.get("browser_events", [])
event_timestamps = {
event["event_id"]: event.get("event_timestamp")
for event in browser_events
if event.get("event_id") and event.get("event_timestamp")
}
click_timestamps: dict[str, list[str]] = {}
for event in browser_events:
click_id = event.get("click_id")
timestamp = event.get("event_timestamp")
if click_id and timestamp:
click_timestamps.setdefault(click_id, []).append(timestamp)
for topic, events in batch.items():
stats = self.topic_stats[topic]
for event in events:
event_timestamp = event.get("event_timestamp")
if event_timestamp is None and topic == "location_events":
event_timestamp = event_timestamps.get(event.get("event_id"))
if event_timestamp is None and topic in ("device_events", "geo_events"):
timestamps = click_timestamps.get(event.get("click_id"))
if timestamps:
event_timestamp = min(timestamps)
stats.add_timestamp(max(timestamps))
stats.add(event, event_timestamp=event_timestamp)
click_id = event.get("click_id")
if topic == "browser_events" and click_id:
self.click_ids.add(click_id)
user_id = event.get("user_domain_id")
if topic == "device_events" and user_id:
self.user_ids.add(user_id)
def to_manifest_topics(self) -> dict:
return {
topic: stats.to_dict()
for topic, stats in self.topic_stats.items()
}
def to_manifest_totals(self) -> dict:
browser_stats = self.topic_stats["browser_events"]
return {
"events": browser_stats.rows,
"visits": len(self.click_ids),
"users": len(self.user_ids),
"min_event_timestamp": browser_stats.min_event_timestamp,
"max_event_timestamp": browser_stats.max_event_timestamp,
}
class GeneratorService: class GeneratorService:
"""Основной сервис генератора.""" """Основной сервис генератора."""
@@ -40,6 +142,7 @@ class GeneratorService:
self.publisher: KafkaPublisher | None = None self.publisher: KafkaPublisher | None = None
self.history: KafkaBatchHistory | None = None self.history: KafkaBatchHistory | None = None
self.state_manager: KafkaStateManager | None = None self.state_manager: KafkaStateManager | None = None
self.manifest_manager: KafkaStartupHistoryManifest | None = None
self._running = False self._running = False
self._tick = 0 self._tick = 0
self._model_time = config.model_t0 self._model_time = config.model_t0
@@ -66,13 +169,42 @@ class GeneratorService:
self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers) self.publisher = KafkaPublisher(self.config.kafka_bootstrap_servers)
self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers) self.history = KafkaBatchHistory(self.config.kafka_bootstrap_servers)
if self.config.run_mode == "backfill" and not self.config.state_enabled:
raise ValueError("GEN_STATE_ENABLED must be true for backfill")
if self.config.state_enabled: if self.config.state_enabled:
self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers) self.state_manager = KafkaStateManager(self.config.kafka_bootstrap_servers)
if self.config.run_mode == "backfill":
self.manifest_manager = KafkaStartupHistoryManifest(
self.config.kafka_bootstrap_servers
)
logger.info("Backfill mode starts from a fresh generator state")
self._run_backfill()
self.stop()
return
if not self.config.state_reset: if not self.config.state_reset:
restored_state = self.state_manager.load() restored_state = self.state_manager.load()
if restored_state: if restored_state:
try: try:
self.manifest_manager = KafkaStartupHistoryManifest(
self.config.kafka_bootstrap_servers
)
manifest = self.manifest_manager.load()
if self._is_startup_history_state(restored_state, manifest):
model_t_end = self._as_aware_utc(
datetime.fromisoformat(manifest["model_t_end"])
)
self.restore_from_startup_history(
restored_state,
model_t_end=model_t_end,
)
elif self._is_startup_history_marker(restored_state):
raise ValueError(
"startup-history state without matching manifest"
)
else:
self._restore_live_state( self._restore_live_state(
restored_state, restored_state,
wall_now_utc=datetime.now(timezone.utc), wall_now_utc=datetime.now(timezone.utc),
@@ -113,6 +245,8 @@ class GeneratorService:
self.history.close() self.history.close()
if self.state_manager: if self.state_manager:
self.state_manager.close() self.state_manager.close()
if self.manifest_manager:
self.manifest_manager.close()
def restore_from_startup_history( def restore_from_startup_history(
self, self,
@@ -169,6 +303,54 @@ class GeneratorService:
"state config mismatch: " + ", ".join(mismatches) "state config mismatch: " + ", ".join(mismatches)
) )
def _is_startup_history_state(self, state, manifest: dict | None) -> bool:
"""Проверяет, что state совпадает со слепком стартовой истории."""
if not manifest or manifest.get("run_mode") != "backfill":
return False
expected_state = manifest.get("state") or {}
if expected_state.get("last_batch_id") != state.last_batch_id:
return False
try:
model_t_end = self._as_aware_utc(
datetime.fromisoformat(manifest["model_t_end"])
)
model_t0 = self._as_aware_utc(
datetime.fromisoformat(manifest["model_t0"])
)
except (KeyError, TypeError, ValueError):
return False
if self._as_aware_utc(state.model_timestamp) != model_t_end:
return False
if self._as_aware_utc(state.model_t0) != model_t0:
return False
if state.gen_seed != manifest.get("gen_seed"):
return False
if state.model_timezone != manifest.get("model_timezone"):
return False
if (
self.config.model_t_end is not None
and self.config.model_t_end != model_t_end
):
return False
if model_t0 != self.config.model_t0:
return False
if manifest.get("gen_seed") != self.config.seed:
return False
if manifest.get("model_timezone") != self.config.model_timezone:
return False
settings = manifest.get("generation_settings") or {}
if abs(state.model_time_speed - settings.get("model_time_speed", -1)) > 1e-9:
return False
return self._generation_settings() == settings
def _is_startup_history_marker(self, state) -> bool:
"""Отличает state стартовой истории от обычного live-state."""
return str(state.last_batch_id).startswith("startup-history-")
@staticmethod @staticmethod
def _as_aware_utc(value: datetime) -> datetime: def _as_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None: if value.tzinfo is None:
@@ -204,6 +386,220 @@ class GeneratorService:
logger.warning(f"Failed to save state: {e}") logger.warning(f"Failed to save state: {e}")
METRICS_ERRORS_TOTAL.labels(topic="state").inc() METRICS_ERRORS_TOTAL.labels(topic="state").inc()
def _run_backfill(self) -> None:
"""Проматывает стартовую историю без сна до GEN_MODEL_T_END."""
if self.config.model_t_end is None:
raise ValueError("GEN_MODEL_T_END is required for backfill")
if not self.publisher:
raise RuntimeError("publisher is not initialized")
if not self.history:
raise RuntimeError("history is not initialized")
logger.info(
"Running backfill from %s to %s",
self.config.model_t0.isoformat(),
self.config.model_t_end.isoformat(),
)
counters = _ManifestCounters()
while self._model_time < self.config.model_t_end:
self._tick += 1
batch_id = f"backfill-{self._tick:08d}"
model_time = self._model_time
started_at = datetime.now(timezone.utc)
events_count = self.generator._calculate_events_count(now=model_time)
batch = self.stream.generate_tick(
events_count,
tick_started_at=model_time,
)
total_sent, sent_counts, status = self._publish_batch(batch)
self._raise_on_backfill_publish_error(batch_id, status, sent_counts)
counters.add_batch(batch)
self._write_batch_history(
batch_id=batch_id,
started_at=started_at,
sent_counts=sent_counts,
total_sent=total_sent,
status=status,
error_message=None if status == "success" else "Backfill publish error",
)
self._advance_model_time()
final_batch = self.stream.drain_until(
self.config.model_t_end,
include_boundary=False,
)
final_sent = 0
final_status = "success"
if any(final_batch.values()):
batch_id = f"backfill-{self._tick + 1:08d}-final"
started_at = datetime.now(timezone.utc)
final_sent, sent_counts, final_status = self._publish_batch(final_batch)
self._raise_on_backfill_publish_error(
batch_id,
final_status,
sent_counts,
)
counters.add_batch(final_batch)
self._write_batch_history(
batch_id=batch_id,
started_at=started_at,
sent_counts=sent_counts,
total_sent=final_sent,
status=final_status,
error_message=None if final_status == "success" else "Backfill publish error",
)
if self.publisher:
self.publisher.flush()
self._model_time = self.config.model_t_end
state_batch_id = self._startup_state_batch_id(counters)
state = self.stream.to_state(
tick=self._tick,
rng_state=self.generator.rng.getstate(),
last_batch_id=state_batch_id,
last_timestamp=self.config.model_t_end,
model_timestamp=self.config.model_t_end,
wall_timestamp=datetime.now(timezone.utc),
model_time_speed=self.config.model_time_speed,
model_timezone=self.config.model_timezone,
model_t0=self.config.model_t0,
gen_seed=self.config.seed,
)
manifest = self._build_startup_history_manifest(
counters=counters,
state=state,
)
if self.manifest_manager:
self.manifest_manager.save(manifest)
self.manifest_manager.flush()
if self.state_manager and self.config.state_enabled:
self.state_manager.save(state)
self.state_manager.flush()
logger.info(
"Backfill completed: events=%s, visits=%s, users=%s, final_sent=%s",
manifest["totals"]["events"],
manifest["totals"]["visits"],
manifest["totals"]["users"],
final_sent,
)
def _raise_on_backfill_publish_error(
self,
batch_id: str,
status: str,
sent_counts: dict[str, dict[str, int]],
) -> None:
"""Останавливает backfill до записи state/manifest при ошибке Kafka."""
if status == "success":
return
raise RuntimeError(
f"Backfill publish failed for batch {batch_id}: "
f"status={status}, sent_counts={sent_counts}"
)
def _publish_batch(
self,
batch: dict[str, list[dict]],
) -> tuple[int, dict[str, dict[str, int]], str]:
"""Публикует batch и возвращает счётчики отправки."""
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"
return total_sent, sent_counts, status
def _write_batch_history(
self,
batch_id: str,
started_at: datetime,
sent_counts: dict[str, dict[str, int]],
total_sent: int,
status: str,
error_message: str | None,
) -> None:
"""Пишет служебную историю batch."""
if not self.history:
return
try:
record = BatchRecord(
batch_id=batch_id,
started_at=started_at,
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=error_message,
)
self.history.add(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()
def _startup_state_batch_id(self, counters) -> str:
digest = counters.topic_stats["browser_events"].checksum[:12]
return f"startup-history-{digest}"
def _build_startup_history_manifest(self, counters, state) -> dict:
return {
"manifest_version": "1.0",
"generated_at": datetime.now(timezone.utc).isoformat(),
"gen_seed": self.config.seed,
"model_t0": self.config.model_t0.isoformat(),
"model_t_end": self.config.model_t_end.isoformat(),
"model_timezone": self.config.model_timezone,
"run_mode": "backfill",
"generation_settings": self._generation_settings(),
"state_version": state.version,
"state": {
"topic": KafkaStateManager.STATE_TOPIC,
"key": KafkaStateManager.STATE_KEY,
"last_batch_id": state.last_batch_id,
"model_timestamp": state.model_timestamp.isoformat(),
},
"topics": counters.to_manifest_topics(),
"totals": counters.to_manifest_totals(),
}
def _generation_settings(self) -> dict:
return {
"tick_seconds": self.config.tick_seconds,
"lambda_base_per_min": self.config.lambda_base_per_min,
"jitter_pct": self.config.jitter_pct,
"min_events_per_tick": self.config.min_events_per_tick,
"max_events_per_tick": self.config.max_events_per_tick,
"max_session_events": self.config.max_session_events,
"max_active_sessions": self.config.max_active_sessions,
"population_max": self.config.population_max,
"p_new_user": self.config.p_new_user,
"min_return_minutes": self.config.min_return_minutes,
"model_time_speed": self.config.model_time_speed,
}
def _main_loop(self): def _main_loop(self):
"""Основной цикл тиков.""" """Основной цикл тиков."""
while self._running: while self._running:
+483
View File
@@ -4,6 +4,8 @@
import logging import logging
import random import random
import hashlib
import json
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from time import sleep as real_sleep from time import sleep as real_sleep
@@ -301,6 +303,463 @@ class TestGeneratorServiceSteadyStream:
assert sum(record.sent_geo for record in history_records) == len(geo_events) assert sum(record.sent_geo for record in history_records) == len(geo_events)
class TestGeneratorServiceBackfill:
"""Проверки режима промотки стартовой истории."""
def test_backfill_publishes_half_open_history_state_and_manifest(
self, base_config
):
"""Backfill пишет [T0, T_end), state на T_end и повторяемый manifest."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=3)
config = replace(
base_config,
run_mode="backfill",
model_t0=model_t0,
model_t_end=model_t_end,
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
state_enabled=True,
)
first = self._run_backfill(config)
second = self._run_backfill(config)
browser_events = first["published"]["browser_events"]
timestamps = [
datetime.fromisoformat(event["event_timestamp"].replace(" ", "T"))
for event in browser_events
]
saved_state = first["state_manager"].save.call_args.args[0]
manifest = first["manifest_manager"].save.call_args.args[0]
assert browser_events
assert min(timestamps) >= model_t0.replace(tzinfo=None)
assert max(timestamps) < model_t_end.replace(tzinfo=None)
assert saved_state.model_timestamp == model_t_end
assert saved_state.last_timestamp == model_t_end
assert manifest["run_mode"] == "backfill"
assert manifest["model_t0"] == model_t0.isoformat()
assert manifest["model_t_end"] == model_t_end.isoformat()
assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id
assert manifest["topics"]["browser_events"]["rows"] == len(browser_events)
for topic in (
"browser_events",
"location_events",
"device_events",
"geo_events",
):
assert manifest["topics"][topic]["min_event_timestamp"] is not None
assert manifest["topics"][topic]["max_event_timestamp"] is not None
assert manifest["totals"]["events"] == len(browser_events)
assert manifest["totals"]["visits"] == len({
event["click_id"]
for event in browser_events
})
assert manifest["totals"]["users"] == len({
event["user_domain_id"]
for event in first["published"]["device_events"]
})
assert first["digest"] == second["digest"]
assert first["manifest_digest"] == second["manifest_digest"]
def test_live_start_uses_startup_manifest_without_wall_delta(
self, base_config, event_dictionary
):
"""Live-запуск из backfill-state стартует с T_end без wall-дельты."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=5)
config = replace(
base_config,
model_t0=model_t0,
tick_seconds=60,
model_time_speed=3600,
)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="startup-history-state",
last_timestamp=model_t_end,
model_timestamp=model_t_end,
wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
manifest = {
"run_mode": "backfill",
"gen_seed": config.seed,
"model_t0": model_t0.isoformat(),
"model_t_end": model_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state": {"last_batch_id": state.last_batch_id},
}
state_manager = MagicMock()
state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = manifest
with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \
patch("clickstream_generator.service.KafkaPublisher"), \
patch("clickstream_generator.service.KafkaBatchHistory"), \
patch(
"clickstream_generator.service.KafkaStateManager",
return_value=state_manager,
), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(config)
service.start()
assert service._model_time == model_t_end
assert service._tick == state.tick
def test_live_start_rejects_startup_manifest_with_different_config_t_end(
self, base_config, event_dictionary
):
"""Manifest от другого T_end не считается стартовой историей запуска."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
manifest_t_end = model_t0 + timedelta(minutes=5)
config_t_end = model_t0 + timedelta(minutes=10)
wall_saved_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
wall_restarted_at = wall_saved_at + timedelta(seconds=30)
config = replace(
base_config,
model_t0=model_t0,
model_t_end=config_t_end,
tick_seconds=60,
model_time_speed=10,
)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="startup-history-state",
last_timestamp=manifest_t_end,
model_timestamp=manifest_t_end,
wall_timestamp=wall_saved_at,
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
manifest = {
"run_mode": "backfill",
"gen_seed": config.seed,
"model_t0": model_t0.isoformat(),
"model_t_end": manifest_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state": {"last_batch_id": state.last_batch_id},
}
state_manager = MagicMock()
state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = manifest
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
if tz is None:
return wall_restarted_at.replace(tzinfo=None)
return wall_restarted_at.astimezone(tz)
with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \
patch("clickstream_generator.service.KafkaPublisher"), \
patch("clickstream_generator.service.KafkaBatchHistory"), \
patch(
"clickstream_generator.service.KafkaStateManager",
return_value=state_manager,
), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch("clickstream_generator.service.datetime", FrozenDateTime), \
patch.object(
GeneratorService,
"restore_from_startup_history",
) as restore_from_startup_history, \
patch.object(
GeneratorService,
"_restore_live_state",
) as restore_live_state, \
patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(config)
service.start()
restore_from_startup_history.assert_not_called()
restore_live_state.assert_not_called()
assert service._tick == 0
assert service._model_time == config.model_t0
def test_live_start_rejects_orphan_startup_state_without_live_restore(
self, base_config, event_dictionary, caplog
):
"""Orphan startup-history state не восстанавливается как live-state."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=5)
config = replace(base_config, model_t0=model_t0, model_time_speed=10)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="startup-history-orphan",
last_timestamp=model_t_end,
model_timestamp=model_t_end,
wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed,
)
state_manager = MagicMock()
state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \
patch("clickstream_generator.service.KafkaPublisher"), \
patch("clickstream_generator.service.KafkaBatchHistory"), \
patch(
"clickstream_generator.service.KafkaStateManager",
return_value=state_manager,
), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(
GeneratorService,
"restore_from_startup_history",
) as restore_from_startup_history, \
patch.object(
GeneratorService,
"_restore_live_state",
) as restore_live_state, \
patch.object(GeneratorService, "_main_loop", return_value=None), \
caplog.at_level(logging.WARNING, logger="generator"):
service = GeneratorService(config)
service.start()
restore_from_startup_history.assert_not_called()
restore_live_state.assert_not_called()
assert service._tick == 0
assert service._model_time == model_t0
assert "startup-history state without matching manifest" in caplog.text
def test_startup_history_state_checks_state_fields(
self, base_config, event_dictionary
):
"""Startup-history state сверяется с manifest/config по полям state."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
model_t_end = model_t0 + timedelta(minutes=5)
config = replace(base_config, model_t0=model_t0, model_time_speed=10)
source_generator = EventGenerator(event_dictionary, config)
source_stream = TickStreamGenerator(source_generator)
source_stream.generate_tick(event_budget=10, tick_started_at=model_t0)
state = source_stream.to_state(
tick=5,
rng_state=source_generator.rng.getstate(),
last_batch_id="startup-history-state",
last_timestamp=model_t_end,
model_timestamp=model_t_end,
wall_timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
model_time_speed=config.model_time_speed,
model_timezone=config.model_timezone,
model_t0=config.model_t0,
gen_seed=config.seed + 1,
)
manifest = {
"run_mode": "backfill",
"gen_seed": config.seed,
"model_t0": model_t0.isoformat(),
"model_t_end": model_t_end.isoformat(),
"model_timezone": config.model_timezone,
"generation_settings": GeneratorService(config)._generation_settings(),
"state": {"last_batch_id": state.last_batch_id},
}
service = GeneratorService(config)
assert not service._is_startup_history_state(state, manifest)
def test_backfill_publish_error_does_not_save_state_or_manifest(
self, base_config
):
"""Backfill не создаёт валидный артефакт при ошибке публикации."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
config = replace(
base_config,
run_mode="backfill",
model_t0=model_t0,
model_t_end=model_t0 + timedelta(minutes=1),
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
state_enabled=True,
)
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = (
lambda topic, events: (len(events), 1)
if topic == "location_events"
else (len(events), 0)
)
service.history = MagicMock()
service.state_manager = MagicMock()
service.manifest_manager = MagicMock()
with pytest.raises(RuntimeError, match="Backfill publish failed"):
service._run_backfill()
service.state_manager.save.assert_not_called()
service.state_manager.flush.assert_not_called()
service.manifest_manager.save.assert_not_called()
service.manifest_manager.flush.assert_not_called()
def test_backfill_manifest_save_error_does_not_save_state(self, base_config):
"""Если manifest не записан, state стартовой истории не сохраняется."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
config = replace(
base_config,
run_mode="backfill",
model_t0=model_t0,
model_t_end=model_t0 + timedelta(minutes=1),
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
state_enabled=True,
)
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = (
lambda topic, events: (len(events), 0)
)
service.publisher.flush.return_value = None
service.history = MagicMock()
service.state_manager = MagicMock()
service.manifest_manager = MagicMock()
service.manifest_manager.save.side_effect = RuntimeError("manifest down")
with pytest.raises(RuntimeError, match="manifest down"):
service._run_backfill()
service.manifest_manager.save.assert_called_once()
service.state_manager.save.assert_not_called()
service.state_manager.flush.assert_not_called()
def test_backfill_manifest_flush_error_does_not_save_state(self, base_config):
"""Если manifest не сброшен в Kafka, state стартовой истории не сохраняется."""
model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
config = replace(
base_config,
run_mode="backfill",
model_t0=model_t0,
model_t_end=model_t0 + timedelta(minutes=1),
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=5,
max_active_sessions=250,
population_max=251,
state_enabled=True,
)
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = (
lambda topic, events: (len(events), 0)
)
service.publisher.flush.return_value = None
service.history = MagicMock()
service.state_manager = MagicMock()
service.manifest_manager = MagicMock()
service.manifest_manager.flush.side_effect = RuntimeError("flush down")
with pytest.raises(RuntimeError, match="flush down"):
service._run_backfill()
service.manifest_manager.save.assert_called_once()
service.manifest_manager.flush.assert_called_once()
service.state_manager.save.assert_not_called()
service.state_manager.flush.assert_not_called()
def _run_backfill(self, config):
service = GeneratorService(config)
service.publisher = MagicMock()
service.publisher.publish.side_effect = (
lambda topic, events: (len(events), 0)
)
service.publisher.flush.return_value = None
service.history = MagicMock()
service.state_manager = MagicMock()
service.manifest_manager = MagicMock()
service._run_backfill()
published = {}
for call in service.publisher.publish.call_args_list:
topic, events = call.args
published.setdefault(topic, []).extend(events)
digest = hashlib.sha256(
json.dumps(published, sort_keys=True, default=str).encode()
).hexdigest()
manifest = service.manifest_manager.save.call_args.args[0]
stable_manifest = {
key: value
for key, value in manifest.items()
if key != "generated_at"
}
manifest_digest = hashlib.sha256(
json.dumps(stable_manifest, sort_keys=True, default=str).encode()
).hexdigest()
return {
"published": published,
"state_manager": service.state_manager,
"manifest_manager": service.manifest_manager,
"digest": digest,
"manifest_digest": manifest_digest,
}
class TestGeneratorServiceStateV2: class TestGeneratorServiceStateV2:
"""Тесты подключения state v2 к сервисному запуску.""" """Тесты подключения state v2 к сервисному запуску."""
@@ -325,6 +784,8 @@ class TestGeneratorServiceStateV2:
state_manager = MagicMock() state_manager = MagicMock()
state_manager.load.return_value = state state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
with patch("clickstream_generator.service.start_http_server"), \ with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \ patch("clickstream_generator.service.ensure_topics"), \
@@ -334,6 +795,10 @@ class TestGeneratorServiceStateV2:
"clickstream_generator.service.KafkaStateManager", "clickstream_generator.service.KafkaStateManager",
return_value=state_manager, return_value=state_manager,
), \ ), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(GeneratorService, "_main_loop", return_value=None): patch.object(GeneratorService, "_main_loop", return_value=None):
service = GeneratorService(base_config) service = GeneratorService(base_config)
@@ -373,6 +838,8 @@ class TestGeneratorServiceStateV2:
) )
state_manager = MagicMock() state_manager = MagicMock()
state_manager.load.return_value = state state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
class FrozenDateTime(datetime): class FrozenDateTime(datetime):
@classmethod @classmethod
@@ -389,6 +856,10 @@ class TestGeneratorServiceStateV2:
"clickstream_generator.service.KafkaStateManager", "clickstream_generator.service.KafkaStateManager",
return_value=state_manager, return_value=state_manager,
), \ ), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch("clickstream_generator.service.datetime", FrozenDateTime), \ patch("clickstream_generator.service.datetime", FrozenDateTime), \
patch.object(GeneratorService, "_main_loop", return_value=None): patch.object(GeneratorService, "_main_loop", return_value=None):
@@ -480,6 +951,8 @@ class TestGeneratorServiceStateV2:
) )
state_manager = MagicMock() state_manager = MagicMock()
state_manager.load.return_value = state state_manager.load.return_value = state
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
with patch("clickstream_generator.service.start_http_server"), \ with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \ patch("clickstream_generator.service.ensure_topics"), \
@@ -489,6 +962,10 @@ class TestGeneratorServiceStateV2:
"clickstream_generator.service.KafkaStateManager", "clickstream_generator.service.KafkaStateManager",
return_value=state_manager, return_value=state_manager,
), \ ), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(GeneratorService, "_main_loop", return_value=None), \ patch.object(GeneratorService, "_main_loop", return_value=None), \
caplog.at_level(logging.WARNING, logger="generator"): caplog.at_level(logging.WARNING, logger="generator"):
@@ -542,6 +1019,8 @@ class TestGeneratorServiceStateV2:
], ],
active_visits=[], active_visits=[],
) )
manifest_manager = MagicMock()
manifest_manager.load.return_value = None
with patch("clickstream_generator.service.start_http_server"), \ with patch("clickstream_generator.service.start_http_server"), \
patch("clickstream_generator.service.ensure_topics"), \ patch("clickstream_generator.service.ensure_topics"), \
@@ -551,6 +1030,10 @@ class TestGeneratorServiceStateV2:
"clickstream_generator.service.KafkaStateManager", "clickstream_generator.service.KafkaStateManager",
return_value=state_manager, return_value=state_manager,
), \ ), \
patch(
"clickstream_generator.service.KafkaStartupHistoryManifest",
return_value=manifest_manager,
), \
patch.object(GeneratorService, "_main_loop", return_value=None), \ patch.object(GeneratorService, "_main_loop", return_value=None), \
caplog.at_level(logging.WARNING, logger="generator"): caplog.at_level(logging.WARNING, logger="generator"):