feat(generator): сериализатор, приёмники, проигрыватель и запуск контейнером
- Зачем:
- до сих пор генератор умел собирать день, но не умел его отдать: топик
hits наполнялся пробником, а не настоящими данными. Тикет #41 доводит
события до стенда и закрывает форму на проводе, на которую обопрётся
типизированный ODS (#43).
- сериализатор один по решению спеки: второе место, печатающее событие в
JSON, разошлось бы с первым молча.
- Что:
- serialize.py — канонический сериализатор на orjson: единственное место,
где событие целиком становится JSON; 47 ключей всегда, «пусто» это
пустое значение, даты ISO-8601, ecommerce строкой. Вложенный блок
ecommerce в commerce.py вторым сериализатором не считается — правило
про событие, а не про блок внутри него.
- sinks.py — приёмники: файл (одно событие — одна строка) и Kafka (одно
событие — одно сообщение). Ключа у сообщения нет: WatchID уникален,
ключом он был бы ключом лишь на вид.
- player.py, cli.py — проигрыватель и интерфейс запуска: режимы batch и
live (темп ×60), несколько дней одним запуском, ограниченная пачка,
раздельные тайминги генерации и доставки, лаг в логе.
- день на оси и имя топика умолчаний не имеют: параметр, описывающий
среду или позицию, приходит от зовущего, иначе отказ до генерации.
Умолчания зерна, числа дней и темпа остаются — они описывают мир.
- generator/Dockerfile — свой образ: зависимости из uv.lock, база
закреплена до патча, раскладка репозитория сохранена ради каталога
товаров. Образ Airflow не тронут.
- разовая служба compose под профилем, цели generate-batch и
generate-live, .dockerignore, tmp/ в .gitignore.
- решения внесены в спеку (разделы 4, 8, 9), быстрый старт — в README.
- Проверка:
- make test 406 passed, make lint, make typecheck, make config-test.
- побайтовый детерминизм: два прогона дня в независимых процессах дают
один sha256; день в контейнере совпадает с днём на машине.
- на стенде: пакетный день доехал до stg.hits_raw_dist, счёт по
Distributed сошёлся — отправлено 50626, в таблице 50626.
- топик прочитан обеими нодами: clickhouse-01 раздел 0 (26368),
clickhouse-02 раздел 1 (24258).
- живой день: модельное время 01:00 на 60-й секунде, 02:00 на 120-й —
темп ×60, лаг печатается.
- форма на проводе в колонке raw: даты читаются глазами, ecommerce лежит
строкой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""Интерфейс запуска: `python -m clickstream_generator batch|live`.
|
||||
|
||||
Зовущий — контейнер (спека генератора, раздел 9), и интерфейс сделан под него:
|
||||
параметры приходят аргументами или переменными окружения, логи идут в
|
||||
стандартный вывод, итог виден кодом возврата. Ни файла настроек, ни состояния
|
||||
между запусками нет — позиция на оси принадлежит тому, кто зовёт.
|
||||
|
||||
Зовущих трое, и все трое видны в форме команд:
|
||||
|
||||
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
||||
- заливка зернового мира (#42) — восемь дней подряд одним запуском: `--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)
|
||||
Reference in New Issue
Block a user