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
+169
View File
@@ -0,0 +1,169 @@
"""Проигрыватель и его интерфейс: побайтовый повтор, построчность, приёмник.
Главное здесь — обещание раздела 2 спеки, доведённое до байтов на диске: два
прогона одного дня дают тот же файл. Тем же тестом сторожится построчность:
одно событие — одна строка. Дешёвая половина контракта транспорта — склейка
двух событий сломала бы разбор целиком, потому что хранилище читает топик
байтами.
"""
import hashlib
import subprocess
import sys
from contextlib import closing
from dataclasses import replace
import pytest
from clickstream_generator import cli, player
from clickstream_generator import day as day_module
from clickstream_generator.seeds import CANONICAL_SEED
from clickstream_generator.sinks import FileSink
DAY = 2
# Переменные, которыми зовущий задаёт прогон. Тест, читающий их из окружения
# машины, зелен у одного и красен у другого — а на этой машине они как раз и
# живут: стенд их экспортирует.
LAUNCH_VARIABLES = (
"GENERATOR_SEED",
"GENERATOR_DAY",
"GENERATOR_DAYS",
"GENERATOR_LIMIT",
"GENERATOR_SPEED",
"GENERATOR_FILE",
"KAFKA_BOOTSTRAP_SERVERS",
"KAFKA_TOPIC",
)
@pytest.fixture(autouse=True)
def bare_environment(monkeypatch):
"""Прогон тестов не зависит от того, что задано в окружении машины."""
for name in LAUNCH_VARIABLES:
monkeypatch.delenv(name, raising=False)
def _play(path, **options):
"""Проиграть в файл и вернуть итог прогона."""
with closing(FileSink(path)) as sink:
return player.play(sink, seed=CANONICAL_SEED, first_day=DAY, **options)
def test_two_runs_give_the_same_file(tmp_path):
"""Два прогона дня — одинаковые байты и строка на событие.
Хеши сравниваются целиком, а не построчно: обещание побайтовое, и
расхождение в одной запятой обязано покраснеть так же, как расхождение в
наборе событий.
Прогоны идут **разными процессами**, а не двумя вызовами в одном. Внутри
одного интерпретатора зерно хеширования общее на оба прогона, поэтому
зависимость канона от порядка обхода множества такой тест не увидел бы
никогда — а это ровно тот класс расхождений, ради которого обещание и
дано. Заодно день играется тем же путём, каким генератор зовут на самом
деле: через интерфейс запуска, а не через функцию.
"""
first, second = tmp_path / "first.jsonl", tmp_path / "second.jsonl"
_run_apart(first)
_run_apart(second)
assert _digest(first) == _digest(second)
events = len(day_module.stream(CANONICAL_SEED, DAY))
assert len(first.read_bytes().splitlines()) == events
def _run_apart(path) -> None:
"""Проиграть день отдельным процессом; окружение он берёт от нас."""
finished = subprocess.run(
[
sys.executable,
"-m",
"clickstream_generator",
"batch",
"--day",
str(DAY),
"--file",
str(path),
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert finished.returncode == 0, finished.stderr
def test_days_play_in_a_row(tmp_path, monkeypatch):
"""Дни идут подряд от названного, а пачка считается на весь прогон.
Восемь дней одним запуском — то, чем зальётся зерновой мир (#42), поэтому
порядок дней проверяется, а не предполагается. День здесь подменён коротким:
проверяется ход проигрывателя, а не содержимое дня, и платить за полсотни
тысяч событий трижды незачем.
"""
short = _shorten(day_module.stream(CANONICAL_SEED, DAY), rows=2)
asked: list[int] = []
def stream(seed: int, number: int):
asked.append(number)
return short
monkeypatch.setattr(player.day_module, "stream", stream)
path = tmp_path / "three-days.jsonl"
played = _play(path, days=3, limit=5)
assert asked == [DAY, DAY + 1, DAY + 2]
# Двум дням хватило по два события, третьему досталось последнее: потолок
# считается на весь прогон, а не на каждый день заново.
assert played.events == 5
assert len(path.read_bytes().splitlines()) == 5
FILE = ["--file", "/dev/null"]
KAFKA = ["--brokers", "kafka:29092", "--topic", "hits"]
@pytest.mark.parametrize(
"argv",
[
pytest.param(["batch", "--day", "0"], id="приёмник не назван"),
pytest.param(["batch", "--day", "0", *FILE, *KAFKA], id="названы оба"),
pytest.param(
["batch", "--day", "0", "--brokers", "kafka:29092"], id="топик не назван"
),
pytest.param(["batch", *FILE], id="день не назван"),
],
)
def test_run_is_refused_loudly(argv):
"""Всё, чего проигрыватель не знает, — отказ до первого события.
Правило одно: параметр, описывающий окружение стенда или позицию на оси
мира, своего умолчания не имеет. Тихо подставленное умолчание — чужой факт,
выданный за наш, и прогон отчитается о нём успехом.
"""
with pytest.raises(SystemExit) as refusal:
cli.main(argv)
assert refusal.value.code == 2
def test_cli_plays_a_limited_batch_into_a_file(tmp_path):
"""Тот самый вызов, которым проверки #43 берут срез: код возврата — 0."""
path = tmp_path / "cli.jsonl"
assert cli.main(["batch", "--day", "0", "--limit", "5", "--file", str(path)]) == 0
assert len(path.read_bytes().splitlines()) == 5
def _shorten(today: day_module.Day, rows: int) -> day_module.Day:
"""Тот же день, но короткий: первые строки всех его рядов."""
return replace(
today,
columns={name: value[:rows] for name, value in today.columns.items()},
page=today.page[:rows],
product=today.product[:rows],
)
def _digest(path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
+72
View File
@@ -0,0 +1,72 @@
"""Канон сериализатора: набор ключей, их порядок и форма на проводе.
Сторожится здесь то, на чём стоит сторона хранилища: строгий приём сверяет
набор ключей и разбирает даты как ISO. Разойдись сериализатор с этим — событие
уйдёт в брак целиком, а поймается это уже на стенде.
"""
import json
import pytest
from clickstream_generator import day as day_module
from clickstream_generator import schema, serialize
from clickstream_generator.seeds import CANONICAL_SEED
DAY = 2
@pytest.fixture(scope="module")
def events() -> list[dict[str, object]]:
"""События дня, разобранные обратно из канонических байтов."""
today = day_module.stream(CANONICAL_SEED, DAY)
return [json.loads(payload) for payload in serialize.events(today)]
def test_every_event_carries_every_column(events):
"""Все 47 ключей всегда и в порядке контракта — у любого события.
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
канона: от него зависят байты, а значит и хеши манифеста.
"""
names = [column.name for column in schema.COLUMNS]
for event in events:
assert list(event) == names
def test_empty_is_a_value_not_a_hole(events):
"""У просмотра страницы торговые колонки пусты, но они есть."""
pageview = next(event for event in events if event["EventType"] == "pageview")
assert pageview["purchaseID"] == []
assert pageview["productPrice"] == []
assert pageview["GoalsReached"] == []
assert pageview["ecommerce"] == ""
def test_dates_go_as_iso(events):
"""Даты читаются глазами: `2026-06-03` и `2026-06-03T12:34:56Z`.
Весь смысл слоя STG в том, что менти открывает колонку `raw` обычным
клиентом и разбирает событие сам; число эпохи этот урок убивает.
"""
for event in events[:100]:
assert event["EventDate"] == "2026-06-03"
assert event["UTCEventTime"].endswith("Z")
assert len(event["UTCEventTime"]) == len("2026-06-03T12:34:56Z")
def test_ecommerce_is_a_string_with_json_inside(events):
"""`ecommerce` уезжает строкой, как отдаёт Метрика, — материал лабы."""
purchase = next(event for event in events if event["EventType"] == "purchase")
assert isinstance(purchase["ecommerce"], str)
inside = json.loads(purchase["ecommerce"])
assert inside["purchase"]["actionField"]["id"] == purchase["purchaseID"][0]
def test_limit_takes_the_beginning_of_the_day(events):
"""Ограниченная пачка — начало дня, а не его пересборка другими байтами."""
today = day_module.stream(CANONICAL_SEED, DAY)
short = serialize.events(today, limit=10)
assert len(short) == 10
assert [json.loads(payload) for payload in short] == events[:10]