Files
clickstream-ch-kafka-supers…/tests/test_generated_analytics_check_contract.py
T
ddadmin dab120a07e 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 пересекающего визита) валит
    гейт прежним сообщением при нулевых непарных счётчиках.
2026-07-12 19:33:28 +03:00

348 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
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(
tmp_path,
*,
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"
fake.write_text(
textwrap.dedent(
f"""\
#!/usr/bin/env bash
set -euo pipefail
args="$*"
query=""
previous=""
for arg in "$@"; do
if [[ "$previous" == "--query" ]]; then
query="$arg"
break
fi
previous="$arg"
done
if [[ "$args" == *"kafka-manifest-summary"* ]]; then
printf '%s\\n' '16054\t2800\t930\t2026-01-01T00:00:00+00:00\t2026-01-01T05:59:00+00:00\t2026-01-01T00:00:00+00:00\t2026-01-01T06:00:00+00:00\t{manifest_profile}'
exit 0
fi
if [[ "$args" == *"clickhouse-client"* ]]; then
if [[ "$query" == *"hex(sipHash128"* ]]; then
printf '%s\\n' '16054\t2800\t930\t2026-01-01 00:00:00.000000\t2026-01-01 05:59:00.000000\t1\t1\t1\tdeadbeef'
elif [[ "$query" == *"returning_users / users"* ]]; then
printf '%s\\n' '930\t120\t0.129\t4'
elif [[ "$query" == *"short_visit_share"* ]]; then
printf '%s\\n' '2800\t0.2\t5\t5.7\t0\t120\t900\t18'
elif [[ "$query" == *"minIf(event_ts"* ]]; then
printf '%s\\n' '2800\t2100\t1500\t900\t650\t1\t0.23'
elif [[ "$query" == *"has_home"* ]]; then
printf '%s\\n' '2800\t2200\t1600\t950\t700\t1\t0.25'
elif [[ "$query" == *"SELECT count()"* && "$query" == *"FROM dds.event"* ]]; then
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' '{seam_context}'
elif [[ "$query" == *"ods_device_rows"* ]]; then
printf '%s\\n' '19\t19\t19\t0\t0'
elif [[ "$query" == *"SELECT source, rows"* ]]; then
if [[ "{views_mode}" == "fail" ]]; then
exit 42
fi
printf '%s\\n' \
'dm.dq_summary\t1' \
'dm.v_daily_traffic\t1' \
'dm.v_events_enriched\t16054' \
'dm.v_session_overview\t1' \
'dm.v_top_pages_daily\t1' \
'dm.v_utm_effectiveness\t1'
else
echo "unexpected ClickHouse query: $query" >&2
exit 91
fi
exit 0
fi
echo "unexpected compose call: $args" >&2
exit 92
"""
),
encoding="utf-8",
)
fake.chmod(0o755)
return fake
def _run_generated_history_check(tmp_path, *, fake_compose, **env_overrides):
env = os.environ.copy()
env.update(
{
"COMPOSE_BIN": str(fake_compose),
"REQUIRE_SUPERSET": "0",
"WAIT_LIVE_ROWS_SECONDS": "0",
}
)
env.update(env_overrides)
return subprocess.run(
["bash", str(CHECK_SCRIPT)],
cwd=REPO_ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30,
check=False,
)
def test_generated_history_check_uses_actual_manifest_boundary_and_profile():
"""Проверка берёт стык и профиль из manifest фактического прогона."""
script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text(
encoding="utf-8"
)
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
assert "kafka-manifest-summary" in script
assert "manifest_model_t_end" in script
assert "CH_MODEL_T_END=\"$(clickhouse_datetime_literal \"${manifest_model_t_end}\")\"" in script
assert "PROFILE ?= ci" in makefile
assert "GEN_LAUNCH_PROFILE" in makefile
assert "PROFILE" in makefile
def test_generated_history_check_does_not_skip_required_live_seam():
"""Обычная проверка требует реальные live-строки после стыка."""
script = (REPO_ROOT / "scripts" / "check_generated_analytics.sh").read_text(
encoding="utf-8"
)
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
assert 'CHECK_LIVE_SEAM="${CHECK_LIVE_SEAM:-1}"' in script
assert 'if [[ "${should_check_live_seam}" == "1" ]] && (( live_rows == 0 )); then' in script
assert "live-продолжение не записало строки" in script
assert 'CHECK_LIVE_SEAM="$${CHECK_LIVE_SEAM:-1}"' in makefile
def test_generated_history_check_rejects_manifest_profile_mismatch(tmp_path):
"""Проверка падает, если ожидаемый профиль не совпал с manifest."""
fake_compose = _fake_compose(tmp_path, manifest_profile="ci")
result = _run_generated_history_check(
tmp_path,
fake_compose=fake_compose,
PROFILE="daily-wave",
CHECK_LIVE_SEAM="0",
)
assert result.returncode != 0
assert "профиля daily-wave, но manifest от ci" in result.stderr
def test_generated_history_check_requires_live_rows_when_seam_is_required(tmp_path):
"""CHECK_LIVE_SEAM=1 падает, если live-продолжение не записало строк."""
fake_compose = _fake_compose(tmp_path, live_rows="0")
result = _run_generated_history_check(
tmp_path,
fake_compose=fake_compose,
PROFILE="ci",
CHECK_LIVE_SEAM="1",
)
assert result.returncode != 0
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")
result = _run_generated_history_check(
tmp_path,
fake_compose=fake_compose,
PROFILE="ci",
CHECK_LIVE_SEAM="0",
)
assert result.returncode != 0
assert "не удалось прочитать основные DM-витрины" in result.stderr
def test_clean_generated_history_run_explicitly_skips_live_seam():
"""Чистый backfill без live-продолжения отключает проверку стыка явно."""
script = (REPO_ROOT / "scripts" / "run_generated_history_analytics.sh").read_text(
encoding="utf-8"
)
assert "CHECK_LIVE_SEAM=0" in script
assert script.index("CHECK_LIVE_SEAM=0") < script.index('bash "${SCRIPT_DIR}/check_generated_analytics.sh"')
def test_runtime_gate_has_bounded_daily_wave_live_seam_path():
"""Runtime gate issue 17 не использует полный daily-wave на 2 суток."""
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
script = (REPO_ROOT / "scripts" / "run_generated_history_runtime_check.sh").read_text(
encoding="utf-8"
)
assert "generated-history-runtime-check" in makefile
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_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(
encoding="utf-8"
)
assert "ods_device_rows" in script
assert "ods_geo_rows" in script
assert "ODS device пустой на стыке" in script
assert "ODS geo пустой на стыке" in script