Зачем: README был складом сведений, осевших там, где писались. По раскладке резолюции #211 он становится лицом репозитория: промо-блок, «куда я попал», типовые действия, ссылки. Слово «менти» уходит из README и путеводителя, хвосты #212 и #214 закрываются. Что: промо-цитата со ссылкой на план обучения и ментора; шапка с нейтральной адресацией и одной фразой о составе и пути данных; «Быстрый старт» сжат до двух команд по решению владельца — проверки остаются роботам, в README одна ссылка на карту проверок; разделы «Адреса и пароли», «Сдвинуть мир вперед», «Вернуть мир к началу», «Выключить или сбросить целиком», «Подключиться из DBeaver»; «Документация» называет все семь справочников вложенным списком. Умерли «Статус», «Что здесь будет», «Чем отличается от предшественника» и три заголовка-якоря. В world.md снята устаревшая фраза о «даге этапа 5», stand.md и ADR 0007 ведут на новый раздел «Адреса и пароли», в путеводителе снято слово «менти». Проверка: скрипт ссылок пуст, grep «менти» по README и путеводителю пуст, make config-test зеленый. Саморевью Sol 1/1; слепое ревью Opus — 0 блокеров, 2 minor, 3 nit, все закрыты; вычитка прозы ai-text-lint и оптикой литературного русского — 15 находок, 9 внесены, 6 отклонены как слова владельца и термины. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
165 lines
13 KiB
Markdown
165 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`. Всё, что
|
||
принимается флагами, принимается и переменными окружения — так генератор
|
||
запускают даги модельного времени; полный список у `--help`.
|
||
|
||
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
|
||
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
|
||
сам. День на оси, приёмник и имя топика относятся к конкретному запуску
|
||
генератора на стенде: если их не задать, он откажется работать и вернёт
|
||
ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
|
||
`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
|
||
|
||
## Даги модельного времени
|
||
|
||
Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
|
||
два дага создают начальное состояние мира, а три двигают ось дальше.
|
||
|
||
| Даг | Что делает |
|
||
| --- | --- |
|
||
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||
| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
|
||
|
||
Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
|
||
`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
|
||
ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
|
||
шаговый даг, а позиция остаётся на месте ([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`.
|