Зачем: ревью владельца по PR #213. Сверка конфигурации в stand.md приехала из README одним абзацем на двадцать строк, а Superset был приклеен к Airflow без пустой строки. Три места в world.md читались калькой с английского. Что: ледгер «Что проверено» разбит на записи по темам — ClickHouse, Kafka, метрики и Grafana, Airflow, Superset. В world.md заголовок «Как позвать генератор» стал «Как запустить генератор» (якоря в README и generator/README.md обновлены), переписаны абзацы про значения по умолчанию и про сокет Docker. Смысл не менялся. Словарь проекта («пульт мира», «опись мира», «работник», «сырье») не трогался: это термины CONTEXT.md и ADR, их пересмотр — отдельная развилка. Проверка: скрипт ссылок из тикета — пустой вывод; make config-test — ЗЕЛЁНО; старый якорь «как-позвать-генератор» в репозитории не встречается. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
163 lines
13 KiB
Markdown
163 lines
13 KiB
Markdown
# Мир и его пульт
|
||
|
||
Модельное время стенда со стороны оператора: как рождается мир, что двигает
|
||
его ось, кто играет дни и где хранится позиция.
|
||
Доводы об устройстве пульта — в [ADR 0009](../adr/0009-world-control.md), о
|
||
безопасном повторном подъеме и жизненном цикле мира — в
|
||
[ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день стартового
|
||
мира неполон и что хранит опись, разобрано в [справочнике стартового
|
||
мира](orders/start-world.md) и [справочнике описи](orders/inventory.md).
|
||
|
||
## Стартовый мир
|
||
|
||
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
|
||
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
|
||
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
|
||
те же.
|
||
|
||
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
||
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
||
виден разный темп потокового трекера и ночной выгрузки магазина.
|
||
|
||
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
|
||
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
|
||
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||
явной командой `make rebuild-storage`.
|
||
|
||
## Опись мира
|
||
|
||
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
||
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
||
[`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия
|
||
генератора, хеш каталога товаров и по строке на каждый день — дата, число
|
||
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики
|
||
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли
|
||
это мир, что был вчера.
|
||
|
||
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
|
||
собирается из кода сегодня: правка генератора меняет мир, и опись надо
|
||
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
|
||
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
|
||
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||
|
||
```bash
|
||
uv run --project generator python -m clickstream_generator batch \
|
||
--day 0 --file tmp/day0.jsonl
|
||
sha256sum tmp/day0.jsonl
|
||
```
|
||
|
||
## Как запустить генератор
|
||
|
||
События производит генератор из [`generator/`](../../generator/). Модельный день —
|
||
функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
|
||
события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой
|
||
день играть, решает зовущий.
|
||
|
||
Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз:
|
||
так заливается мир и так переигрывается день после обрыва. **Живой** держит темп
|
||
модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты,
|
||
суточная волна разворачивается на глазах, отставание видно в логе.
|
||
|
||
На стенде генератор ходит своим образом — разовой службой, которую поднимают
|
||
и убирают на один прогон. На каждый режим по цели:
|
||
|
||
```bash
|
||
# день D0 целиком в топик hits
|
||
make generate-batch
|
||
# первая сотня событий дня D3
|
||
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
|
||
# день D3 живьём, ускорение ×1000
|
||
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
||
```
|
||
|
||
| Переменная | Что задаёт | Умолчание |
|
||
| --- | --- | --- |
|
||
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
|
||
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
|
||
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
|
||
|
||
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
|
||
`kafka:9092` и топик `hits`.
|
||
|
||
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
|
||
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
|
||
или зависимостей, — отдельной командой:
|
||
|
||
```bash
|
||
docker compose --profile generator build generator
|
||
```
|
||
|
||
Тот же образ несёт заливка стартового мира. Перед такой заливкой
|
||
`world_initialize` сам собирает его из текущего дерева; на готовом мире
|
||
повторный `make up` до сборки не доходит.
|
||
|
||
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
|
||
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
|
||
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
|
||
|
||
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
|
||
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
|
||
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
|
||
|
||
```bash
|
||
uv run --project generator python -m clickstream_generator batch \
|
||
--day 0 --limit 100 --file tmp/day0.jsonl
|
||
```
|
||
|
||
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается
|
||
флагами, принимается и переменными окружения — так генератор позовёт даг
|
||
этапа 5; полный список у `--help`.
|
||
|
||
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
|
||
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
|
||
сам. День на оси, приемник и имя топика описывают стенд, на котором генератор
|
||
запустили: если их не задать, он откажется работать и вернет ненулевой код еще
|
||
до первого события. Поэтому значение дня по умолчанию живет в `Makefile`: день
|
||
задает тот, кто запускает, а не тот, кого запускают.
|
||
|
||
## Пульт мира
|
||
|
||
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
|
||
создают его начальное состояние, а три двигают ось дальше.
|
||
|
||
| Даг | Что делает |
|
||
| --- | --- |
|
||
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||
|
||
Работник не только играет день: следом он отправляет слепок заказов за
|
||
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
|
||
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
|
||
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
|
||
месте ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||
|
||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
|
||
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
|
||
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
|
||
начинают играть только с позиции `8` и больше.
|
||
|
||
В форме запуска можно выбрать только число дней. Первый день работник берет из
|
||
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
|
||
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
|
||
позицию работник пишет в журнал шага `remember_played`.
|
||
|
||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||
целиком — [ADR 0009](../adr/0009-world-control.md).
|
||
|
||
**У этого решения есть цена: планировщику Airflow отдан сокет Docker** —
|
||
иначе ему не поднять контейнер генератора. Доступ к сокету равен правам root
|
||
на машине; для локального учебного стенда это приемлемо, но знать об этом
|
||
нужно. Доступ дает не само монтирование сокета, а членство в группе `docker`:
|
||
у нее на каждой машине свой GID, он записан в `.env`, а узнать его можно
|
||
командой `getent group docker`.
|