- Зачем:
- до сих пор генератор умел собирать день, но не умел его отдать: топик
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>
170 lines
7.3 KiB
Python
170 lines
7.3 KiB
Python
"""Проигрыватель и его интерфейс: побайтовый повтор, построчность, приёмник.
|
|
|
|
Главное здесь — обещание раздела 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()
|