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,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