- Зачем:
- гейт 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 пересекающего визита) валит
гейт прежним сообщением при нулевых непарных счётчиках.
159 lines
13 KiB
Markdown
159 lines
13 KiB
Markdown
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`
|
||
(появление этого гейта).
|