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,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()
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user