- Зачем:
- world_next_day перечитывал всю историю топиков Kafka ради
накопительных счётчиков — время прогона росло с возрастом мира
(issue #5, находка F9).
- Что:
- счётчики засеваются при import из уже прочитанного артефакта и при
backfill из потока; next-day продвигает их только событиями нового
дня, полного чтения Kafka больше нет;
- катящаяся контрольная сумма — сумма SHA-256 событий по модулю 2^256
(инкремент равен полному пересчёту), старый формат артефакта
принимается без изменений;
- точные множества click_id/user_domain_id вынесены из manifest в
цепочку контент-адресуемых фрагментов (<=10 000 ID, SHA-256-цепочка,
отдельный топик counter_chunks) — потолок сообщения Kafka не грозит,
предел 900 000 байт проверяется явно с понятной ошибкой;
- порядок записи всюду: фрагменты -> manifest -> state; старое локальное
состояние отклоняется с подсказкой перезапустить import;
- документация manifest/state обновлена (ARCHITECTURE, OPERATIONS,
runbook startup-history).
- Проверка:
- make test (216+31) и make lint зелёные;
- живая приёмка на чистом стенде: import 235 с; три прогона
world_next_day — 716/718/716 с (плоское время, O(нового дня));
мир 3->6 дней, 561 942 события; make generated-history-chain-check —
все порции и стыки однородны;
- тест равенства инкремента и полного пересчёта:
test_incremental_counters_equal_full_recompute.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""
|
||
Тесты чистой логики пульта Airflow для генератора.
|
||
"""
|
||
|
||
from dataclasses import replace
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
|
||
def test_default_artifact_paths_keep_import_and_backfill_separate():
|
||
"""Import читает эталонный мир, а backfill пишет в прежний файл."""
|
||
from clickstream_generator.airflow_control import default_artifact_path
|
||
|
||
assert default_artifact_path("import") == (
|
||
"/opt/airflow/data/startup_history/reference-world.json.xz"
|
||
)
|
||
assert default_artifact_path("backfill") == (
|
||
"/opt/airflow/data/startup-history.json"
|
||
)
|
||
|
||
|
||
def test_build_control_env_uses_profile_duration_and_airflow_data_dir():
|
||
"""Пульт строит env для backfill из профиля и каталога Airflow."""
|
||
from clickstream_generator.airflow_control import build_control_env
|
||
|
||
env = build_control_env(
|
||
"backfill",
|
||
profile_name="ci",
|
||
duration="",
|
||
artifact_path="/opt/airflow/data/startup-history.json",
|
||
overrides={"GEN_LAMBDA_BASE_PER_MIN": "120", "GEN_SEED": "99"},
|
||
)
|
||
|
||
assert env["GEN_RUN_MODE"] == "backfill"
|
||
assert env["GEN_HISTORY_DURATION"] == "6h"
|
||
assert env["GEN_MODEL_T_END"] == "2026-01-01T06:00:00+00:00"
|
||
assert env["GEN_DATA_DIR"] == "/opt/airflow/data"
|
||
assert env["GEN_METRICS_ENABLED"] == "false"
|
||
assert env["GEN_STARTUP_HISTORY_ARTIFACT"] == "/opt/airflow/data/startup-history.json"
|
||
assert env["GEN_LAMBDA_BASE_PER_MIN"] == "120"
|
||
assert env["GEN_SEED"] == "99"
|
||
|
||
|
||
def test_import_env_requires_artifact_path_and_uses_backfill_contract():
|
||
"""Import валидируется как тот же мир, что backfill."""
|
||
from clickstream_generator.airflow_control import build_control_env
|
||
|
||
with pytest.raises(ValueError, match="artifact_path"):
|
||
build_control_env("import", profile_name="ci", artifact_path="")
|
||
|
||
env = build_control_env(
|
||
"import",
|
||
profile_name="daily-wave",
|
||
artifact_path="/opt/airflow/data/history.json",
|
||
)
|
||
|
||
assert env["GEN_RUN_MODE"] == "backfill"
|
||
assert env["GEN_STATE_RESET"] == "true"
|
||
assert env["GEN_HISTORY_DURATION"] == "3d"
|
||
|
||
|
||
def test_next_day_env_uses_world_settings_and_current_manifest_boundary():
|
||
"""Next-day восстанавливает настройки мира из manifest, а не из формы DAG."""
|
||
from clickstream_generator.airflow_control import build_next_day_env
|
||
|
||
manifest = {
|
||
"gen_seed": 42,
|
||
"model_t0": "2026-01-01T00:00:00+00:00",
|
||
"model_t_end": "2026-01-03T00:00:00+00:00",
|
||
"model_timezone": "UTC",
|
||
"launch_profile": "daily-wave",
|
||
"generation_settings": {
|
||
"tick_seconds": 1,
|
||
"lambda_base_per_min": 60,
|
||
"jitter_pct": 0,
|
||
"min_events_per_tick": 1,
|
||
"max_events_per_tick": 1000,
|
||
"max_session_events": 30,
|
||
"max_active_sessions": 200,
|
||
"population_max": 300,
|
||
"p_new_user": 0.15,
|
||
"min_return_minutes": 30,
|
||
"model_time_speed": 60,
|
||
},
|
||
}
|
||
|
||
env = build_next_day_env(manifest)
|
||
|
||
assert env["GEN_RUN_MODE"] == "next-day"
|
||
assert env["GEN_STATE_RESET"] == "false"
|
||
assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00"
|
||
assert env["GEN_MODEL_TIME_SPEED"] == "60"
|
||
assert env["GEN_MAX_SESSION_EVENTS"] == "30"
|
||
assert env["GEN_DATA_DIR"] == "/opt/airflow/data"
|
||
|
||
|
||
def test_next_day_env_map_covers_every_generation_setting(base_config):
|
||
"""Новая настройка генерации не может потеряться между manifest и env."""
|
||
from clickstream_generator.airflow_control import NEXT_DAY_SETTING_ENV_KEYS
|
||
from clickstream_generator.startup_history_artifact import (
|
||
generation_settings_from_config,
|
||
)
|
||
|
||
assert set(NEXT_DAY_SETTING_ENV_KEYS) == set(
|
||
generation_settings_from_config(base_config)
|
||
)
|
||
|
||
|
||
def test_next_day_precheck_rejects_stale_expected_boundary_with_both_values():
|
||
"""Повторный запуск с прежней границей падает до записи данных."""
|
||
from clickstream_generator.airflow_control import assert_expected_t_end
|
||
|
||
with pytest.raises(RuntimeError) as exc_info:
|
||
assert_expected_t_end(
|
||
"2026-01-03T00:00:00+00:00",
|
||
"2026-01-04T00:00:00+00:00",
|
||
)
|
||
|
||
message = str(exc_info.value)
|
||
assert "2026-01-03T00:00:00+00:00" in message
|
||
assert "2026-01-04T00:00:00+00:00" in message
|
||
|
||
|
||
def test_next_day_precheck_requires_manifest_and_matching_state():
|
||
"""Next-day громко отвергает пустой стенд и state не на T_end."""
|
||
from clickstream_generator.airflow_control import assert_next_day_snapshot
|
||
from test_startup_history_artifact import _state
|
||
|
||
state = _state()
|
||
with pytest.raises(RuntimeError, match="Manifest"):
|
||
assert_next_day_snapshot(None, state)
|
||
|
||
manifest = {
|
||
"run_mode": "backfill",
|
||
"model_t0": state.model_t0.isoformat(),
|
||
"model_t_end": "2026-01-01T02:00:00+00:00",
|
||
"gen_seed": state.gen_seed,
|
||
"model_timezone": state.model_timezone,
|
||
"state_version": state.version,
|
||
"state": {
|
||
"last_batch_id": state.last_batch_id,
|
||
"model_timestamp": "2026-01-01T02:00:00+00:00",
|
||
},
|
||
"generation_settings": {"model_time_speed": state.model_time_speed},
|
||
}
|
||
with pytest.raises(RuntimeError, match="state.*T_end"):
|
||
assert_next_day_snapshot(manifest, state)
|
||
|
||
manifest["model_t_end"] = state.model_timestamp.isoformat()
|
||
manifest["state"]["model_timestamp"] = state.model_timestamp.isoformat()
|
||
with pytest.raises(RuntimeError, match="повторно запустите import"):
|
||
assert_next_day_snapshot(manifest, state)
|
||
|
||
|
||
def test_target_dag_trigger_error_covers_missing_paused_and_ready_states():
|
||
"""Проверка зависимого DAG различает три реальные ветки."""
|
||
from clickstream_generator.airflow_control import target_dag_trigger_error
|
||
|
||
assert "ещё не найден" in target_dag_trigger_error("etl_pipeline", None)
|
||
assert "стоит на паузе" in target_dag_trigger_error(
|
||
"etl_pipeline",
|
||
SimpleNamespace(is_paused=True),
|
||
)
|
||
assert target_dag_trigger_error(
|
||
"etl_pipeline",
|
||
SimpleNamespace(is_paused=False),
|
||
) is None
|
||
|
||
|
||
def test_assert_stand_clean_rejects_non_empty_stg_before_writes():
|
||
"""Backfill/import не стартуют на непустом STG."""
|
||
from clickstream_generator.airflow_control import assert_stg_tables_empty
|
||
|
||
class Hook:
|
||
def execute(self, sql):
|
||
self.sql = sql
|
||
return [(0, 2, 0, 0)]
|
||
|
||
hook = Hook()
|
||
|
||
with pytest.raises(RuntimeError, match="make clean"):
|
||
assert_stg_tables_empty(hook)
|
||
assert "stg.browser_raw" in hook.sql
|
||
assert "stg.location_raw" in hook.sql
|
||
|
||
|
||
def test_assert_stand_clean_rejects_non_empty_kafka_with_make_clean_hint(monkeypatch):
|
||
"""Непустые Kafka-топики дают ту же подсказку про make clean."""
|
||
from clickstream_generator import airflow_control, stand_clean
|
||
|
||
class Inspector:
|
||
def __init__(self, bootstrap_servers):
|
||
self.bootstrap_servers = bootstrap_servers
|
||
|
||
def assert_data_topics_empty(self):
|
||
raise RuntimeError("Kafka data topics are not empty: browser_events")
|
||
|
||
class Hook:
|
||
def execute(self, sql):
|
||
return [(0, 0, 0, 0)]
|
||
|
||
monkeypatch.setattr(stand_clean, "KafkaTopicInspector", Inspector)
|
||
|
||
with pytest.raises(RuntimeError, match="make clean"):
|
||
airflow_control.assert_stand_clean("kafka:29092", Hook())
|
||
|
||
|
||
def test_shared_stand_clean_rejects_host_stg_counts():
|
||
"""Хостовая предпроверка использует ту же STG-границу, что пульт."""
|
||
from clickstream_generator.stand_clean import assert_stg_counts_empty
|
||
|
||
with pytest.raises(RuntimeError, match="stg.location_raw=3.*make clean"):
|
||
assert_stg_counts_empty((0, 3, 0, 0))
|
||
|
||
|
||
def test_continue_rejects_dirty_stand_without_state(base_config, monkeypatch):
|
||
"""Continue без state не стартует новый мир поверх грязного STG."""
|
||
from clickstream_generator import stand_clean
|
||
|
||
class StateManager:
|
||
def __init__(self, bootstrap_servers):
|
||
self.bootstrap_servers = bootstrap_servers
|
||
|
||
def load(self):
|
||
return None
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
class Inspector:
|
||
def __init__(self, bootstrap_servers):
|
||
self.bootstrap_servers = bootstrap_servers
|
||
|
||
def assert_data_topics_empty(self):
|
||
pass
|
||
|
||
monkeypatch.setattr(stand_clean, "KafkaStateManager", StateManager)
|
||
monkeypatch.setattr(stand_clean, "KafkaTopicInspector", Inspector)
|
||
|
||
with pytest.raises(RuntimeError, match="stg.browser_raw=1.*make clean"):
|
||
stand_clean.assert_continue_has_state_or_clean_stand(
|
||
"localhost:9092",
|
||
(1, 0, 0, 0),
|
||
base_config,
|
||
)
|
||
|
||
|
||
def test_check_manifest_compares_clickhouse_stats(base_config):
|
||
"""Check падает, когда контрольные числа ClickHouse расходятся с manifest."""
|
||
from clickstream_generator.airflow_control import assert_clickhouse_matches_manifest
|
||
from clickstream_generator.startup_history_artifact import (
|
||
StartupHistoryArtifactBuilder,
|
||
build_manifest,
|
||
)
|
||
from test_startup_history_artifact import _batch, _state
|
||
|
||
state = _state()
|
||
builder = StartupHistoryArtifactBuilder()
|
||
builder.add_batch(_batch())
|
||
manifest = build_manifest(
|
||
config=replace(
|
||
base_config,
|
||
model_t0=state.model_t0,
|
||
model_t_end=state.model_timestamp,
|
||
model_time_speed=1,
|
||
model_timezone="UTC",
|
||
seed=42,
|
||
),
|
||
counters=builder.counters,
|
||
state=state,
|
||
)
|
||
|
||
class Hook:
|
||
def execute(self, sql):
|
||
self.sql = sql
|
||
return [(
|
||
0,
|
||
1,
|
||
1,
|
||
"2026-01-01 00:00:00.000000",
|
||
"2026-01-01 00:00:00.000000",
|
||
)]
|
||
|
||
with pytest.raises(RuntimeError, match="events"):
|
||
assert_clickhouse_matches_manifest(manifest, Hook())
|
||
|
||
|
||
def test_generator_metrics_server_can_be_disabled(base_config, monkeypatch):
|
||
"""Airflow-задача может запускать генератор без HTTP-сервера метрик."""
|
||
from clickstream_generator.service import GeneratorService
|
||
|
||
config = replace(base_config, enabled=False, metrics_enabled=False)
|
||
service = GeneratorService(config)
|
||
called = False
|
||
|
||
def start_http_server(_port):
|
||
nonlocal called
|
||
called = True
|
||
|
||
monkeypatch.setattr(
|
||
"clickstream_generator.service.start_http_server",
|
||
start_http_server,
|
||
)
|
||
|
||
service.start()
|
||
|
||
assert called is False
|