- Зачем: - нужно зафиксировать состояние трекера после рабочего коммита issue 09. - Что: - задача 09 переведена в done. - журнал coordinator-loop дополнен двумя кругами гейтового ревью и коммит-гейтом. - Проверка: - git diff --cached --check.
13 KiB
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, в liveFirefox / 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
- Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state
посреди визита, восстановить и сверить оставшиеся события с продолжением без
рестарта — по всем per-event полям, кроме
event_id(браузерные и location: referer, utm — не только browser_name; см. «Расхождение шире» выше). Это один код восстановления для стыка backfill->live и crash-recovery (задача 04) — одного теста на него достаточно. - Проверка стыка на стенде расширена per-event полями: на сценарии из «Что
нашли» (2 суток backfill + live за
T_end,GEN_SEED=4242) визиты через стык однородны вdds.eventпо браузерным полям (browser_name,browser_language) и полям источника перехода (referer, utm) — 0 расхождений изcrossing_visits. Запрос/скрипт проверки сохранён как повторяемый, а не разовый. - Без регрессий на том же сценарии:
duplicate_events=0, конфликтов device / os / geo на уровне ODS по-прежнему 0. STATE_VERSIONподнята, новое поле валидируется; несовместимый старый state обрабатывается по действующему правилу (см. детали выше).
Notes
- Рекомендуемый режим ревью по coordinator-loop: гейт (state, сериализация —
из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз:
критерий задач 04/05 проверяли только по
dds.click. - Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть исправлен до того, как на него навешивать доливку.
Blocked by
- Нет. Это bug по результатам независимой проверки задач 05 и 06.