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:
2026-08-07 12:05:11 +03:00
co-authored by Claude Opus 5
parent 8903b6a054
commit a61f7934ec
19 changed files with 1184 additions and 16 deletions
+207
View File
@@ -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)