Files
clickstream-ch-kafka-supers…/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md
T
ddadmin f8b419d84d docs(generator): закрыты находки финального ревью цепочки
- Зачем:
  - финальный review должен видеть согласованные PRD, issue, курс, Superset и архитектурные документы.
- Что:
  - обновлены PRD, чекбоксы закрытых issue и журнал coordinator-loop.
  - синхронизированы архитектура, карта репозитория, CONTEXT и курс со startup-history-путём.
  - убраны старые маркеры Superset-геокарты после перехода на Top Countries.
- Проверка:
  - rg-проверки финального review по PRD, issue и Superset-маркерам.
  - git diff --cached --check.
2026-07-04 23:09:15 +03:00

160 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Status: done
# Браузерная фактура не сохраняется на стыке восстановления визита
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Что нашли
Визит, переживший стык backfill->live (активен на `T_end` и продолжается живьём),
меняет браузерную фактуру в live-продолжении (фактура — конкретные значения полей
события: какой `browser_name`, user agent, язык стоят в строках). Внутри одного `click_id` человек как
будто «пересел» с одного браузера на другой посреди сессии.
Эмпирика. Чистый стенд, 2 суток backfill + 6 live-тиков за `T_end`,
`GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`,
`GEN_MODEL_T_END=2026-01-03T00:00:00+00:00`, `speed=1`:
- `crossing_visits=22`; `browser_name` отличается у `17/22`, `browser_language`
у `22/22`; каждая сторона внутренне постоянна (`22/22`).
- Чисто-исторические визиты (>=3 событий, целиком до `T_end`): браузер постоянен
у `0/14556`.
- device / os / geo на стыке НЕ расходятся: `0/22` конфликтов на уровне ODS.
- `duplicate_events=0`: дублей на границе нет.
- Пример `click_id`: в истории `Chrome / or_IN`, в live `Firefox / el_CY`.
## Почему это дефект
Нарушает заявленный критерий задач 04 и 05: «визит, переживший восстановление,
остаётся однородным; контекст визита не меняется на стыке и не создаёт второй путь
генерации внутри одного `click_id`». Браузер — часть контекста визита.
Критерий был помечен выполненным, но проверялся неполно: саморевью и ревьюер
смотрели только поля `dds.click` (user / device / geo), которые по одной строке на
`click_id` и потому однородны структурно. Per-event браузерные поля в `dds.event` не
проверялись. Это ровно слепая зона «двух путей генерации», под которую PRD требовал
внешний review gate другой родословной после задачи 5 — а он был выполнен той же
родословной, и дефект прошёл.
## Причина (подтверждена чтением кода, 2026-07-04)
Первоначальная гипотеза «рассинхрон `event_index`» не подтвердилась: восстановление
перебирает визит с нуля, индекс события выровнен правильно (точнее, берётся
`event_index % len` — по модулю длины списка, см. `runtime.py:332`). Разошёлся сам
**источник** фактуры:
- При рождении визита (`generation.py`, `generate_batch`) браузерные строки берутся
у **случайного** click_id из словаря — «донора» (`rng.choice(visit_candidates)`,
затем `browser_by_click_id[base_click_id]`). Кто донор — нигде не сохраняется,
он живёт только в памяти процесса.
- При восстановлении из state (`runtime.py`, `_compact_visit_batch`) фактура
пересобирается из **другого** click_id — того, к которому привязан пользователь
(`browser_by_click_id[user.seed_click_id]`).
Донор и seed — разные click_id, отсюда разные браузеры. Внутри одного click_id
сида браузер практически постоянен (реальный клик одного человека), поэтому
«каждая сторона внутренне постоянна». `browser_language` разошёлся у 22/22
(случайные click_id почти всегда с разными языками), `browser_name`у 17/22
(популярные браузеры иногда совпадают случайно). device и geo не расходятся,
потому что оба пути берут их из `UserProfile`.
Расхождение шире, чем браузер (уточнение по ревью 2026-07-04). Шаблон location
ищется по event_id браузерной строки (`location_by_event_id[...]` в обоих путях),
то есть тоже зависит от того, чей click_id взят источником. Из state
восстанавливаются только `page_url` / `page_url_path`; остальные per-event поля
location — `referer_url`, `referer_medium`, `utm_*` — приходят от источника и на
стыке расходятся так же, как браузер. Эмпирика выше их просто не замеряла.
Тот же `restore_state` -> `_visit_from_state` -> `_compact_visit_batch` работает и
при восстановлении после сбоя (задача 04) — дефект касается и этого пути, не
только стыка backfill->live. Стык и рестарт на уровне генератора — один и тот же
код, различие только в отбрасывании просроченных визитов.
## Масштаб и важность
- Затрагивает только визиты, активные ровно на `T_end` (здесь 22 из 17397 визитов,
~0.13%). Чистый backfill и чистый live — без дефекта.
- Тот же механизм (фактура пересобирается при restore) вероятно затрагивает и
восстановление после сбоя (задача 04), не только backfill->live; проверка задачи 04
его не ловила, потому что смотрела только `dds.click`.
- Для учебного демо урон низкий, но это реальное нарушение стыковой однородности и
сигнал, что браузерная фактура не входит в восстановимое состояние визита.
## Решение по направлению фикса (пользователь, 2026-07-04)
**Хранить донора в state.** В сериализацию визита (`_visit_to_state`) добавить поле
с click_id донора фактуры (`base_click_id`), рядом с `page_url_paths` /
`timestamps`. При восстановлении `_compact_visit_batch` берёт строки из
`browser_by_click_id[base_click_id]` — по уже существующему абсолютному индексу
восстановленная часть точно продолжает выпущенную.
Рассмотренные альтернативы (отклонены):
- Брать браузер от `seed_click_id` пользователя уже при рождении визита — schema
state не меняется и семантика красивее («один человек — один браузер»), но
меняется весь выпуск генератора: контрольные суммы, распределение браузеров,
артефакты пришлось бы перегенерировать. Это уже не багфикс, а смена поведения;
если захочется — отдельная задача.
- Сериализовать браузерные строки визита целиком — надёжно, но раздувает state и
дублирует словарь. Перебор: донор + абсолютный индекс дают тот же результат.
Детали для исполнителя:
- В основной ветке рождения донор покрывает визит целиком: кандидаты фильтруются
по `len(browser_events) >= len(visit_path)` (`generation.py:170`), так что
индекс не выйдет за список строк донора и `% len` становится безобидным
холостым ходом (оставить или убрать — на усмотрение при фиксе).
- **Запасная ветка рождения** (`generation.py:182-185`): если кандидатов нет,
берётся одна случайная браузерная строка и повторяется на весь визит — тогда
«восстановить по донору и индексу» даст другие строки. С реальным сидом ветка,
судя по фильтру, не срабатывает — проверить при фиксе и либо превратить её в
громкую ошибку, либо сериализовать так, чтобы восстановление её воспроизводило.
Молча оставить как есть нельзя.
- Неизвестный `base_click_id` при восстановлении — ошибка (по образцу проверки
`seed_click_id` в `_user_from_state`). Сейчас в `_compact_visit_batch` два
тихих fallback'а (`.get(...)` со словарём целиком для браузера и
`location_events[0]` для location) — для донора их заменить на ошибку, не
копировать.
- **Известное расхождение вне скоупа**: event_id при рождении — случайный uuid4
(`generation.py`, `_new_uuid`), при восстановлении — детерминированный uuid5 от
`click_id:index` (`runtime.py:140-141`). Фикс это не трогает: коллизий нет,
дублей не создаёт. Тест поэтому сравнивает события по всем полям, **кроме
`event_id`** — иначе он падает не из-за нашего бага.
- Расширение схемы state — поднять `STATE_VERSION` (`state.py`) и валидацию
нового поля. Старые state становятся несовместимы: поведение при этом — по
действующему правилу (сейчас — предупреждение и чистый старт; громкий отказ
при намерении продолжить делает задача 07, здесь его не реализовывать).
## Acceptance criteria
- [x] Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state
посреди визита, восстановить и сверить оставшиеся события с продолжением без
рестарта — по **всем** per-event полям, кроме `event_id` (браузерные и
location: referer, utm — не только browser_name; см. «Расхождение шире» выше).
Это один код восстановления для стыка backfill->live и crash-recovery
(задача 04) — одного теста на него достаточно.
- [x] Проверка стыка на стенде расширена per-event полями: на сценарии из «Что
нашли» (2 суток backfill + live за `T_end`, `GEN_SEED=4242`) визиты через стык
однородны в `dds.event` по браузерным полям (`browser_name`,
`browser_language`) **и** полям источника перехода (referer, utm) — 0
расхождений из `crossing_visits`. Запрос/скрипт проверки сохранён как
повторяемый, а не разовый.
- [x] Без регрессий на том же сценарии: `duplicate_events=0`, конфликтов
device / os / geo на уровне ODS по-прежнему 0.
- [x] `STATE_VERSION` поднята, новое поле валидируется; несовместимый старый
state обрабатывается по действующему правилу (см. детали выше).
## Notes
- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация —
из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз:
критерий задач 04/05 проверяли только по `dds.click`.
- Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть
исправлен до того, как на него навешивать доливку.
## Blocked by
- Нет. Это bug по результатам независимой проверки задач 05 и 06.