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
@@ -0,0 +1,157 @@
"""Проигрыватель: гонит дни мира в приёмник — пачкой или с темпом живого дня.
Состояния у него нет (спека генератора, раздел 9). Зерно и номер дня приходят
параметрами, позицию на оси он не хранит и из данных не выводит: её ведёт тот,
кто зовёт, — на этапе 5 это переменная Airflow у дага `next_day`. Отсюда и
переигровка обрыва: позвали тот же день заново — получили те же `WatchID`, и
дедупликация склеила повтор.
Два режима отличаются только темпом. Пакетный шлёт события подряд, без пауз, —
это заливка снимка и переигровка дня. Живой держит модельное время: событие
уезжает тогда, когда до него дошли модельные часы, поделённые на ускорение.
По умолчанию ускорение ×60 — модельные сутки за 24 реальные минуты (спека,
раздел 5): суточная волна разворачивается на глазах.
Тайминги печатаются раздельно — генерация и доставка, как требует спека
(раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит манифест.
"""
import logging
import time
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import day as day_module
from clickstream_generator import serialize
from clickstream_generator.sinks import Sink
log = logging.getLogger(__name__)
# Как часто живой режим отчитывается о ходе дня. Минута реального времени — это
# час модельного при ×60: отчёт на каждый модельный час.
REPORT_SECONDS = 60.0
@dataclass(frozen=True, slots=True)
class Played:
"""Итог прогона: сколько уехало и за сколько."""
events: int
generated_seconds: float
delivered_seconds: float
def play(
sink: Sink,
seed: int,
first_day: int,
days: int = 1,
limit: int | None = None,
speed: float | None = None,
) -> Played:
"""Проиграть `days` дней подряд начиная с `first_day` в приёмник `sink`.
`limit` — потолок событий на весь прогон: срез для того, кто смотрит на
конвейер и не хочет ждать целый день. `speed` — ускорение живого режима;
`None` означает пакетный, то есть без пауз вовсе.
"""
events = 0
generated = 0.0
delivered = 0.0
for number in range(first_day, first_day + days):
left = None if limit is None else limit - events
if left is not None and left <= 0:
break
clock = time.monotonic()
today = day_module.stream(seed, number)
payloads = serialize.events(today, limit=left)
seconds = _event_seconds(today, len(payloads))
spent = time.monotonic() - clock
generated += spent
log.info("день %d: событий %d, генерация %.1f с", number, len(payloads), spent)
clock = time.monotonic()
if speed is None:
_send(sink, payloads)
else:
_send_paced(sink, payloads, seconds, speed)
# Рубеж дня: доставка асинхронна, и без него напечатанное время
# означало бы только «события легли в очередь отправителя».
sink.flush()
spent = time.monotonic() - clock
delivered += spent
events += len(payloads)
log.info(
"день %d: отправлено %d, доставка %.1f с", number, len(payloads), spent
)
log.info(
"итого отправлено %d событий: генерация %.1f с, доставка %.1f с",
events,
generated,
delivered,
)
return Played(
events=events, generated_seconds=generated, delivered_seconds=delivered
)
def _send(sink: Sink, payloads: list[bytes]) -> None:
"""Пакетно: подряд и без пауз."""
for payload in payloads:
sink.send(payload)
def _send_paced(
sink: Sink, payloads: list[bytes], seconds: NDArray[np.int64], speed: float
) -> None:
"""С темпом: событие уезжает, когда до него дошло модельное время.
Отставание не догоняется рывком и не прячется: спешить некуда — событие
всё равно уедет, — а вот увидеть отставание в логе нужно, иначе живой
режим врёт про темп. Обгонять модельное время нельзя, отставать можно, и
именно это печатает отчёт.
"""
started = time.monotonic()
origin = int(seconds[0]) if seconds.size else 0
reported = started
lag = 0.0
for number, payload in enumerate(payloads):
due = started + (int(seconds[number]) - origin) / speed
now = time.monotonic()
if now < due:
time.sleep(due - now)
else:
lag = max(lag, now - due)
sink.send(payload)
now = time.monotonic()
if now - reported >= REPORT_SECONDS:
log.info(
"проиграно %d из %d, модельное время %s, лаг %.1f с",
number + 1,
len(payloads),
_model_time(int(seconds[number]) - origin),
lag,
)
reported = now
lag = 0.0
def _event_seconds(today: day_module.Day, count: int) -> NDArray[np.int64]:
"""Секунды событий абсолютной меткой — по ним живой режим держит темп."""
times: NDArray[np.datetime64] = today.columns["UTCEventTime"][:count]
return times.astype("datetime64[s]").astype(np.int64)
def _model_time(elapsed: int) -> str:
"""Прожитое модельное время дня в виде `ЧЧ:ММ` — от первого события."""
return f"{elapsed // 3600:02d}:{elapsed % 3600 // 60:02d}"