- Зачем: - финальный review должен видеть согласованные PRD, issue, курс, Superset и архитектурные документы. - Что: - обновлены PRD, чекбоксы закрытых issue и журнал coordinator-loop. - синхронизированы архитектура, карта репозитория, CONTEXT и курс со startup-history-путём. - убраны старые маркеры Superset-геокарты после перехода на Top Countries. - Проверка: - rg-проверки финального review по PRD, issue и Superset-маркерам. - git diff --cached --check.
160 lines
13 KiB
Markdown
160 lines
13 KiB
Markdown
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.
|