feat(generator): глагол next-day — следующий модельный день от слепка
- Зачем:
- режим кормления стенда порциями: менти триггерит «следующий день»,
видит полный цикл DWH за один шаг (задача 13, вариант 2 — генерация
от слепка T_end).
- Что:
- новый ограниченный режим next-day: восстановление мира из state,
генерация ровно [T_end, T_end+24h), публикация данные -> state ->
манифест (манифест — точка фиксации, автоотката нет).
- операция next-day в DAG generator_control: своя предпроверка границы
вместо clean-guard, идемпотентность через параметр expected_t_end.
- цепочка границ — накопительное поле boundaries в манифесте, старый
формат читается как [T0, T_end]; импорт не изменён.
- новая проверка цепочки (make generated-history-chain-check): непарные
счётчики и однородность по каждой границе, явный статус нулевого
стыка, хвост за границей по всем четырём топикам, литералы в UTC
с микросекундами.
- документация OPERATIONS.md: глагол, предпроверка, восстановление
после сбоя, ограничение retention; в задаче 13 — решения двух слепых
ревью постановки и кода с аргументами отклонений.
- Проверка:
- make test: 204 теста генератора + 31 контракт корня, зелёные.
- make generated-history-chain-check: зелёный, 2 внутренние границы,
непарные счётчики нулевые; учебный цикл: DM 322 -> 10026 -> 19196
за два next-day подряд.
- make generated-history-runtime-check (регрессия задачи 20): зелёный.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
@@ -10,6 +11,7 @@ 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"
|
||||
CHAIN_CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_generated_history_chain.sh"
|
||||
|
||||
|
||||
def _required_runtime_line(script, command):
|
||||
@@ -345,3 +347,229 @@ def test_generated_history_check_rejects_empty_ods_context():
|
||||
assert "ods_geo_rows" in script
|
||||
assert "ODS device пустой на стыке" in script
|
||||
assert "ODS geo пустой на стыке" in script
|
||||
|
||||
|
||||
def test_boundary_chain_has_separate_batch_check_and_keeps_live_gate_unchanged():
|
||||
"""Цепочка boundaries проверяется отдельной целью, не режимом live-гейта."""
|
||||
chain = CHAIN_CHECK_SCRIPT.read_text(encoding="utf-8")
|
||||
live = CHECK_SCRIPT.read_text(encoding="utf-8")
|
||||
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||
|
||||
assert "generated-history-chain-check" in makefile
|
||||
assert "check_generated_history_chain.sh" in makefile
|
||||
assert "kafka-boundaries" in chain
|
||||
assert "boundaries" in chain
|
||||
assert "unpaired_location_rows" in chain
|
||||
assert "unpaired_device_rows" in chain
|
||||
assert "unpaired_geo_rows" in chain
|
||||
assert "browser_names" in chain
|
||||
assert "referer_urls" in chain
|
||||
assert "utm_sources" in chain
|
||||
assert "per_event_homogeneous_visits" in chain
|
||||
assert "toDateTime64('${boundary}', 6, 'UTC') AS boundary" in chain
|
||||
assert "index < boundary_count - 1" in chain
|
||||
assert "kafka-boundaries" not in live
|
||||
|
||||
|
||||
def _fake_chain_compose(tmp_path):
|
||||
fake = tmp_path / "chain-compose"
|
||||
fake.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/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-boundaries"* ]]; then
|
||||
printf '%b' "${CHAIN_BOUNDARIES}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$args" == *"kafka-topic-rows"* ]]; then
|
||||
printf '%s\n' "${CHAIN_MANIFEST_TOPIC_ROWS:-10 10 10 10}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$args" == *"clickhouse-client"* ]]; then
|
||||
printf '%s\n' "$query" >> "${CHAIN_QUERY_LOG}"
|
||||
if [[ "$query" == *"data_tail_rows"* ]]; then
|
||||
printf '%s\n' "${CHAIN_TAIL_ROWS:-0}"
|
||||
elif [[ "$query" == *"actual_topic_rows"* ]]; then
|
||||
printf '%s\n' "${CHAIN_ACTUAL_TOPIC_ROWS:-10 10 10 10}"
|
||||
elif [[ "$query" == *"FROM dm.v_events_enriched"* ]]; then
|
||||
printf '%s\n' "${CHAIN_SEGMENT_ROWS:-1}"
|
||||
elif [[ "$query" == *"unpaired_location_rows"* ]]; then
|
||||
printf '%s\n' "${CHAIN_PAIRING:-1 0 0 0}"
|
||||
elif [[ "$query" == *"per_event_homogeneous_visits"* ]]; then
|
||||
printf '%s\n' "${CHAIN_CONTEXT:-1 1}"
|
||||
else
|
||||
printf '%s\n' '1'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected compose call: $args" >&2
|
||||
exit 92
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
return fake
|
||||
|
||||
|
||||
def _run_chain_check(tmp_path, **overrides):
|
||||
query_log = tmp_path / "queries.log"
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"COMPOSE_BIN": str(_fake_chain_compose(tmp_path)),
|
||||
"CHAIN_BOUNDARIES": (
|
||||
"2026-01-01T00:00:00+00:00\\n"
|
||||
"2026-01-02T00:00:00+00:00\\n"
|
||||
),
|
||||
"CHAIN_QUERY_LOG": str(query_log),
|
||||
}
|
||||
)
|
||||
env.update(overrides)
|
||||
result = subprocess.run(
|
||||
["bash", str(CHAIN_CHECK_SCRIPT)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
return result, query_log.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_boundary_chain_rejects_data_tail_after_manifest_end(tmp_path):
|
||||
"""Сообщения после последней границы означают незавершённый next-day."""
|
||||
result, queries = _run_chain_check(tmp_path, CHAIN_TAIL_ROWS="1")
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "хвост" in result.stderr
|
||||
assert "FROM stg.browser_raw" in queries
|
||||
assert ">= toDateTime64('2026-01-02 00:00:00.000000', 6, 'UTC')" in queries
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("actual_rows", "topic"),
|
||||
[
|
||||
("10\t11\t10\t10", "location_events"),
|
||||
("10\t10\t11\t10", "device_events"),
|
||||
("10\t10\t10\t11", "geo_events"),
|
||||
],
|
||||
)
|
||||
def test_boundary_chain_rejects_non_browser_topic_tail(
|
||||
tmp_path,
|
||||
actual_rows,
|
||||
topic,
|
||||
):
|
||||
"""Хвост отдельной Kafka-темы обнаруживается без browser-события."""
|
||||
result, queries = _run_chain_check(
|
||||
tmp_path,
|
||||
CHAIN_ACTUAL_TOPIC_ROWS=actual_rows,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "хвост" in result.stderr
|
||||
assert topic in result.stderr
|
||||
assert "FROM stg.location_raw" in queries
|
||||
assert "FROM stg.device_raw" in queries
|
||||
assert "FROM stg.geo_raw" in queries
|
||||
|
||||
|
||||
def test_boundary_chain_reports_boundary_without_crossing_visits(tmp_path):
|
||||
"""Естественный нулевой стык проходит с явным статусом без однородности."""
|
||||
result, _ = _run_chain_check(
|
||||
tmp_path,
|
||||
CHAIN_BOUNDARIES=(
|
||||
"2026-01-01T00:00:00+00:00\\n"
|
||||
"2026-01-02T00:00:00+00:00\\n"
|
||||
"2026-01-03T00:00:00+00:00\\n"
|
||||
),
|
||||
CHAIN_PAIRING="0\t0\t0\t0",
|
||||
CHAIN_CONTEXT="0\t0",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (
|
||||
"boundary 1/3: crossing_visits=0 — однородность неприменима"
|
||||
in result.stdout
|
||||
)
|
||||
|
||||
|
||||
def test_boundary_chain_still_rejects_unpaired_rows_without_crossing_visits(
|
||||
tmp_path,
|
||||
):
|
||||
"""Нулевой стык не отключает обязательную проверку пар сообщений."""
|
||||
result, _ = _run_chain_check(
|
||||
tmp_path,
|
||||
CHAIN_BOUNDARIES=(
|
||||
"2026-01-01T00:00:00+00:00\\n"
|
||||
"2026-01-02T00:00:00+00:00\\n"
|
||||
"2026-01-03T00:00:00+00:00\\n"
|
||||
),
|
||||
CHAIN_PAIRING="0\t1\t0\t0",
|
||||
CHAIN_CONTEXT="0\t0",
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "непарные строки" in result.stderr
|
||||
|
||||
|
||||
def test_boundary_chain_still_rejects_empty_segment(tmp_path):
|
||||
"""Отсутствие переходящих визитов не разрешает пустую порцию дня."""
|
||||
result, _ = _run_chain_check(tmp_path, CHAIN_SEGMENT_ROWS="0")
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "порция" in result.stderr
|
||||
assert "пуста" in result.stderr
|
||||
|
||||
|
||||
def test_boundary_chain_normalizes_offsets_and_uses_explicit_utc(tmp_path):
|
||||
"""Граница с +03:00 проверяет тот же UTC-интервал, а не местные часы."""
|
||||
result, queries = _run_chain_check(
|
||||
tmp_path,
|
||||
CHAIN_BOUNDARIES=(
|
||||
"2026-01-01T03:00:00+03:00\\n"
|
||||
"2026-01-02T03:00:00+03:00\\n"
|
||||
"2026-01-03T03:00:00+03:00\\n"
|
||||
),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "toDateTime64('2026-01-01 00:00:00.000000', 6, 'UTC')" in queries
|
||||
assert "toDateTime64('2026-01-02 00:00:00.000000', 6, 'UTC')" in queries
|
||||
assert "toDateTime64('2026-01-03 00:00:00.000000', 6, 'UTC')" in queries
|
||||
literals = re.findall(r"toDateTime64\([^)]*\)", queries)
|
||||
assert len(literals) == 7
|
||||
assert all(", 6, 'UTC')" in literal for literal in literals)
|
||||
|
||||
|
||||
def test_boundary_chain_preserves_fractional_boundary_seconds(tmp_path):
|
||||
"""Приведение к UTC сохраняет микросекунды полуоткрытой границы."""
|
||||
result, queries = _run_chain_check(
|
||||
tmp_path,
|
||||
CHAIN_BOUNDARIES=(
|
||||
"2026-01-01T00:00:00.500000+00:00\\n"
|
||||
"2026-01-02T00:00:00.500000+00:00\\n"
|
||||
),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "toDateTime64('2026-01-01 00:00:00.500000', 6, 'UTC')" in queries
|
||||
assert "toDateTime64('2026-01-02 00:00:00.500000', 6, 'UTC')" in queries
|
||||
|
||||
Reference in New Issue
Block a user