fix(generator): устранён флаки runtime-гейта стыка backfill/live

- Зачем:
  - гейт 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 пересекающего визита) валит
    гейт прежним сообщением при нулевых непарных счётчиках.
This commit is contained in:
2026-07-12 19:33:28 +03:00
parent 55a3feb63d
commit dab120a07e
9 changed files with 433 additions and 30 deletions
@@ -81,8 +81,9 @@ Status: ready-for-agent
## Blocked by ## Blocked by
- `20-flaky-runtime-seam-check.md` — доверенный стабильный гейт стыка нужен - Нет. Последний блокер снят 2026-07-12: `20-flaky-runtime-seam-check.md`
до навешивания на него цепочки границ; фикс 20 идёт первым. закрыта, гейт стыка стабилен (3 подряд зелёных прогона, красный сценарий
ловится).
- Исторические блокеры закрыты: `15-world-boundary-after-cross-review.md` - Исторические блокеры закрыты: `15-world-boundary-after-cross-review.md`
(граница миров) и `17-trusted-checks-startup-history-superset.md` (граница миров) и `17-trusted-checks-startup-history-superset.md`
(доверенные проверки) — done. (доверенные проверки) — done.
@@ -1,4 +1,4 @@
Status: ready-for-agent Status: done
# Runtime-проверка стыка нестабильна: фактура меняется через раз # Runtime-проверка стыка нестабильна: фактура меняется через раз
@@ -99,24 +99,54 @@ location/device/geo -> DDS строит `dds.event` через `LEFT JOIN locati
сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить
вероятность — гонку они не убирают и фиксом не считаются. вероятность — гонку они не убирают и фиксом не считаются.
## Принятый остаточный риск (решение координатора, 2026-07-12)
Ревью отметило: `stop_grace_period: 1m` не ограничивает публикацию по
времени — при зависании Kafka дольше минуты SIGKILL всё ещё оборвёт batch.
Риск принят: штатный тик публикуется за секунды (минута — многократный
запас), а ограничивать публикацию таймером значило бы рвать batch уже по
построению. Ключевое отличие от исходного флаки: такой обрыв больше не
маскируется под «смену фактуры» — precheck непарных строк назовёт его
явно, гейт упадёт громко и честно.
## Acceptance criteria ## Acceptance criteria
- [x] Причина расхождения 8/19 найдена и названа (код, не догадка) — - [x] Причина расхождения 8/19 найдена и названа (код, не догадка) —
см. «Диагноз» выше. см. «Диагноз» выше.
- [ ] Генератор корректно завершается по SIGTERM: текущий batch дописывается - [x] Генератор корректно завершается по SIGTERM: текущий batch дописывается
во все четыре топика целиком (flush + запись history), потом процесс во все четыре топика целиком (flush + запись history), потом процесс
выходит; runtime-check дожидается фактической остановки контейнера выходит; runtime-check дожидается фактической остановки контейнера
(направление фикса, пункт 1). (направление фикса, пункт 1). Плюс `stop_grace_period: 1m` в compose.
- [ ] Seam-check различает «непарные live-строки» и «смена фактуры»: - [x] Seam-check различает «непарные live-строки» и «смена фактуры»:
precheck называет реальную причину (пункт 2). precheck называет реальную причину (пункт 2).
- [ ] Красный сценарий по-прежнему ловится: настоящая смена per-event - [x] Красный сценарий по-прежнему ловится: контролируемое искажение
фактуры внутри `click_id` (инъекция в тесте или контролируемое искажение `referer_url` у события пересекающего визита в `dds.event` уронило гейт
данных) валит гейт с прежним сообщением — precheck и фикс не сделали с прежним сообщением «per-event фактура меняется на стыке: 18/19» при
проверку мягче. нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12).
- [ ] Стабильность обоснована структурно (гонка снята по построению: - [x] Стабильность обоснована структурно (SIGTERM ставит флаг, тик
завершение только на границе batch), а не статистикой прогонов; дописывает все четыре топика + flush + history и выходит на границе
`make generated-history-runtime-check` — N подряд зелёных (N >= 3) 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 ## Blocked by
+2
View File
@@ -332,6 +332,8 @@ services:
depends_on: depends_on:
- kafka - kafka
restart: unless-stopped restart: unless-stopped
# SIGTERM останавливает генератор на границе пакета; минута нужна на сброс буфера Kafka.
stop_grace_period: 1m
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"] test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
interval: 30s interval: 30s
+6 -3
View File
@@ -220,9 +220,12 @@ CHECK_LIVE_SEAM=1 GEN_LIVE_CHECK_MINUTES=10 make generated-history-check
make generated-history-runtime-check make generated-history-runtime-check
``` ```
По умолчанию он берёт профиль `daily-wave`, но сжимает историю до `1h`, ждёт По умолчанию он берёт профиль `daily-wave`, но сжимает историю до `1h` и до
новые STG-строки от live до 25 секунд, делает второй batch и проверяет стык с 25 секунд ждёт в STG визит с browser-событиями по обе стороны границы. Затем
`CHECK_LIVE_SEAM=1`. он делает второй batch и проверяет стык с `CHECK_LIVE_SEAM=1`.
При остановке generator получает SIGTERM и завершает текущий пакет. Параметр
`stop_grace_period: 1m` даёт время дописать четыре топика, сбросить буферы и
записать историю пакета до принудительной остановки контейнера.
Если машина медленная, можно увеличить только ожидания: Если машина медленная, можно увеличить только ожидания:
```bash ```bash
+15 -2
View File
@@ -1,7 +1,9 @@
"""Основной сервисный цикл генератора.""" """Основной сервисный цикл генератора."""
import logging import logging
import signal
import sys import sys
import threading
import time import time
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -54,6 +56,8 @@ class GeneratorService:
self.state_manager: KafkaStateManager | None = None self.state_manager: KafkaStateManager | None = None
self.manifest_manager: KafkaStartupHistoryManifest | None = None self.manifest_manager: KafkaStartupHistoryManifest | None = None
self._running = False self._running = False
self._stop_requested = False
self._shutdown_event = threading.Event()
self._tick = 0 self._tick = 0
self._model_time = config.model_t0 self._model_time = config.model_t0
@@ -168,7 +172,7 @@ class GeneratorService:
else: else:
logger.info("State management disabled") logger.info("State management disabled")
self._running = True self._running = not self._stop_requested
try: try:
self._main_loop() self._main_loop()
@@ -181,6 +185,7 @@ class GeneratorService:
"""Останавливает сервис.""" """Останавливает сервис."""
logger.info("Stopping generator service...") logger.info("Stopping generator service...")
self._running = False self._running = False
self._shutdown_event.set()
if self.publisher: if self.publisher:
self.publisher.close() self.publisher.close()
if self.history: if self.history:
@@ -190,6 +195,13 @@ class GeneratorService:
if self.manifest_manager: if self.manifest_manager:
self.manifest_manager.close() self.manifest_manager.close()
def request_stop(self, signum, _frame) -> None:
"""Просит завершить сервис после текущего batch."""
logger.info("Received shutdown signal %s", signum)
self._stop_requested = True
self._running = False
self._shutdown_event.set()
def restore_from_startup_history( def restore_from_startup_history(
self, self,
state, state,
@@ -642,7 +654,7 @@ class GeneratorService:
sleep_time = max(0, self.config.tick_seconds - elapsed) sleep_time = max(0, self.config.tick_seconds - elapsed)
if sleep_time > 0: if sleep_time > 0:
logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick") logger.debug(f"Sleeping for {sleep_time:.1f}s until next tick")
time.sleep(sleep_time) self._shutdown_event.wait(sleep_time)
def _advance_model_time(self) -> None: def _advance_model_time(self) -> None:
"""Сдвигает модельное время после успешного live-тика.""" """Сдвигает модельное время после успешного live-тика."""
@@ -656,6 +668,7 @@ def main():
try: try:
config = Config() config = Config()
service = GeneratorService(config) service = GeneratorService(config)
signal.signal(signal.SIGTERM, service.request_stop)
service.start() service.start()
except Exception as e: except Exception as e:
logger.exception(f"Fatal error: {e}") logger.exception(f"Fatal error: {e}")
+82 -4
View File
@@ -6,6 +6,7 @@ import logging
import random import random
import hashlib import hashlib
import json import json
import signal
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from time import sleep as real_sleep from time import sleep as real_sleep
@@ -21,6 +22,7 @@ from generator import (
GeneratorState, GeneratorState,
KafkaBatchHistory, KafkaBatchHistory,
TickStreamGenerator, TickStreamGenerator,
main,
) )
from clickstream_generator.state import UnsupportedStateVersionError from clickstream_generator.state import UnsupportedStateVersionError
@@ -210,8 +212,9 @@ class TestGeneratorServiceSteadyStream:
service._running = False service._running = False
with caplog.at_level(logging.INFO, logger="generator"), \ with caplog.at_level(logging.INFO, logger="generator"), \
patch( patch.object(
"clickstream_generator.service.time.sleep", service._shutdown_event,
"wait",
side_effect=stop_after_first_tick, side_effect=stop_after_first_tick,
): ):
service._main_loop() service._main_loop()
@@ -221,6 +224,77 @@ class TestGeneratorServiceSteadyStream:
assert "completed:" not in caplog.text assert "completed:" not in caplog.text
assert "sent" not in caplog.text assert "sent" not in caplog.text
def test_sigterm_during_publish_finishes_current_batch(self, base_config):
"""SIGTERM посреди публикации не обрывает связанные топики batch."""
config = replace(
base_config,
tick_seconds=60,
lambda_base_per_min=600,
jitter_pct=0,
min_events_per_tick=1,
max_events_per_tick=1000,
max_session_events=3,
metrics_enabled=False,
state_enabled=False,
)
service = GeneratorService(config)
publisher = MagicMock()
history = MagicMock()
published_topics = []
def publish_and_request_stop(topic, events):
published_topics.append(topic)
if len(published_topics) == 1:
service.request_stop(signal.SIGTERM, None)
return len(events), 0
publisher.publish.side_effect = publish_and_request_stop
with patch(
"clickstream_generator.service.start_http_server",
side_effect=AssertionError("тест не должен открывать порт метрик"),
), \
patch("clickstream_generator.service.ensure_topics"), \
patch(
"clickstream_generator.service.KafkaPublisher",
return_value=publisher,
), \
patch(
"clickstream_generator.service.KafkaBatchHistory",
return_value=history,
), \
patch.object(
service.generator,
"_calculate_events_count",
return_value=int(EXPECTED_VISIT_EVENTS),
):
service.start()
assert published_topics == [
"browser_events",
"location_events",
"device_events",
"geo_events",
]
publisher.flush.assert_called_once_with()
history.add.assert_called_once()
history.flush.assert_called_once_with()
publisher.close.assert_called_once_with()
history.close.assert_called_once_with()
assert service._tick == 1
def test_main_connects_sigterm_to_graceful_stop(self, base_config):
"""Точка входа передаёт SIGTERM сервису как штатный запрос остановки."""
service = MagicMock()
with patch("clickstream_generator.service.Config", return_value=base_config), \
patch("clickstream_generator.service.GeneratorService", return_value=service), \
patch("clickstream_generator.service.signal.signal") as register_signal:
main()
register_signal.assert_called_once_with(signal.SIGTERM, service.request_stop)
service.start.assert_called_once_with()
def _run_service_ticks(self, config, wall_now: datetime, ticks_count: int): def _run_service_ticks(self, config, wall_now: datetime, ticks_count: int):
service = GeneratorService(config) service = GeneratorService(config)
service.publisher = MagicMock() service.publisher = MagicMock()
@@ -256,7 +330,11 @@ class TestGeneratorServiceSteadyStream:
"_calculate_events_count", "_calculate_events_count",
side_effect=calculate_events_count, side_effect=calculate_events_count,
), \ ), \
patch("clickstream_generator.service.time.sleep", side_effect=stop_after_tick): patch.object(
service._shutdown_event,
"wait",
side_effect=stop_after_tick,
):
service._main_loop() service._main_loop()
published = {} published = {}
@@ -294,7 +372,7 @@ class TestGeneratorServiceSteadyStream:
service.generator, service.generator,
"_visit_pause_seconds", "_visit_pause_seconds",
return_value=0.05, return_value=0.05,
), patch("clickstream_generator.service.time.sleep") as sleep_mock: ), patch.object(service._shutdown_event, "wait") as sleep_mock:
sleep_mock.side_effect = stop_after_second_tick sleep_mock.side_effect = stop_after_second_tick
service._main_loop() service._main_loop()
+108
View File
@@ -335,6 +335,114 @@ if [[ "${should_check_live_seam}" == "1" ]]; then
echo "" echo ""
echo "=== Проверка ClickHouse: стык backfill/live в DDS ===" echo "=== Проверка ClickHouse: стык backfill/live в DDS ==="
live_pairing_query="
WITH
parseDateTime64BestEffort('${manifest_model_t_end}', 6, 'UTC') AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end,
live_browser AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id
FROM stg.browser_raw
WHERE parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
) >= t_end
AND parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
) < t_live_end
AND event_id IS NOT NULL
AND click_id IS NOT NULL
GROUP BY event_id, click_id
),
live_click_ids AS (
SELECT click_id
FROM live_browser
GROUP BY click_id
),
browser_counts AS (
SELECT click_id, count() AS browser_rows
FROM
(
SELECT
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id
FROM stg.browser_raw
WHERE event_id IS NOT NULL AND click_id IS NOT NULL
GROUP BY event_id, click_id
)
WHERE click_id IN (SELECT click_id FROM live_click_ids)
GROUP BY click_id
),
location_ids AS (
SELECT toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id
FROM stg.location_raw
WHERE event_id IS NOT NULL
GROUP BY event_id
),
device_counts AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
count() AS device_rows
FROM stg.device_raw
WHERE click_id IS NOT NULL
AND click_id IN (SELECT click_id FROM live_click_ids)
GROUP BY click_id
),
geo_counts AS (
SELECT
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
count() AS geo_rows
FROM stg.geo_raw
WHERE click_id IS NOT NULL
AND click_id IN (SELECT click_id FROM live_click_ids)
GROUP BY click_id
)
SELECT
(SELECT count() FROM live_browser) AS live_browser_rows,
(
SELECT count()
FROM live_browser AS b
LEFT JOIN location_ids AS l ON l.event_id = b.event_id
WHERE l.event_id IS NULL
) AS unpaired_location_rows,
(
SELECT ifNull(sum(if(
b.browser_rows > ifNull(d.device_rows, 0),
b.browser_rows - ifNull(d.device_rows, 0),
0
)), 0)
FROM browser_counts AS b
LEFT JOIN device_counts AS d ON d.click_id = b.click_id
) AS unpaired_device_rows,
(
SELECT ifNull(sum(if(
b.browser_rows > ifNull(g.geo_rows, 0),
b.browser_rows - ifNull(g.geo_rows, 0),
0
)), 0)
FROM browser_counts AS b
LEFT JOIN geo_counts AS g ON g.click_id = b.click_id
) AS unpaired_geo_rows
SETTINGS join_use_nulls = 1
FORMAT TabSeparated"
live_pairing="$(ch_query "${live_pairing_query}")"
IFS=$'\t' read -r live_browser_rows unpaired_location_rows unpaired_device_rows unpaired_geo_rows <<< "${live_pairing}"
[[ "${live_browser_rows}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_location_rows}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_device_rows}" =~ ^[0-9]+$ ]] \
&& [[ "${unpaired_geo_rows}" =~ ^[0-9]+$ ]] \
|| fail "не удалось прочитать пары live-строк"
if (( unpaired_location_rows > 0 || unpaired_device_rows > 0 || unpaired_geo_rows > 0 )); then
fail "непарные live-строки: location=${unpaired_location_rows}, device=${unpaired_device_rows}, geo=${unpaired_geo_rows} из browser=${live_browser_rows}"
fi
echo "live_browser_rows=${live_browser_rows}"
echo "unpaired_location_rows=${unpaired_location_rows}"
echo "unpaired_device_rows=${unpaired_device_rows}"
echo "unpaired_geo_rows=${unpaired_geo_rows}"
seam_duplicates_query=" seam_duplicates_query="
WITH WITH
toDateTime64('${CH_MODEL_T0}', 6) AS t0, toDateTime64('${CH_MODEL_T0}', 6) AS t0,
+46 -4
View File
@@ -57,9 +57,47 @@ SELECT
FORMAT TabSeparated" FORMAT TabSeparated"
} }
stg_crossing_visits() {
${COMPOSE_BIN} exec -T clickhouse clickhouse-client \
--user=default \
--password=123456 \
--query "
WITH
parseDateTime64BestEffort('${GEN_MODEL_T_END}', 6, 'UTC') AS t_end,
t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE AS t_live_end,
browser AS (
SELECT
JSONExtractString(raw, 'click_id') AS click_id,
parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
) AS event_ts
FROM stg.browser_raw
WHERE click_id != ''
AND event_ts >= t_end - INTERVAL 30 MINUTE
AND event_ts < t_live_end
)
SELECT count()
FROM
(
SELECT
click_id,
min(event_ts) AS first_ts,
max(event_ts) AS last_ts
FROM browser
GROUP BY click_id
HAVING first_ts < t_end AND last_ts >= t_end
)
FORMAT TabSeparated"
}
cleanup_live_generator() { cleanup_live_generator() {
${COMPOSE_BIN} stop generator >/dev/null 2>&1 || true ${COMPOSE_BIN} stop generator >/dev/null 2>&1 || true
} }
stop_live_generator() {
# docker compose stop возвращается только после фактической остановки контейнера.
${COMPOSE_BIN} stop generator
}
trap cleanup_live_generator EXIT trap cleanup_live_generator EXIT
echo "=== Быстрая runtime-проверка startup-history/live seam ===" echo "=== Быстрая runtime-проверка startup-history/live seam ==="
@@ -103,7 +141,7 @@ sleep "${WAIT_STG_SECONDS}"
bash "${SCRIPT_DIR}/run_batch.sh" bash "${SCRIPT_DIR}/run_batch.sh"
stg_rows_before_live="$(stg_total_rows)" stg_rows_before_live="$(stg_total_rows)"
echo "Шаг 6: live-продолжение, ждём новые STG-строки до ${LIVE_SECONDS} сек." echo "Шаг 6: live-продолжение, ждём переходящий визит в STG до ${LIVE_SECONDS} сек."
GEN_RUN_MODE=live \ GEN_RUN_MODE=live \
GEN_STATE_RESET=false \ GEN_STATE_RESET=false \
GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \ GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \
@@ -120,19 +158,23 @@ GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \
${COMPOSE_BIN} up -d --build generator ${COMPOSE_BIN} up -d --build generator
deadline=$((SECONDS + LIVE_SECONDS)) deadline=$((SECONDS + LIVE_SECONDS))
while true; do while true; do
crossing_visits="$(stg_crossing_visits)"
[[ "${crossing_visits}" =~ ^[0-9]+$ ]] \
|| { echo "Ошибка: не удалось прочитать переходящие визиты из STG." >&2; exit 1; }
if (( crossing_visits > 0 )); then
stg_rows_after_live="$(stg_total_rows)" stg_rows_after_live="$(stg_total_rows)"
if (( stg_rows_after_live > stg_rows_before_live )); then
echo "live_stg_rows_before=${stg_rows_before_live}" echo "live_stg_rows_before=${stg_rows_before_live}"
echo "live_stg_rows_after=${stg_rows_after_live}" echo "live_stg_rows_after=${stg_rows_after_live}"
echo "live_stg_crossing_visits=${crossing_visits}"
break break
fi fi
if (( SECONDS >= deadline )); then if (( SECONDS >= deadline )); then
echo "Ошибка: live-продолжение не записало новые STG-строки за ${LIVE_SECONDS} сек." >&2 echo "Ошибка: live-продолжение не создало переходящий визит в STG за ${LIVE_SECONDS} сек." >&2
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
cleanup_live_generator stop_live_generator
echo "Шаг 7: второй batch после live" echo "Шаг 7: второй batch после live"
sleep "${WAIT_STG_SECONDS}" sleep "${WAIT_STG_SECONDS}"
@@ -3,9 +3,23 @@ import os
import subprocess import subprocess
import textwrap import textwrap
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_generated_analytics.sh" CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_generated_analytics.sh"
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
OPERATIONS_DOC = REPO_ROOT / "docs" / "OPERATIONS.md"
def _required_runtime_line(script, command):
matches = [
line_number
for line_number, line in enumerate(script.splitlines())
if line == command
]
assert len(matches) == 1, f"{command} должна быть отдельной командой ровно один раз"
return matches[0]
def _fake_compose( def _fake_compose(
@@ -13,6 +27,8 @@ def _fake_compose(
*, *,
manifest_profile="ci", manifest_profile="ci",
live_rows="3", live_rows="3",
live_pairing="3\t0\t0\t0",
seam_context="19\t19\t19",
views_mode="ok", views_mode="ok",
): ):
fake = tmp_path / "docker-compose" fake = tmp_path / "docker-compose"
@@ -53,8 +69,10 @@ def _fake_compose(
printf '%s\\n' '{live_rows}' printf '%s\\n' '{live_rows}'
elif [[ "$query" == *"uniqExact(event_id)"* ]]; then elif [[ "$query" == *"uniqExact(event_id)"* ]]; then
printf '%s\\n' '16200\t16200\t0' printf '%s\\n' '16200\t16200\t0'
elif [[ "$query" == *"unpaired_location_rows"* ]]; then
printf '%s\\n' '{live_pairing}'
elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then
printf '%s\\n' '19\t19\t19' printf '%s\\n' '{seam_context}'
elif [[ "$query" == *"ods_device_rows"* ]]; then elif [[ "$query" == *"ods_device_rows"* ]]; then
printf '%s\\n' '19\t19\t19\t0\t0' printf '%s\\n' '19\t19\t19\t0\t0'
elif [[ "$query" == *"SELECT source, rows"* ]]; then elif [[ "$query" == *"SELECT source, rows"* ]]; then
@@ -165,6 +183,71 @@ def test_generated_history_check_requires_live_rows_when_seam_is_required(tmp_pa
assert "live-продолжение не записало строки" in result.stderr assert "live-продолжение не записало строки" in result.stderr
def test_generated_history_check_names_unpaired_live_rows(tmp_path):
"""Неполный live-batch получает отдельную ошибку до проверки фактуры."""
fake_compose = _fake_compose(
tmp_path,
live_pairing="3\t1\t0\t0",
seam_context="19\t18\t19",
)
result = _run_generated_history_check(
tmp_path,
fake_compose=fake_compose,
PROFILE="ci",
CHECK_LIVE_SEAM="1",
)
assert result.returncode != 0
assert "непарные live-строки: location=1, device=0, geo=0 из browser=3" in result.stderr
assert "per-event фактура меняется на стыке" not in result.stderr
def test_live_pairing_precheck_counts_device_and_geo_messages_in_stg():
"""Старая ODS-строка click_id не скрывает пропуск live device/geo."""
script = CHECK_SCRIPT.read_text(encoding="utf-8")
live_pairing_query = script.split('live_pairing_query="', 1)[1].split(
'FORMAT TabSeparated"',
1,
)[0]
assert "FROM stg.browser_raw" in live_pairing_query
assert "FROM stg.location_raw" in live_pairing_query
assert "FROM stg.device_raw" in live_pairing_query
assert "FROM stg.geo_raw" in live_pairing_query
assert "browser_rows - ifNull(d.device_rows, 0)" in live_pairing_query
assert "browser_rows - ifNull(g.geo_rows, 0)" in live_pairing_query
assert "FROM ods.device_by_click" not in live_pairing_query
assert "FROM ods.geo_by_click" not in live_pairing_query
assert (
"parseDateTime64BestEffort('${manifest_model_t_end}', 6, 'UTC') AS t_end"
in live_pairing_query
)
assert live_pairing_query.count(
"JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'"
) == 2
def test_generated_history_check_still_rejects_real_per_event_change(tmp_path):
"""Полные пары не скрывают настоящую смену per-event фактуры."""
fake_compose = _fake_compose(
tmp_path,
live_pairing="3\t0\t0\t0",
seam_context="19\t18\t19",
)
result = _run_generated_history_check(
tmp_path,
fake_compose=fake_compose,
PROFILE="ci",
CHECK_LIVE_SEAM="1",
)
assert result.returncode != 0
assert "per-event фактура меняется на стыке: 18/19" in result.stderr
assert "непарные live-строки" not in result.stderr
def test_generated_history_check_fails_when_dm_views_query_fails(tmp_path): def test_generated_history_check_fails_when_dm_views_query_fails(tmp_path):
"""Ошибка запроса DM-витрин не превращается в пустой успешный цикл.""" """Ошибка запроса DM-витрин не превращается в пустой успешный цикл."""
fake_compose = _fake_compose(tmp_path, views_mode="fail") fake_compose = _fake_compose(tmp_path, views_mode="fail")
@@ -201,14 +284,57 @@ def test_runtime_gate_has_bounded_daily_wave_live_seam_path():
assert 'PROFILE="${PROFILE:-daily-wave}"' in script assert 'PROFILE="${PROFILE:-daily-wave}"' in script
assert 'GEN_HISTORY_DURATION="${GEN_HISTORY_DURATION:-1h}"' in script assert 'GEN_HISTORY_DURATION="${GEN_HISTORY_DURATION:-1h}"' in script
assert 'LIVE_SECONDS="${LIVE_SECONDS:-25}"' in script assert 'LIVE_SECONDS="${LIVE_SECONDS:-25}"' in script
assert "stg_rows_after_live > stg_rows_before_live" in script assert "stg_crossing_visits()" in script
assert "crossing_visits=\"$(stg_crossing_visits)\"" in script
assert "crossing_visits > 0" in script
assert "t_end - INTERVAL 30 MINUTE" in script
assert "t_end + INTERVAL ${GEN_LIVE_CHECK_MINUTES} MINUTE" in script
assert "HAVING first_ts < t_end AND last_ts >= t_end" in script
assert (
"parseDateTime64BestEffort('${GEN_MODEL_T_END}', 6, 'UTC') AS t_end"
in script
)
assert "JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'" in script
assert "stg_rows_after_live > stg_rows_before_live" not in script
assert "не создало переходящий визит" in script
assert "GEN_RUN_MODE=live" in script assert "GEN_RUN_MODE=live" in script
assert "GEN_STATE_RESET=false" in script assert "GEN_STATE_RESET=false" in script
assert "up -d --build generator" in script assert "up -d --build generator" in script
stop_call_line = _required_runtime_line(script, "stop_live_generator")
step_7_line = _required_runtime_line(
script,
'echo "Шаг 7: второй batch после live"',
)
assert stop_call_line < step_7_line
assert "CHECK_LIVE_SEAM=1" in script assert "CHECK_LIVE_SEAM=1" in script
assert "REQUIRE_SUPERSET=0" in script assert "REQUIRE_SUPERSET=0" in script
def test_runtime_gate_contract_rejects_removed_stop_call():
"""Определение функции не скрывает удалённый вызов перед вторым batch."""
script = (
REPO_ROOT / "scripts" / "run_generated_history_runtime_check.sh"
).read_text(encoding="utf-8")
script_without_call = script.replace("\nstop_live_generator\n", "\n", 1)
assert "stop_live_generator" in script_without_call
with pytest.raises(AssertionError, match="отдельной командой"):
_required_runtime_line(script_without_call, "stop_live_generator")
def test_generator_shutdown_grace_covers_slow_batch_and_is_documented():
"""Compose даёт текущему batch минуту на завершение после SIGTERM."""
compose = COMPOSE_FILE.read_text(encoding="utf-8")
operations = OPERATIONS_DOC.read_text(encoding="utf-8")
generator_service = compose.split("\n generator:\n", 1)[1].split(
"\n kafka-exporter:\n",
1,
)[0]
assert "stop_grace_period: 1m" in generator_service
assert "stop_grace_period: 1m" in operations
def test_generated_history_check_rejects_empty_ods_context(): def test_generated_history_check_rejects_empty_ods_context():
"""ODS-блок стыка падает, если в ODS нет строк для переходящих визитов.""" """ODS-блок стыка падает, если в ODS нет строк для переходящих визитов."""
script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text( script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text(