Status: done # Runtime-проверка стыка нестабильна: фактура меняется через раз ## Parent `.scratch/generator-model-time-startup-history/PRD.md` ## Что нашли Два подряд прогона `make generated-history-runtime-check` на одном и том же стенде (2026-07-07, проверка пути менти) дали разный результат: - Прогон 1 (20:18–20:20): **красный** — `Ошибка: per-event фактура меняется на стыке: 8/19` (у 8 из 19 визитов, переживших границу backfill->live, поменялась per-event фактура). При этом `duplicate_events=0`, events=2776. - Прогон 2 (21:5x, та же команда, без изменений кода): **зелёный** — «runtime-проверка startup-history/live seam прошла», конфликтов 0, events=2815. Конфигурация проверки: `GEN_LAUNCH_PROFILE=daily-wave`, `GEN_HISTORY_DURATION=1h`, `GEN_MODEL_T_END=2026-01-01T01:00:00+00:00` (зашита в `scripts/run_generated_history_runtime_check.sh`). ## Почему это важно - Это гейт, которому мы доверяем стык backfill->live (задача 17); флаки-гейт ничего не гарантирует: красный пугает зря, зелёный ничего не доказывает. - Смена фактуры на стыке — класс дефекта задачи 09, который считается закрытым (донор фактуры сохранён в state, тихие fallback'и заменены на ошибки). Либо фикс неполон, либо есть второй источник расхождения. - Задача 13 (глагол next-day) навешивает на этот же механизм цепочку границ — ей нужен доверенный, стабильный гейт. ## Диагноз (Codex, 2026-07-07; ключевой факт перепроверен координатором) **Корень: гонка остановки live-генератора, а не смена фактуры.** Цепочка: `run_generated_history_runtime_check.sh` останавливает live сразу после первых новых STG-строк -> генератор публикует batch по четырём топикам последовательно, без атомарности -> `docker compose stop generator` может оборвать процесс между топиками -> часть browser-событий остаётся без парных location/device/geo -> DDS строит `dds.event` через `LEFT JOIN location`, и такие строки получают `NULL` в referer/utm -> seam-SQL считает это «сменой фактуры внутри click_id». Эмпирика: в красном прогоне `browser_raw=2776`, но `location_raw=2736` — 40 live-событий без пары; в зелёном `browser_raw=location_raw=2815`, поэтому проверка прошла (хотя device/geo и там отстали: 2781). Судьба гипотез: 1. Запасная ветка рождения — **опровергнута**: ветка уже хранит донора (`generation.py:196-209`, комментарий «Запасная ветка тоже восстановима») и громко падает на неполных locations. Перепроверено координатором по коду. 2. Недетерминизм live — **подтверждена частично**: решает не сид, а настенный момент остановки процесса и какие топики успели дописаться. 3. Проверочный SQL — **опровергнута в формулировке**: поля не «легитимно различаются», SQL маскирует пропущенный location под «смену фактуры». Уточнение механизма обрыва (адверсарное ревью постановки, 2026-07-12; перепроверено координатором по коду): - Генератор не обрабатывает SIGTERM: ловится только `KeyboardInterrupt` (`service.py:175`), а `docker compose stop` шлёт именно SIGTERM. В compose у сервиса generator нет `init:`/`stop_signal:`, python работает PID 1 — SIGTERM игнорируется, и через grace-период прилетает SIGKILL: жёсткий обрыв посреди batch, без `finally` и без flush. - Почему отставание device/geo не валит гейт, а location валит: device/geo привязаны к `click_id` (одно значение на визит) — при пропуске весь визит однородно NULL, массив уникальных значений длины 1, проверка проходит; location привязан к `event_id` — частичный пропуск даёт смешанный массив (реальное значение + NULL) и «смену фактуры». Догонять device/geo фикс не обязан — важна граница batch. ## Направление фикса (развилка решена после ревью постановки 2026-07-12) 1. **Основное: корректная обработка SIGTERM в генераторе.** Минимальный хендлер: по SIGTERM выставить `_running = False`, дать текущему tick'у дописаться (все четыре топика + flush + запись batch history), затем штатный `finally`/`stop()`. Runtime-check после `docker compose stop` дожидается фактического завершения контейнера. Так генератор завершается только на границе batch — гонка снята по построению, а не вероятностно. - **Отклонено: «ожидание записи `generator_batch_history` перед stop» как самостоятельный фикс** — запись history подтверждает только ПРОШЕДШИЙ batch (`service.py:586-603`, пишется после цикла публикации); следующий batch к моменту stop уже может быть в полёте, и SIGKILL оборвёт его так же. Довод — ревью постановки, перепроверен по коду. - Bounded live mode (генератор сам останавливается по лимиту) — более тяжёлая альтернатива; в скоуп не входит, возвращаться к ней только если SIGTERM-хендлера окажется недостаточно (с доводом в задаче). 2. **Дополнительно:** precheck в seam-check на непарные browser/location/device/geo live-строки — чтобы ошибка называла реальную причину, а не «фактура поменялась». Precheck не подменяет основную проверку: настоящая смена фактуры внутри `click_id` обязана падать как и раньше. 3. **Отклонены любые вероятностные смягчения**, а не только «увеличить sleep»: settle-sleep перед прогоном, рост `WAIT_LIVE_ROWS`/`LIVE_SECONDS`, сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить вероятность — гонку они не убирают и фиксом не считаются. ## Принятый остаточный риск (решение координатора, 2026-07-12) Ревью отметило: `stop_grace_period: 1m` не ограничивает публикацию по времени — при зависании Kafka дольше минуты SIGKILL всё ещё оборвёт batch. Риск принят: штатный тик публикуется за секунды (минута — многократный запас), а ограничивать публикацию таймером значило бы рвать batch уже по построению. Ключевое отличие от исходного флаки: такой обрыв больше не маскируется под «смену фактуры» — precheck непарных строк назовёт его явно, гейт упадёт громко и честно. ## Acceptance criteria - [x] Причина расхождения 8/19 найдена и названа (код, не догадка) — см. «Диагноз» выше. - [x] Генератор корректно завершается по SIGTERM: текущий batch дописывается во все четыре топика целиком (flush + запись history), потом процесс выходит; runtime-check дожидается фактической остановки контейнера (направление фикса, пункт 1). Плюс `stop_grace_period: 1m` в compose. - [x] Seam-check различает «непарные live-строки» и «смена фактуры»: precheck называет реальную причину (пункт 2). - [x] Красный сценарий по-прежнему ловится: контролируемое искажение `referer_url` у события пересекающего визита в `dds.event` уронило гейт с прежним сообщением «per-event фактура меняется на стыке: 18/19» при нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12). - [x] Стабильность обоснована структурно (SIGTERM ставит флаг, тик дописывает все четыре топика + flush + history и выходит на границе batch — тест `test_sigterm_during_publish_finishes_current_batch`); дымовая проверка поверх довода: 3 подряд зелёных `make generated-history-runtime-check` (2026-07-12, прогоны 19:25, 19:28, 19:30). ## Находки стендовой приёмки (2026-07-12, исправлены в этой же задаче) Ревью по чтению кода их поймать не могло — вскрылись только прогонами: 1. **Гейт жил на побочном эффекте бага.** Пересекающие визиты для проверки стыка появлялись только потому, что генератор игнорировал SIGTERM и дописывал ~10 секунд данных до SIGKILL. После фикса live-хвост стал коротким и пересекающих визитов могло не быть вовсе. Фикс: шаг 6 runtime-check ждёт не «любую новую STG-строку», а появления пересекающего визита в STG (детерминированное предусловие проверки, то же окно, что у seam-SQL). 2. **Часовой пояс в STG-запросах.** Сырые `event_timestamp` наивные и означают UTC, сервер ClickHouse — Europe/Moscow: сравнение с границей, заданной с `+00:00`, уезжало на 3 часа, precheck был зелёным вакуумно (не видел ни одной live-строки). Фикс: явный `'UTC'` в `parseDateTime64BestEffort*` с обеих сторон сравнения (4 места), закреплено контрактными тестами. ## Blocked by - Нет. Диагноз можно начинать сразу; стенд воспроизводит через раз. Связано: `09-seam-browser-fixture-not-preserved.md` (класс дефекта и решение про донора), `13-backfill-top-up-from-snapshot.md` (нуждается в доверенном гейте на цепочке границ), `17-trusted-checks-startup-history-superset.md` (появление этого гейта).