Зачем Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому же состоянию, а в git лежит то, чем это состояние проверяется. Что - Опись мира `data/world-inventory.json`: паспорт (зерно, версия генератора, хеш каталога) и по строке на каждый из восьми дней — дата, число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит `test_inventory.py` — тем же способом, что свежесть описания выгрузки. - Разовая служба `world-init` вышла из-под профиля и играет в топик восемь дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait` считает успешно отработавшую службу упавшей. - `scripts/wait-for-world.sh` — вторая половина `make up`: приём асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения заливки. Ограниченный цикл опроса, не пауза наугад. - Девятая проверка `make check-clickhouse`: подневный счёт событий против описи, рамка по датам стартового мира, счёт через `FINAL`. При расхождении называет, где искать, — в событиях или в браке. - Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал 1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с датой. Раздел 9 спеки закрыт: открытых вопросов не осталось. - Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром (решение владельца). Оба заведены в словарь CONTEXT.md. Проверка `make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий. `make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с), `make test` — 407 тестов за 71 с, `make lint`, `make typecheck`, `make config-test` зелёные. Что проверка умеет краснеть, снято двумя поломками: снос партиции 2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье — «сломан разбор». Строки опыта убраны, день переигран, счёт вернулся. Тест свежести проверен молчаливой правкой цены в каталоге: покраснел. Ссылка: #42
208 lines
9.8 KiB
Python
208 lines
9.8 KiB
Python
"""Интерфейс запуска: `python -m clickstream_generator batch|live`.
|
|
|
|
Зовущий — контейнер (спека генератора, раздел 9), и интерфейс сделан под него:
|
|
параметры приходят аргументами или переменными окружения, логи идут в
|
|
стандартный вывод, итог виден кодом возврата. Ни файла настроек, ни состояния
|
|
между запусками нет — позиция на оси принадлежит тому, кто зовёт.
|
|
|
|
Зовущих трое, и все трое видны в форме команд:
|
|
|
|
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
|
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
|
|
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
|
|
|
|
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
|
|
в числе, а тем, что у них разное: у пакетного есть `--limit` и нет ожидания, у
|
|
живого есть `--speed` и нет пачки. Один флаг `--speed 0` прятал бы это
|
|
различие за числом, а команда называет его словом. Ограниченная пачка живому
|
|
дню не полагается: ждать там нечего — ожидание снимает ускорение.
|
|
|
|
**Приёмник выбирается тем, что для него назвали**: `--file` или `--brokers`.
|
|
Оба сразу — ошибка, ни одного — тоже: молча выбранный по умолчанию приёмник
|
|
однажды напишет в файл то, чего ждали в Kafka.
|
|
|
|
**Умолчания есть только у того, что описывает мир, а не окружение.** Зерно,
|
|
число дней и темп живого дня — свойства модели, они заданы спекой и тикетом,
|
|
и умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес
|
|
брокера, имя топика и путь файла — факты стенда, на котором нас запустили:
|
|
генератор их не знает и знать не должен, он отдельная и переносимая сущность.
|
|
Подставь он своё умолчание — забытый параметр превратился бы в неверный ответ,
|
|
поданный как успех.
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import sys
|
|
from collections.abc import Callable, Sequence
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
|
|
from clickstream_generator import player
|
|
from clickstream_generator.seeds import CANONICAL_SEED
|
|
from clickstream_generator.sinks import FileSink, KafkaSink, Sink
|
|
|
|
DEFAULT_SPEED = 60.0
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Разобрать аргументы, проиграть дни, вернуть код возврата."""
|
|
parser = _parser()
|
|
options = parser.parse_args(argv)
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(message)s", stream=sys.stdout, force=True
|
|
)
|
|
|
|
_check(parser, options)
|
|
try:
|
|
with closing(_sink(parser, options)) as sink:
|
|
player.play(
|
|
sink,
|
|
seed=options.seed,
|
|
first_day=options.day,
|
|
days=options.days,
|
|
limit=options.limit,
|
|
speed=options.speed,
|
|
)
|
|
except (OSError, RuntimeError) as failure:
|
|
logging.error("прогон не удался: %s", failure)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _parser() -> argparse.ArgumentParser:
|
|
"""Разбор командной строки; умолчания приходят из окружения."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m clickstream_generator",
|
|
description="Проигрыватель модельных дней кликстрима в файл или Kafka.",
|
|
)
|
|
modes = parser.add_subparsers(dest="mode", required=True)
|
|
|
|
batch = modes.add_parser(
|
|
"batch", help="пачкой, без пауз: заливка снимка и переигровка дня"
|
|
)
|
|
_common(batch)
|
|
batch.set_defaults(speed=None)
|
|
batch.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=_env_int("GENERATOR_LIMIT", None),
|
|
metavar="N",
|
|
help="взять не больше N событий на весь прогон, а не день целиком",
|
|
)
|
|
|
|
live = modes.add_parser("live", help="живой день: темп модельного времени")
|
|
_common(live)
|
|
live.set_defaults(limit=None)
|
|
live.add_argument(
|
|
"--speed",
|
|
type=float,
|
|
default=_env_float("GENERATOR_SPEED", DEFAULT_SPEED),
|
|
metavar="X",
|
|
help=f"ускорение модельного времени (по умолчанию ×{DEFAULT_SPEED:.0f})",
|
|
)
|
|
return parser
|
|
|
|
|
|
def _common(parser: argparse.ArgumentParser) -> None:
|
|
"""Что спрашивают у обоих режимов: какой мир, какие дни и куда."""
|
|
parser.add_argument(
|
|
"--seed",
|
|
type=int,
|
|
default=_env_int("GENERATOR_SEED", CANONICAL_SEED),
|
|
metavar="N",
|
|
help="зерно мира (по умолчанию каноническое)",
|
|
)
|
|
parser.add_argument(
|
|
"--day",
|
|
type=int,
|
|
default=_env_int("GENERATOR_DAY", None),
|
|
metavar="D",
|
|
help="номер дня на оси мира (D0 — первый); умолчания нет —"
|
|
" позицию ведёт зовущий",
|
|
)
|
|
parser.add_argument(
|
|
"--days",
|
|
type=int,
|
|
default=_env_int("GENERATOR_DAYS", 1),
|
|
metavar="N",
|
|
help="сколько дней подряд проиграть одним запуском",
|
|
)
|
|
parser.add_argument(
|
|
"--file",
|
|
type=Path,
|
|
default=_env("GENERATOR_FILE", None, Path),
|
|
metavar="ПУТЬ",
|
|
help="приёмник — файл: одно событие в строке",
|
|
)
|
|
parser.add_argument(
|
|
"--brokers",
|
|
default=os.environ.get("KAFKA_BOOTSTRAP_SERVERS"),
|
|
metavar="АДРЕС",
|
|
help="приёмник — Kafka: адреса брокеров через запятую",
|
|
)
|
|
parser.add_argument(
|
|
"--topic",
|
|
default=os.environ.get("KAFKA_TOPIC"),
|
|
metavar="ИМЯ",
|
|
help="топик Kafka; умолчания нет — имя топика принадлежит стенду, а не пакету",
|
|
)
|
|
|
|
|
|
def _check(parser: argparse.ArgumentParser, options: argparse.Namespace) -> None:
|
|
"""День на оси обязан быть назван — иначе прогон не начинается."""
|
|
if options.day is None:
|
|
parser.error(
|
|
"день на оси не назван: передайте --day или GENERATOR_DAY."
|
|
" Позицию ведёт зовущий — проигрыватель её не помнит"
|
|
)
|
|
|
|
|
|
def _sink(parser: argparse.ArgumentParser, options: argparse.Namespace) -> Sink:
|
|
"""Приёмник по названному: файл либо Kafka, но не оба и не ничего."""
|
|
if options.file and options.brokers:
|
|
parser.error("названы оба приёмника: оставьте --file либо --brokers")
|
|
if options.file:
|
|
return FileSink(options.file)
|
|
if options.brokers:
|
|
if not options.topic:
|
|
parser.error(
|
|
"топик не назван: передайте --topic или KAFKA_TOPIC."
|
|
" Имя топика — факт стенда, генератор его не знает"
|
|
)
|
|
try:
|
|
return KafkaSink(options.brokers, options.topic)
|
|
except ImportError:
|
|
# Ловим там, где возникает: обёрнутый вокруг всего прогона,
|
|
# этот перехват однажды объявил бы «нет клиента Kafka» о чужой
|
|
# сорванной загрузке модуля.
|
|
parser.error(
|
|
"приёмник Kafka требует клиента: поставьте пакет с группой"
|
|
" зависимостей kafka (`uv sync --extra kafka`)"
|
|
)
|
|
parser.error("приёмник не назван: нужен --file либо --brokers")
|
|
|
|
|
|
def _env[T](name: str, fallback: T, kind: Callable[[str], T]) -> T:
|
|
"""Значение переменной окружения нужного типа; нет переменной — умолчание.
|
|
|
|
Пустая строка считается отсутствием: в compose так выглядит переменная,
|
|
которую не задали, — `${GENERATOR_DAYS:-}`. Мусор в переменной называется
|
|
вместе с её именем: из контейнера иначе не видно, чьё это значение.
|
|
"""
|
|
value = os.environ.get(name)
|
|
if not value:
|
|
return fallback
|
|
try:
|
|
return kind(value)
|
|
except ValueError:
|
|
raise SystemExit(f"переменная {name} не разбирается: {value!r}") from None
|
|
|
|
|
|
def _env_int(name: str, fallback: int | None) -> int | None:
|
|
return _env(name, fallback, int)
|
|
|
|
|
|
def _env_float(name: str, fallback: float) -> float:
|
|
return _env(name, fallback, float)
|