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:
2026-07-12 23:53:48 +03:00
co-authored by Claude Fable 5
parent 540989d358
commit f5dee26eda
17 changed files with 1501 additions and 31 deletions
@@ -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