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:
1 parent
8903b6a054
commit
a61f7934ec
19 files changed
+1184
-16
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user