fix(generator): исключено смешение миров при запуске
- Зачем: - старый state и ручной live-генератор могли тихо писать события в новый мир данных. - Что: - добавлена общая проверка чистого стенда для Airflow и host-путей. - защищены backfill, import, continue и clean-start сценарии live-generator. - описан миграционный отказ старого state. - Проверка: - make generator-test; docker compose config --quiet; py_compile; bash -n.
This commit is contained in:
@@ -18,11 +18,11 @@ up:
|
|||||||
|
|
||||||
# Остановить и удалить контейнеры/сети текущего проекта
|
# Остановить и удалить контейнеры/сети текущего проекта
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down
|
$(COMPOSE) --profile live-generator down
|
||||||
|
|
||||||
# Полная очистка окружения проекта (включая volumes)
|
# Полная очистка окружения проекта (включая volumes)
|
||||||
clean:
|
clean:
|
||||||
$(COMPOSE) down -v --remove-orphans
|
$(COMPOSE) --profile live-generator down -v --remove-orphans
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs -f --tail=200 $(service)
|
$(COMPOSE) logs -f --tail=200 $(service)
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ Kafka-топики данных, state и manifest генератора. `make g
|
|||||||
PROFILE=daily-wave make generator-backfill
|
PROFILE=daily-wave make generator-backfill
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые.
|
||||||
|
Если там уже есть данные, она останавливается с подсказкой `make clean`.
|
||||||
|
|
||||||
Старый способ через `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END`
|
Старый способ через `GEN_RUN_MODE`, `GEN_STATE_RESET` и `GEN_MODEL_T_END`
|
||||||
остаётся низкоуровневым путём для отладки прямого `docker compose run`. В обычной
|
остаётся низкоуровневым путём для отладки прямого `docker compose run`. В обычной
|
||||||
проверке используйте `make generated-history-analytics`, чтобы не забыть
|
проверке используйте `make generated-history-analytics`, чтобы не забыть
|
||||||
@@ -237,6 +240,9 @@ docker compose exec -T kafka /opt/kafka/bin/kafka-console-consumer.sh \
|
|||||||
|
|
||||||
Live-продолжение стартует с этого state. Используйте тот же профиль или ту же
|
Live-продолжение стартует с этого state. Используйте тот же профиль или ту же
|
||||||
длительность, что были у backfill. Команда сама выставит `GEN_STATE_RESET=false`.
|
длительность, что были у backfill. Команда сама выставит `GEN_STATE_RESET=false`.
|
||||||
|
Если state записан старой версией генератора, запуск теперь падает громко:
|
||||||
|
сначала очистите стенд через `make clean` и пересоздайте историю либо явно
|
||||||
|
начните новый мир через `make generator-reset`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PROFILE=daily-wave make generator-continue
|
PROFILE=daily-wave make generator-continue
|
||||||
|
|||||||
@@ -59,8 +59,9 @@
|
|||||||
Для `import` — что читать; пусто — `/opt/airflow/data/startup-history-import.json`.
|
Для `import` — что читать; пусто — `/opt/airflow/data/startup-history-import.json`.
|
||||||
|
|
||||||
Backfill и import работают только на чистом стенде. Если Kafka-топики данных или
|
Backfill и import работают только на чистом стенде. Если Kafka-топики данных или
|
||||||
STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Это защита от
|
STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Консольные
|
||||||
смешивания разных миров.
|
команды `make generator-backfill` и `make startup-history-import` делают такую же
|
||||||
|
предпроверку с хоста. Это защита от смешивания разных миров.
|
||||||
|
|
||||||
Границы пульта:
|
Границы пульта:
|
||||||
|
|
||||||
@@ -145,6 +146,12 @@ PROFILE=daily-wave make generator-continue
|
|||||||
полей. Это защита от смешения разных миров. Для намеренного нового мира
|
полей. Это защита от смешения разных миров. Для намеренного нового мира
|
||||||
используйте `make generator-reset` или `make clean`.
|
используйте `make generator-reset` или `make clean`.
|
||||||
|
|
||||||
|
После обновления кода старый state может оказаться в старом формате. При
|
||||||
|
`GEN_STATE_RESET=false` это теперь громкий отказ, а не тихий старт с нуля поверх
|
||||||
|
старой истории. Оператору нужно выбрать одно из двух: очистить стенд через
|
||||||
|
`make clean` и заново создать стартовую историю, либо осознанно начать новый мир
|
||||||
|
через `GEN_STATE_RESET=true` / `make generator-reset`.
|
||||||
|
|
||||||
После нестандартного мира `make generator-continue` нужно запускать с теми же
|
После нестандартного мира `make generator-continue` нужно запускать с теми же
|
||||||
настройками, что были у backfill/import. При расхождении генератор громко
|
настройками, что были у backfill/import. При расхождении генератор громко
|
||||||
покажет поля, которые не совпали.
|
покажет поля, которые не совпали.
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ make generator-test
|
|||||||
и `generator-reset`. Старые `GEN_RUN_MODE`, `GEN_STATE_RESET` и
|
и `generator-reset`. Старые `GEN_RUN_MODE`, `GEN_STATE_RESET` и
|
||||||
`GEN_MODEL_T_END` остаются низкоуровневым способом для отладки.
|
`GEN_MODEL_T_END` остаются низкоуровневым способом для отладки.
|
||||||
|
|
||||||
|
`generator-backfill` и `startup-history-import` перед записью проверяют, что
|
||||||
|
Kafka data-топики и STG пустые. `generator-continue` продолжает только
|
||||||
|
совместимый state; старый формат state при `GEN_STATE_RESET=false` даёт отказ с
|
||||||
|
подсказкой очистить стенд или явно начать новый мир.
|
||||||
|
|
||||||
## Метрики Prometheus
|
## Метрики Prometheus
|
||||||
|
|
||||||
Генератор экспортирует метрики на `:9109/metrics`:
|
Генератор экспортирует метрики на `:9109/metrics`:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ from clickstream_generator.startup_history_artifact import (
|
|||||||
load_startup_history_artifact,
|
load_startup_history_artifact,
|
||||||
validate_startup_history_artifact,
|
validate_startup_history_artifact,
|
||||||
)
|
)
|
||||||
|
from clickstream_generator.stand_clean import (
|
||||||
|
STG_EMPTY_SQL,
|
||||||
|
assert_kafka_data_topics_empty,
|
||||||
|
assert_stg_counts_empty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
AIRFLOW_DATA_DIR = "/opt/airflow/data"
|
AIRFLOW_DATA_DIR = "/opt/airflow/data"
|
||||||
@@ -43,14 +48,6 @@ WORLD_OVERRIDE_KEYS = {
|
|||||||
"GEN_MAX_EVENTS_PER_TICK",
|
"GEN_MAX_EVENTS_PER_TICK",
|
||||||
}
|
}
|
||||||
|
|
||||||
STG_EMPTY_SQL = """
|
|
||||||
SELECT
|
|
||||||
(SELECT count() FROM stg.browser_raw) AS browser_raw,
|
|
||||||
(SELECT count() FROM stg.location_raw) AS location_raw,
|
|
||||||
(SELECT count() FROM stg.device_raw) AS device_raw,
|
|
||||||
(SELECT count() FROM stg.geo_raw) AS geo_raw
|
|
||||||
"""
|
|
||||||
|
|
||||||
CLICKHOUSE_STATS_SQL = """
|
CLICKHOUSE_STATS_SQL = """
|
||||||
WITH
|
WITH
|
||||||
toDateTime64('{model_t0}', 6) AS t0,
|
toDateTime64('{model_t0}', 6) AS t0,
|
||||||
@@ -101,13 +98,7 @@ def build_control_env(
|
|||||||
|
|
||||||
def assert_stand_clean(kafka_bootstrap_servers: str, clickhouse_hook) -> None:
|
def assert_stand_clean(kafka_bootstrap_servers: str, clickhouse_hook) -> None:
|
||||||
"""Проверяет, что backfill/import не смешает миры."""
|
"""Проверяет, что backfill/import не смешает миры."""
|
||||||
try:
|
assert_kafka_data_topics_empty(kafka_bootstrap_servers)
|
||||||
KafkaTopicInspector(kafka_bootstrap_servers).assert_data_topics_empty()
|
|
||||||
except RuntimeError as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Стенд не чистый: в Kafka data-топиках уже есть сообщения. "
|
|
||||||
"Выполните make clean с консоли и повторите операцию."
|
|
||||||
) from exc
|
|
||||||
assert_stg_tables_empty(clickhouse_hook)
|
assert_stg_tables_empty(clickhouse_hook)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,18 +106,7 @@ def assert_stg_tables_empty(clickhouse_hook) -> None:
|
|||||||
"""Падает, если в STG уже есть строки."""
|
"""Падает, если в STG уже есть строки."""
|
||||||
result = clickhouse_hook.execute(STG_EMPTY_SQL)
|
result = clickhouse_hook.execute(STG_EMPTY_SQL)
|
||||||
counts = result[0] if result else ()
|
counts = result[0] if result else ()
|
||||||
names = ("stg.browser_raw", "stg.location_raw", "stg.device_raw", "stg.geo_raw")
|
assert_stg_counts_empty(counts)
|
||||||
dirty = [
|
|
||||||
f"{name}={int(count)}"
|
|
||||||
for name, count in zip(names, counts)
|
|
||||||
if int(count) > 0
|
|
||||||
]
|
|
||||||
if dirty:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Стенд не чистый: в STG уже есть строки ("
|
|
||||||
+ ", ".join(dirty)
|
|
||||||
+ "). Выполните make clean с консоли и повторите операцию."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_live_generator_not_running(url: str = GENERATOR_METRICS_URL) -> None:
|
def assert_live_generator_not_running(url: str = GENERATOR_METRICS_URL) -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from clickstream_generator.metrics import METRICS_ERRORS_TOTAL, METRICS_EVENTS_TOTAL
|
from clickstream_generator.metrics import METRICS_ERRORS_TOTAL, METRICS_EVENTS_TOTAL
|
||||||
from clickstream_generator.state import GeneratorState
|
from clickstream_generator.state import GeneratorState
|
||||||
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("generator")
|
logger = logging.getLogger("generator")
|
||||||
@@ -178,14 +179,24 @@ class KafkaStateManager:
|
|||||||
f"Restored state: tick={last_state.get('tick')}, "
|
f"Restored state: tick={last_state.get('tick')}, "
|
||||||
f"last_batch_id={last_state.get('last_batch_id')}"
|
f"last_batch_id={last_state.get('last_batch_id')}"
|
||||||
)
|
)
|
||||||
restored = GeneratorState.from_dict_safe(last_state)
|
try:
|
||||||
if restored is None:
|
restored = GeneratorState.from_dict(last_state)
|
||||||
|
except UnsupportedStateVersionError:
|
||||||
|
logger.error(
|
||||||
|
"Unsupported generator state version %s",
|
||||||
|
last_state.get("version", "1.0"),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except ValueError:
|
||||||
logger.warning("State data was invalid, starting fresh")
|
logger.warning("State data was invalid, starting fresh")
|
||||||
|
return None
|
||||||
return restored
|
return restored
|
||||||
|
|
||||||
logger.info("No previous state found, starting fresh")
|
logger.info("No previous state found, starting fresh")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
except UnsupportedStateVersionError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load state: {e}, starting fresh")
|
logger.warning(f"Failed to load state: {e}, starting fresh")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from clickstream_generator.startup_history_artifact import (
|
|||||||
generation_settings_from_config,
|
generation_settings_from_config,
|
||||||
write_startup_history_artifact,
|
write_startup_history_artifact,
|
||||||
)
|
)
|
||||||
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("generator")
|
logger = logging.getLogger("generator")
|
||||||
@@ -98,7 +99,21 @@ class GeneratorService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self.config.state_reset:
|
if not self.config.state_reset:
|
||||||
|
try:
|
||||||
restored_state = self.state_manager.load()
|
restored_state = self.state_manager.load()
|
||||||
|
except UnsupportedStateVersionError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Readable generator state uses an old format: version %s. "
|
||||||
|
"Run make clean or set GEN_STATE_RESET=true only if you "
|
||||||
|
"intentionally start a new world.",
|
||||||
|
exc.found_version,
|
||||||
|
)
|
||||||
|
raise IncompatibleStateError(
|
||||||
|
f"state version {exc.found_version} is not supported "
|
||||||
|
f"by this code version ({exc.expected_version}); "
|
||||||
|
"run make clean, or set GEN_STATE_RESET=true to start "
|
||||||
|
"a new world intentionally"
|
||||||
|
) from exc
|
||||||
if restored_state:
|
if restored_state:
|
||||||
try:
|
try:
|
||||||
self.manifest_manager = KafkaStartupHistoryManifest(
|
self.manifest_manager = KafkaStartupHistoryManifest(
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Общая предпроверка чистого стенда перед новым миром."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from clickstream_generator.config import Config
|
||||||
|
from clickstream_generator.kafka_io import (
|
||||||
|
KafkaStateManager,
|
||||||
|
KafkaStartupHistoryManifest,
|
||||||
|
)
|
||||||
|
from clickstream_generator.service import GeneratorService, IncompatibleStateError
|
||||||
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
from clickstream_generator.startup_history_artifact import KafkaTopicInspector
|
||||||
|
|
||||||
|
|
||||||
|
STG_EMPTY_SQL = """
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM stg.browser_raw) AS browser_raw,
|
||||||
|
(SELECT count() FROM stg.location_raw) AS location_raw,
|
||||||
|
(SELECT count() FROM stg.device_raw) AS device_raw,
|
||||||
|
(SELECT count() FROM stg.geo_raw) AS geo_raw
|
||||||
|
"""
|
||||||
|
|
||||||
|
STG_TABLE_NAMES = (
|
||||||
|
"stg.browser_raw",
|
||||||
|
"stg.location_raw",
|
||||||
|
"stg.device_raw",
|
||||||
|
"stg.geo_raw",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_kafka_data_topics_empty(kafka_bootstrap_servers: str) -> None:
|
||||||
|
"""Падает, если в Kafka data-топиках уже есть сообщения."""
|
||||||
|
try:
|
||||||
|
KafkaTopicInspector(kafka_bootstrap_servers).assert_data_topics_empty()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Стенд не чистый: в Kafka data-топиках уже есть сообщения. "
|
||||||
|
"Выполните make clean и повторите операцию."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def assert_stg_counts_empty(counts: Iterable[int]) -> None:
|
||||||
|
"""Падает, если в STG уже есть строки."""
|
||||||
|
dirty = [
|
||||||
|
f"{name}={int(count)}"
|
||||||
|
for name, count in zip(STG_TABLE_NAMES, counts)
|
||||||
|
if int(count) > 0
|
||||||
|
]
|
||||||
|
if dirty:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Стенд не чистый: в STG уже есть строки ("
|
||||||
|
+ ", ".join(dirty)
|
||||||
|
+ "). Выполните make clean и повторите операцию."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stg_counts(value: str) -> tuple[int, int, int, int]:
|
||||||
|
"""Разбирает TSV-строку ClickHouse с четырьмя счётчиками STG."""
|
||||||
|
parts = value.strip().split()
|
||||||
|
if len(parts) != len(STG_TABLE_NAMES):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Не удалось проверить чистоту STG: ClickHouse вернул "
|
||||||
|
f"{len(parts)} значений вместо {len(STG_TABLE_NAMES)}."
|
||||||
|
)
|
||||||
|
return tuple(int(part) for part in parts) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_stand_clean(kafka_bootstrap_servers: str, stg_counts: Iterable[int]) -> None:
|
||||||
|
"""Проверяет Kafka data-топики и STG перед backfill/import."""
|
||||||
|
assert_kafka_data_topics_empty(kafka_bootstrap_servers)
|
||||||
|
assert_stg_counts_empty(stg_counts)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_live_generator_not_running(live_metrics_url: str) -> None:
|
||||||
|
"""Падает, если live-генератор отвечает на metrics-порту."""
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(live_metrics_url, timeout=2).close()
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
"Live-генератор уже запущен. Остановите его через make generator-down "
|
||||||
|
"или make clean перед новым backfill/import."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_continue_has_state_or_clean_stand(
|
||||||
|
kafka_bootstrap_servers: str,
|
||||||
|
stg_counts: Iterable[int],
|
||||||
|
config: Config,
|
||||||
|
) -> None:
|
||||||
|
"""Разрешает continue только от совместимого state или чистого стенда."""
|
||||||
|
state_manager = KafkaStateManager(kafka_bootstrap_servers)
|
||||||
|
try:
|
||||||
|
state = state_manager.load()
|
||||||
|
except UnsupportedStateVersionError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"State записан старой версией {exc.found_version}. "
|
||||||
|
"Выполните make clean или явно начните новый мир через make generator-reset."
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
state_manager.close()
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
assert_stand_clean(kafka_bootstrap_servers, stg_counts)
|
||||||
|
return
|
||||||
|
|
||||||
|
service = GeneratorService(config)
|
||||||
|
manifest_manager = KafkaStartupHistoryManifest(kafka_bootstrap_servers)
|
||||||
|
try:
|
||||||
|
manifest = manifest_manager.load()
|
||||||
|
finally:
|
||||||
|
manifest_manager.close()
|
||||||
|
|
||||||
|
if service._is_startup_history_state(state, manifest):
|
||||||
|
return
|
||||||
|
if service._is_startup_history_marker(state):
|
||||||
|
mismatches = service._startup_history_mismatch_fields(state, manifest)
|
||||||
|
raise RuntimeError(
|
||||||
|
"State стартовой истории несовместим с текущими настройками: "
|
||||||
|
+ ", ".join(mismatches)
|
||||||
|
+ ". Выполните make clean или используйте тот же профиль."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
service._validate_live_state_config(state)
|
||||||
|
except IncompatibleStateError as exc:
|
||||||
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Проверить, что стенд чист перед новым миром генератора."
|
||||||
|
)
|
||||||
|
parser.add_argument("--mode", choices=("clean", "continue", "live"), default="clean")
|
||||||
|
parser.add_argument("--kafka-bootstrap-servers")
|
||||||
|
parser.add_argument("--stg-counts")
|
||||||
|
parser.add_argument("--live-metrics-url")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.live_metrics_url:
|
||||||
|
assert_live_generator_not_running(args.live_metrics_url)
|
||||||
|
if args.mode == "live":
|
||||||
|
return 0
|
||||||
|
if not args.kafka_bootstrap_servers or not args.stg_counts:
|
||||||
|
raise RuntimeError("Нужны --kafka-bootstrap-servers и --stg-counts.")
|
||||||
|
stg_counts = parse_stg_counts(args.stg_counts)
|
||||||
|
if args.mode == "continue":
|
||||||
|
assert_continue_has_state_or_clean_stand(
|
||||||
|
args.kafka_bootstrap_servers,
|
||||||
|
stg_counts,
|
||||||
|
Config(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert_stand_clean(args.kafka_bootstrap_servers, stg_counts)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"Ошибка: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -11,6 +11,19 @@ logger = logging.getLogger("generator")
|
|||||||
|
|
||||||
|
|
||||||
STATE_VERSION = "3.0"
|
STATE_VERSION = "3.0"
|
||||||
|
KNOWN_OLD_STATE_VERSIONS = {"2.0"}
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedStateVersionError(ValueError):
|
||||||
|
"""State записан в старом формате и требует явного решения оператора."""
|
||||||
|
|
||||||
|
def __init__(self, found_version: str, expected_version: str = STATE_VERSION):
|
||||||
|
self.found_version = found_version
|
||||||
|
self.expected_version = expected_version
|
||||||
|
super().__init__(
|
||||||
|
f"unsupported state version {found_version}; "
|
||||||
|
f"current version is {expected_version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _nested_list_to_tuple(obj):
|
def _nested_list_to_tuple(obj):
|
||||||
@@ -234,6 +247,8 @@ class GeneratorState:
|
|||||||
raise ValueError("state must be an object")
|
raise ValueError("state must be an object")
|
||||||
version = data.get("version", "1.0")
|
version = data.get("version", "1.0")
|
||||||
if version != STATE_VERSION:
|
if version != STATE_VERSION:
|
||||||
|
if str(version) in KNOWN_OLD_STATE_VERSIONS:
|
||||||
|
raise UnsupportedStateVersionError(str(version))
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Unsupported generator state version %s, will start fresh",
|
"Unsupported generator state version %s, will start fresh",
|
||||||
version,
|
version,
|
||||||
@@ -283,6 +298,8 @@ class GeneratorState:
|
|||||||
active_visits=data.get("active_visits", []),
|
active_visits=data.get("active_visits", []),
|
||||||
pending_visit_births=data.get("pending_visit_births", 0.0),
|
pending_visit_births=data.get("pending_visit_births", 0.0),
|
||||||
)
|
)
|
||||||
|
except UnsupportedStateVersionError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Invalid state format, will start fresh: {e}")
|
logger.warning(f"Invalid state format, will start fresh: {e}")
|
||||||
raise ValueError(f"Invalid state: {e}")
|
raise ValueError(f"Invalid state: {e}")
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def test_assert_stand_clean_rejects_non_empty_stg_before_writes():
|
|||||||
|
|
||||||
def test_assert_stand_clean_rejects_non_empty_kafka_with_make_clean_hint(monkeypatch):
|
def test_assert_stand_clean_rejects_non_empty_kafka_with_make_clean_hint(monkeypatch):
|
||||||
"""Непустые Kafka-топики дают ту же подсказку про make clean."""
|
"""Непустые Kafka-топики дают ту же подсказку про make clean."""
|
||||||
from clickstream_generator import airflow_control
|
from clickstream_generator import airflow_control, stand_clean
|
||||||
|
|
||||||
class Inspector:
|
class Inspector:
|
||||||
def __init__(self, bootstrap_servers):
|
def __init__(self, bootstrap_servers):
|
||||||
@@ -81,12 +81,52 @@ def test_assert_stand_clean_rejects_non_empty_kafka_with_make_clean_hint(monkeyp
|
|||||||
def execute(self, sql):
|
def execute(self, sql):
|
||||||
return [(0, 0, 0, 0)]
|
return [(0, 0, 0, 0)]
|
||||||
|
|
||||||
monkeypatch.setattr(airflow_control, "KafkaTopicInspector", Inspector)
|
monkeypatch.setattr(stand_clean, "KafkaTopicInspector", Inspector)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="make clean"):
|
with pytest.raises(RuntimeError, match="make clean"):
|
||||||
airflow_control.assert_stand_clean("kafka:29092", Hook())
|
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):
|
def test_check_manifest_compares_clickhouse_stats(base_config):
|
||||||
"""Check падает, когда контрольные числа ClickHouse расходятся с manifest."""
|
"""Check падает, когда контрольные числа ClickHouse расходятся с manifest."""
|
||||||
from clickstream_generator.airflow_control import assert_clickhouse_matches_manifest
|
from clickstream_generator.airflow_control import assert_clickhouse_matches_manifest
|
||||||
|
|||||||
@@ -66,6 +66,66 @@ def test_compose_mounts_generator_code_without_socket_and_gates_live_service():
|
|||||||
assert "profiles:\n - live-generator" in text
|
assert "profiles:\n - live-generator" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_and_clean_include_live_generator_profile():
|
||||||
|
"""Остановка стенда удаляет профильный live-генератор."""
|
||||||
|
text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "down:\n\t$(COMPOSE) --profile live-generator down" in text
|
||||||
|
assert "clean:\n\t$(COMPOSE) --profile live-generator down -v --remove-orphans" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_generator_paths_precheck_clean_stand_before_writes():
|
||||||
|
"""Backfill/import с хоста проверяют чистый стенд до записи."""
|
||||||
|
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
import_artifact = (
|
||||||
|
REPO_ROOT / "scripts" / "import_startup_history_artifact.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in run_generator
|
||||||
|
assert run_generator.index("assert_stand_clean.sh") < run_generator.index(
|
||||||
|
"run --rm --no-deps"
|
||||||
|
)
|
||||||
|
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" clean" in import_artifact
|
||||||
|
assert import_artifact.index("assert_stand_clean.sh") < import_artifact.index(
|
||||||
|
"startup_history_artifact import"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_console_continue_checks_state_or_clean_stand_before_live_start():
|
||||||
|
"""Continue не стартует новый live-мир на грязном стенде без state."""
|
||||||
|
run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "elif [[ \"${VERB}\" == \"continue\" ]]" in run_generator
|
||||||
|
assert "bash \"${SCRIPT_DIR}/assert_stand_clean.sh\" continue" in run_generator
|
||||||
|
assert run_generator.index("assert_stand_clean.sh\" continue") < run_generator.index(
|
||||||
|
"up -d --build generator"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_start_paths_include_live_generator_profile():
|
||||||
|
"""Все чистые сбросы видят профильный live-генератор."""
|
||||||
|
generated_history = (
|
||||||
|
REPO_ROOT / "scripts" / "run_generated_history_analytics.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "--profile live-generator down -v --remove-orphans" in generated_history
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_clean_precheck_checks_live_before_kafka_and_stg():
|
||||||
|
"""Host backfill/import сначала проверяет, что live-генератор не запущен."""
|
||||||
|
text = (REPO_ROOT / "scripts" / "assert_stand_clean.sh").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "--mode live" in text
|
||||||
|
assert text.index("--mode live") < text.index("clickhouse-client")
|
||||||
|
assert text.index("clickhouse-client") < text.index("--mode \"${CHECK_MODE}\"")
|
||||||
|
|
||||||
|
|
||||||
def test_airflow_and_generator_kafka_dependency_versions_match():
|
def test_airflow_and_generator_kafka_dependency_versions_match():
|
||||||
"""Airflow и генератор используют одну версию kafka-python."""
|
"""Airflow и генератор используют одну версию kafka-python."""
|
||||||
airflow_req = (REPO_ROOT / "airflow" / "requirements.txt").read_text(encoding="utf-8")
|
airflow_req = (REPO_ROOT / "airflow" / "requirements.txt").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from generator import (
|
|||||||
KafkaBatchHistory,
|
KafkaBatchHistory,
|
||||||
TickStreamGenerator,
|
TickStreamGenerator,
|
||||||
)
|
)
|
||||||
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
|
||||||
|
|
||||||
class TestBatchRecordWithDictConversion:
|
class TestBatchRecordWithDictConversion:
|
||||||
@@ -1162,6 +1163,31 @@ class TestGeneratorServiceState:
|
|||||||
state_manager.load.assert_not_called()
|
state_manager.load.assert_not_called()
|
||||||
assert service._tick == 0
|
assert service._tick == 0
|
||||||
|
|
||||||
|
def test_known_old_state_version_fails_instead_of_starting_new_world(
|
||||||
|
self, base_config, caplog
|
||||||
|
):
|
||||||
|
"""GEN_STATE_RESET=false не скрывает старый state за чистым стартом."""
|
||||||
|
state_manager = MagicMock()
|
||||||
|
state_manager.load.side_effect = UnsupportedStateVersionError("2.0", "3.0")
|
||||||
|
|
||||||
|
with patch("clickstream_generator.service.start_http_server"), \
|
||||||
|
patch("clickstream_generator.service.ensure_topics"), \
|
||||||
|
patch("clickstream_generator.service.KafkaPublisher"), \
|
||||||
|
patch("clickstream_generator.service.KafkaBatchHistory"), \
|
||||||
|
patch(
|
||||||
|
"clickstream_generator.service.KafkaStateManager",
|
||||||
|
return_value=state_manager,
|
||||||
|
), \
|
||||||
|
patch.object(GeneratorService, "_main_loop", return_value=None), \
|
||||||
|
caplog.at_level(logging.ERROR, logger="generator"):
|
||||||
|
|
||||||
|
service = GeneratorService(base_config)
|
||||||
|
with pytest.raises(ValueError, match="state version 2.0.*GEN_STATE_RESET=true"):
|
||||||
|
service.start()
|
||||||
|
|
||||||
|
assert service._tick == 0
|
||||||
|
assert "Readable generator state uses an old format" in caplog.text
|
||||||
|
|
||||||
def test_invalid_restored_state_starts_fresh(self, base_config, caplog):
|
def test_invalid_restored_state_starts_fresh(self, base_config, caplog):
|
||||||
"""Сервис не падает, если state ссылается на неизвестный профиль."""
|
"""Сервис не падает, если state ссылается на неизвестный профиль."""
|
||||||
state_manager = MagicMock()
|
state_manager = MagicMock()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from generator import GeneratorState, KafkaStateManager
|
from generator import GeneratorState, KafkaStateManager
|
||||||
|
from clickstream_generator.state import UnsupportedStateVersionError
|
||||||
|
|
||||||
|
|
||||||
def _make_valid_rng_state(seed: int = 42):
|
def _make_valid_rng_state(seed: int = 42):
|
||||||
@@ -423,6 +424,14 @@ class TestGeneratorStateValidation:
|
|||||||
with pytest.raises(ValueError, match="version"):
|
with pytest.raises(ValueError, match="version"):
|
||||||
GeneratorState.from_dict(data)
|
GeneratorState.from_dict(data)
|
||||||
|
|
||||||
|
def test_from_dict_rejects_known_old_state_version_loudly(self):
|
||||||
|
"""Известная старая версия state даёт отдельную ошибку миграции."""
|
||||||
|
data = _make_valid_state_data()
|
||||||
|
data["version"] = "2.0"
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedStateVersionError, match="2.0.*3.0"):
|
||||||
|
GeneratorState.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
class TestKafkaStateManager:
|
class TestKafkaStateManager:
|
||||||
"""Тесты менеджера состояния."""
|
"""Тесты менеджера состояния."""
|
||||||
|
|||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Проверяет, что новый мир генератора не смешается со старым.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
COMPOSE_BIN="${COMPOSE_BIN:-docker compose}"
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS_HOST="${KAFKA_BOOTSTRAP_SERVERS_HOST:-localhost:9092}"
|
||||||
|
GENERATOR_METRICS_URL_HOST="${GENERATOR_METRICS_URL_HOST:-http://localhost:9109/metrics}"
|
||||||
|
CLICKHOUSE_SERVICE="${CLICKHOUSE_SERVICE:-clickhouse}"
|
||||||
|
CLICKHOUSE_DB="${CLICKHOUSE_DB:-default}"
|
||||||
|
CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}"
|
||||||
|
CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-123456}"
|
||||||
|
CHECK_MODE="${1:-clean}"
|
||||||
|
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
|
||||||
|
if [[ "${CHECK_MODE}" != "clean" && "${CHECK_MODE}" != "continue" ]]; then
|
||||||
|
echo "Ошибка: неизвестный режим предпроверки: ${CHECK_MODE}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${CHECK_MODE}" == "clean" ]]; then
|
||||||
|
echo "Предпроверка: live-генератор не должен быть запущен."
|
||||||
|
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
||||||
|
uv run python -m clickstream_generator.stand_clean \
|
||||||
|
--mode live \
|
||||||
|
--live-metrics-url "${GENERATOR_METRICS_URL_HOST}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Предпроверка: Kafka data-топики и STG должны быть пустыми или state совместим."
|
||||||
|
|
||||||
|
if ! stg_counts="$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
||||||
|
--user="${CLICKHOUSE_USER}" \
|
||||||
|
--password="${CLICKHOUSE_PASSWORD}" \
|
||||||
|
--database="${CLICKHOUSE_DB}" \
|
||||||
|
--format=TSV \
|
||||||
|
--query "
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM stg.browser_raw) AS browser_raw,
|
||||||
|
(SELECT count() FROM stg.location_raw) AS location_raw,
|
||||||
|
(SELECT count() FROM stg.device_raw) AS device_raw,
|
||||||
|
(SELECT count() FROM stg.geo_raw) AS geo_raw
|
||||||
|
")"; then
|
||||||
|
echo "Ошибка: не удалось проверить STG через ClickHouse." >&2
|
||||||
|
echo "Проверьте, что ClickHouse запущен и DDL применён: make ddl" >&2
|
||||||
|
echo "${stg_counts}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS="${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
||||||
|
GEN_DATA_DIR="${REPO_ROOT}/data" \
|
||||||
|
PYTHONPATH="${REPO_ROOT}/generator/src" \
|
||||||
|
uv run python -m clickstream_generator.stand_clean \
|
||||||
|
--mode "${CHECK_MODE}" \
|
||||||
|
--kafka-bootstrap-servers "${KAFKA_BOOTSTRAP_SERVERS_HOST}" \
|
||||||
|
--stg-counts "${stg_counts}"
|
||||||
@@ -43,13 +43,16 @@ echo "GEN_MODEL_T0=${GEN_MODEL_T0}"
|
|||||||
echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}"
|
echo "GEN_MODEL_T_END=${GEN_MODEL_T_END}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "Шаг 1: запуск Kafka"
|
echo "Шаг 1: запуск Kafka и ClickHouse"
|
||||||
${COMPOSE_BIN} up -d kafka
|
${COMPOSE_BIN} up -d kafka clickhouse
|
||||||
|
|
||||||
echo "Шаг 2: сборка образа генератора"
|
echo "Шаг 2: предпроверка чистого стенда"
|
||||||
|
bash "${SCRIPT_DIR}/assert_stand_clean.sh" clean
|
||||||
|
|
||||||
|
echo "Шаг 3: сборка образа генератора"
|
||||||
${COMPOSE_BIN} build generator
|
${COMPOSE_BIN} build generator
|
||||||
|
|
||||||
echo "Шаг 3: воспроизведение артефакта в Kafka"
|
echo "Шаг 4: воспроизведение артефакта в Kafka"
|
||||||
${COMPOSE_BIN} run --rm --no-deps \
|
${COMPOSE_BIN} run --rm --no-deps \
|
||||||
-v "${ARTIFACT_DIR}:/startup-artifacts:ro" \
|
-v "${ARTIFACT_DIR}:/startup-artifacts:ro" \
|
||||||
-e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \
|
-e GEN_LAUNCH_PROFILE="${GEN_LAUNCH_PROFILE}" \
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ echo ""
|
|||||||
|
|
||||||
if [[ "${CLEAN_START}" == "1" ]]; then
|
if [[ "${CLEAN_START}" == "1" ]]; then
|
||||||
echo "Шаг 0: очистка volumes ClickHouse/Kafka/state"
|
echo "Шаг 0: очистка volumes ClickHouse/Kafka/state"
|
||||||
${COMPOSE_BIN} down -v --remove-orphans
|
${COMPOSE_BIN} --profile live-generator down -v --remove-orphans
|
||||||
else
|
else
|
||||||
echo "Шаг 0: CLEAN_START=0, очистка пропущена"
|
echo "Шаг 0: CLEAN_START=0, очистка пропущена"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -52,13 +52,16 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "${VERB}" == "backfill" ]]; then
|
if [[ "${VERB}" == "backfill" ]]; then
|
||||||
echo "Шаг 1: запуск Kafka"
|
echo "Шаг 1: запуск Kafka и ClickHouse"
|
||||||
${COMPOSE_BIN} up -d kafka
|
${COMPOSE_BIN} up -d kafka clickhouse
|
||||||
|
|
||||||
echo "Шаг 2: сборка образа генератора"
|
echo "Шаг 2: предпроверка чистого стенда"
|
||||||
|
bash "${SCRIPT_DIR}/assert_stand_clean.sh" clean
|
||||||
|
|
||||||
|
echo "Шаг 3: сборка образа генератора"
|
||||||
${COMPOSE_BIN} build generator
|
${COMPOSE_BIN} build generator
|
||||||
|
|
||||||
echo "Шаг 3: backfill стартовой истории"
|
echo "Шаг 4: backfill стартовой истории"
|
||||||
${COMPOSE_BIN} run --rm --no-deps \
|
${COMPOSE_BIN} run --rm --no-deps \
|
||||||
-e GEN_RUN_MODE="${GEN_RUN_MODE}" \
|
-e GEN_RUN_MODE="${GEN_RUN_MODE}" \
|
||||||
-e GEN_STATE_RESET="${GEN_STATE_RESET}" \
|
-e GEN_STATE_RESET="${GEN_STATE_RESET}" \
|
||||||
@@ -74,6 +77,15 @@ if [[ "${VERB}" == "backfill" ]]; then
|
|||||||
-e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \
|
-e GEN_MIN_EVENTS_PER_TICK="${GEN_MIN_EVENTS_PER_TICK}" \
|
||||||
-e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \
|
-e GEN_MAX_EVENTS_PER_TICK="${GEN_MAX_EVENTS_PER_TICK}" \
|
||||||
generator
|
generator
|
||||||
|
elif [[ "${VERB}" == "continue" ]]; then
|
||||||
|
echo "Шаг 1: запуск Kafka и ClickHouse"
|
||||||
|
${COMPOSE_BIN} up -d kafka clickhouse
|
||||||
|
|
||||||
|
echo "Шаг 2: проверка state или чистого стенда"
|
||||||
|
bash "${SCRIPT_DIR}/assert_stand_clean.sh" continue
|
||||||
|
|
||||||
|
echo "Шаг 3: запуск live-генератора"
|
||||||
|
${COMPOSE_BIN} up -d --build generator
|
||||||
else
|
else
|
||||||
echo "Шаг 1: запуск live-генератора"
|
echo "Шаг 1: запуск live-генератора"
|
||||||
${COMPOSE_BIN} up -d --build generator
|
${COMPOSE_BIN} up -d --build generator
|
||||||
|
|||||||
Reference in New Issue
Block a user