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:
ddadminandClaude Opus 5 committed 2026-08-07 12:05:11 +03:00
1 parent 8903b6a054
commit a61f7934ec
19 files changed
+1184 -16

No files matched your search

+112 -8
View File
@@ -259,8 +259,7 @@
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
хранилища читает топик байтами и кладёт сообщение строкой
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
событий в одно сообщение сломала бы разбор целиком. Сторожится тестом
приёмника.
событий в одно сообщение сломала бы разбор целиком.
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
@@ -269,9 +268,10 @@
принимает ISO без плясок. Колонка `ecommerce` — строка, внутри которой лежит
экранированный JSON, как отдаёт Метрика.
Форму пинит хранилище (#43) как первый потребитель, сериализатор (#41)
её соблюдает. Порядок тикетов обратный порядку зависимости, поэтому здесь она
и записана — иначе каждый выберет своё, и разойдётся это уже после приёмки.
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
разойдётся это уже после приёмки.
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
Смена библиотеки меняет канонические байты, поэтому проходит как
@@ -428,6 +428,16 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
переменной Airflow: отсутствие переменной означает мир в зерновом
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
Довод — генератор отдельная и заменяемая сущность, привязывать его к
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
данными ClickHouse, так что позиция и мир чистятся одной командой.
Расхождение переменной с данными (менти почистил партицию руками) лечится
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
CI-генерация на amd64 и arm64.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
@@ -865,11 +875,105 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
причины, а не долю.
Решено при исполнении #41 (2026-08-07). Первые два пункта — решения владельца
грилингом 7 августа 2026 года, внесённые как есть; остальные приняты при
исполнении.
- **Генератор живёт в своём контейнере**, и на этапе 5 даг будет запускать
контейнер, а не импортировать пакет в процессе воркера. Довод учебный: так
граница между оркестратором и задачей видна глазами — у задачи своё
окружение, свой образ, свой процесс, а оркестратор передаёт параметры и
читает результат. Это та же ступенька, с которой в бою вырастает
`KubernetesPodOperator`. Побочно это единственный вариант, при котором
«генератор — отдельная и заменяемая сущность» перестаёт быть словами: его
зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для
`make test`, `make lint` и `make typecheck`. Цена названа и принимается:
чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это
доступ, равный root на хосте; на учебном стенде размен допустим, но
записывается уроком — в бою так не делают. Отклонено: *генератор в образе
Airflow, даг импортирует пакет* (так у стенда v1) — механика проще, но менти
видит лишь «даг позвал функцию», а платить пришлось бы сменой базового
образа всего стенда, переездом контекста сборки и смешением зависимостей;
*`ExternalPythonOperator` — задача в отдельном venv внутри образа Airflow*:
изоляцию даёт и сокета не требует, но границу видно в конфигурации, а не в
`docker ps` — остаётся запасным путём, если проброс сокета однажды окажется
неприемлемым.
- **Клиент Kafka — `confluent-kafka`.** Он уже стоит в образе Airflow, на нём
написан пробник `dags/test_kafka.py`, и его же использует официальный
провайдер Kafka для Airflow: один клиент на стенде вместо двух. Объявлен
необязательной группой зависимостей генератора (`[kafka]`), а образ ставит
её из лока: приёмник импортирует клиента внутри функции, поэтому пакет без
этой группы остаётся живым и переносимым — с файловым приёмником.
- **Интерфейс запуска — `python -m clickstream_generator batch|live`.**
Зовущий — контейнер: параметры приходят аргументами или переменными
окружения (`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`,
`GENERATOR_LIMIT`, `GENERATOR_SPEED`, `GENERATOR_FILE`,
`KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_TOPIC`), логи идут в стандартный вывод,
итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и
приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на
оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько
дней подряд одним запуском — этим зальётся зерновой мир (#42), восемь
запусков службы внутри `make up` были бы плохим ответом. Отклонено:
*режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как
у режимов разное устройство: у пакетного есть пачка и нет ожидания, у
живого наоборот; *приёмник по умолчанию* — молчание однажды напишет в файл
то, чего ждали в Kafka, поэтому не назван приёмник — ошибка, названы оба —
тоже.
- **Умолчание есть только у того, что описывает мир** (решение владельца при
разборе ревью #41, 2026-08-07). Зерно (каноническое), число дней (1) и темп
живого дня (×60) — свойства модели: они заданы спекой и этим тикетом, и
умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес
брокера, имя топика и путь файла — факты стенда, на котором генератор
запущен; своего умолчания у них нет, и без них прогон падает с ненулевым
кодом до первой сгенерированной строки. Довод: генератор — отдельная и
переносимая сущность, факты про *этот* стенд обязан приносить зовущий
(служба compose, цель `make`, даг этапа 5). Умолчание подменило бы забытый
параметр чужим фактом и выдало бы неверный ответ за успех — даг `next_day`,
не передавший день, переиграл бы нулевой, вышел бы с нулём, был бы записан
удачей и подвинул позицию: мир и позиция разъехались бы молча. Отличие от
`--days 1` названо прямо: единица измерения — минимальный *верный* ответ, а
`0` в позиции — конкретный *неверный*, поданный тихо. Переменные окружения
(`GENERATOR_DAY`, `KAFKA_TOPIC`) умолчаниями не считаются: это тот же
зовущий, только другим каналом. Следствие для обвязки: имя топика и день
служба compose и цели `make` передают явно.
- **Ограниченная пачка — флаг `--limit N` пакетного режима**: потолок событий
на весь прогон, а не на каждый день. Довод от менти: тот, кто хочет
посмотреть на конвейер, не должен ждать полсотни тысяч событий. Сочетается
с файловым приёмником, даёт взять срез, ещё не приезжавший в ODS, а число
отправленного печатается всегда — счёт нужен обеим сторонам (на нём стоят
проверки #43). На живой день пачка не распространяется: ожидание там
снимает ускорение.
- **Ключа у сообщения Kafka нет.** `WatchID` ключом был бы ключом только на
вид: обещание Kafka про ключ — «сообщения одного ключа лежат в одном
разделе и сохраняют порядок», а у ряда, где ключи не повторяются, обещать
нечего; читатель же прочёл бы в нём смысл, которого нет. Осмысленный ключ
здесь — `ClientID`, но раскладку по разделам стенд намеренно оставляет
транспорту (раздел 2), и лаба «какая нода читала топик» живёт как раз тем,
что раскладка не предрешена. Проверено 2026-08-07 (Context7 по
`CONFIGURATION.md` librdkafka и снятие умолчаний с самой библиотеки,
поставленной `confluent-kafka` 2.15.0, через `rd_kafka_conf_dump`):
умолчание топикового `partitioner` — `consistent_random`, то есть у
сообщения без ключа раздел случайный; `sticky.partitioning.linger.ms` по
умолчанию 10 — окно, в котором подряд идущие сообщения без ключа липнут к
одному разделу. Документация умолчаний не называет, поэтому оба числа взяты
у библиотеки. Следствие: день целиком идёт секунды и ложится в оба раздела,
а короткая пачка укладывается в одно окно и уезжает в один — поэтому «топик
прочитан обеими нодами» проверяется днём и разово.
- **Приёмников два, а режимов три.** «Kafka пачкой» и «Kafka с темпом ×60» —
один и тот же приёмник у разных зовущих: темп держит проигрыватель, знающий
время события. Приёмник, умеющий ускорение, снова знал бы о содержимом —
ровно то, чего правило «глупых приёмников» (раздел 4) не хочет.
- **Раскладка образа повторяет раскладку репозитория**: `/app` — корень,
пакет лежит исходником в `/app/generator/src` и виден через `PYTHONPATH`,
каталог товаров — в `/app/data`. Причина не в красоте: путь к каталогу
считается от модуля вверх по дереву (`catalog.py`, `parents[3]`), а
поставленный не editable пакет переехал бы в `site-packages` — и те же
`parents[3]` указали бы внутрь `.venv`. Тогда каталог в образе есть, а
модуль его не находит. Проверено запуском в контейнере; заодно день,
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
Остаётся открытым, за тикетами:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
с манифестным решением напрашивается мини-манифест зернового мира —
форма за #42;