# Мир и модельное время Оператор управляет модельным временем стенда. Здесь разобрано, как рождается мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы об устройстве дагов модельного времени — в [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`.