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:
@@ -3,9 +3,23 @@ import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
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(
|
||||
@@ -13,6 +27,8 @@ def _fake_compose(
|
||||
*,
|
||||
manifest_profile="ci",
|
||||
live_rows="3",
|
||||
live_pairing="3\t0\t0\t0",
|
||||
seam_context="19\t19\t19",
|
||||
views_mode="ok",
|
||||
):
|
||||
fake = tmp_path / "docker-compose"
|
||||
@@ -53,8 +69,10 @@ def _fake_compose(
|
||||
printf '%s\\n' '{live_rows}'
|
||||
elif [[ "$query" == *"uniqExact(event_id)"* ]]; then
|
||||
printf '%s\\n' '16200\t16200\t0'
|
||||
elif [[ "$query" == *"unpaired_location_rows"* ]]; then
|
||||
printf '%s\\n' '{live_pairing}'
|
||||
elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then
|
||||
printf '%s\\n' '19\t19\t19'
|
||||
printf '%s\\n' '{seam_context}'
|
||||
elif [[ "$query" == *"ods_device_rows"* ]]; then
|
||||
printf '%s\\n' '19\t19\t19\t0\t0'
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Ошибка запроса DM-витрин не превращается в пустой успешный цикл."""
|
||||
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 'GEN_HISTORY_DURATION="${GEN_HISTORY_DURATION:-1h}"' 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_STATE_RESET=false" 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 "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():
|
||||
"""ODS-блок стыка падает, если в ODS нет строк для переходящих визитов."""
|
||||
script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text(
|
||||
|
||||
Reference in New Issue
Block a user