- Зачем:
- гейт generated-history-runtime-check падал через раз: docker compose
stop убивал генератор SIGKILL'ом посреди batch (SIGTERM не ловился),
непарные строки маскировались под «смену фактуры» (задача 20).
- Что:
- генератор грациозно завершается по SIGTERM: текущий batch дописывается
во все топики с flush и записью history; compose даёт минуту grace.
- runtime-check ждёт пересекающий визит в STG (предусловие проверки),
seam-check получил precheck непарных live-строк; в STG-запросах
закреплён 'UTC' против сдвига наивных меток в поясе сервера.
- контрактные тесты усилены, задача 20 закрыта, блокер задачи 13 снят.
- Проверка:
- тесты: 192 passed (generator), 21 passed (контракты);
- стенд: 3 подряд зелёных make generated-history-runtime-check;
красный сценарий (искажение referer_url пересекающего визита) валит
гейт прежним сообщением при нулевых непарных счётчиках.
13 KiB
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).
Судьба гипотез:
- Запасная ветка рождения — опровергнута: ветка уже хранит донора
(
generation.py:196-209, комментарий «Запасная ветка тоже восстановима») и громко падает на неполных locations. Перепроверено координатором по коду. - Недетерминизм live — подтверждена частично: решает не сид, а настенный момент остановки процесса и какие топики успели дописаться.
- Проверочный 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)
- Основное: корректная обработка 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-хендлера окажется недостаточно (с доводом в задаче).
- Отклонено: «ожидание записи
- Дополнительно: precheck в seam-check на непарные
browser/location/device/geo live-строки — чтобы ошибка называла реальную
причину, а не «фактура поменялась». Precheck не подменяет основную
проверку: настоящая смена фактуры внутри
click_idобязана падать как и раньше. - Отклонены любые вероятностные смягчения, а не только «увеличить
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
- Причина расхождения 8/19 найдена и названа (код, не догадка) — см. «Диагноз» выше.
- Генератор корректно завершается по SIGTERM: текущий batch дописывается
во все четыре топика целиком (flush + запись history), потом процесс
выходит; runtime-check дожидается фактической остановки контейнера
(направление фикса, пункт 1). Плюс
stop_grace_period: 1mв compose. - Seam-check различает «непарные live-строки» и «смена фактуры»: precheck называет реальную причину (пункт 2).
- Красный сценарий по-прежнему ловится: контролируемое искажение
referer_urlу события пересекающего визита вdds.eventуронило гейт с прежним сообщением «per-event фактура меняется на стыке: 18/19» при нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12). - Стабильность обоснована структурно (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, исправлены в этой же задаче)
Ревью по чтению кода их поймать не могло — вскрылись только прогонами:
- Гейт жил на побочном эффекте бага. Пересекающие визиты для проверки стыка появлялись только потому, что генератор игнорировал SIGTERM и дописывал ~10 секунд данных до SIGKILL. После фикса live-хвост стал коротким и пересекающих визитов могло не быть вовсе. Фикс: шаг 6 runtime-check ждёт не «любую новую STG-строку», а появления пересекающего визита в STG (детерминированное предусловие проверки, то же окно, что у seam-SQL).
- Часовой пояс в 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
(появление этого гейта).