# Жизненный цикл мира: создание и пересоздание ведёт Airflow Дата: 21 августа 2026 года. Решение и доводы — [ADR 0013](../adr/0013-world-lifecycle.md); здесь устройство, которое из него следует. ## Задача До этой работы начало оси мира собирали трое. Compose создавал топики, применял DDL и двумя одноразовыми службами заливал восемь дней. Shell-сценарии ждали, пока события доедут до ODS, и разово дёргали даг приёма заказов. Airflow вёл мир после стартовой заливки. Граница владения не совпадала с процессом, и это видно по следствиям. Успешная отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу после записи в топик, до приёма сообщений хранилищем. Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем, кто миром не управляет. **Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат прикладная схема, данные мира и позиция модельного времени, — и Compose, который держит только инфраструктуру. Зелёный `world_initialize` значит ровно одно: схема создана, оба источника приняты, следующий день известен. ## Решение Граница владения одна: **Compose отвечает за инфраструктуру; Airflow — за прикладной мир.** Compose держит долгоживущие службы, сети, тома, объявленные файлом учётные записи ClickHouse, инициализацию метаданных Airflow и инициализацию Superset. Airflow держит прикладные топики Kafka, канонический DDL прикладных баз `stg`, `ods`, `dds`, `dm`, `dic`, данные стартового мира, их приём и переменную `world_position`. Одноразовых служб `kafka-init`, `clickhouse-init`, `world-init` и `world-snapshots` в графе Compose не остаётся. Ни профиля, ни вызова через `compose run` им не оставляют: спрятанная за профилем служба — тот же второй оркестратор, только менее заметный. `make` инфраструктурой и ограничивается: поднимает стенд, зовёт нужный даг через REST API Airflow и ждёт результата. Shell больше не применяет DDL, не запускает генератор и не правит позицию. ### Учётная запись жизненного цикла Прикладную структуру создаёт и удаляет отдельная учётная запись `lifecycle` с ролью `lifecycle_owner`, объявленная тем же файлом, что остальные учётные записи ([ADR 0007](../adr/0007-clickhouse-access.md)). Ей разрешено администрировать прикладные базы — заводить и удалять их и объекты внутри, — читать их и выполнять кластерный DDL. Ей разрешено чтение `system.settings`: без него `clickhouse-connect` не открывает соединение. Административные запросы к системным базам ей запрещены. `etl` при этом не расширяется. Право удаления баз нужно редкой операции восстановления и остаётся у `lifecycle`. Граница проверяема: при лишнем запросе менти увидит в ошибке имя учётной записи и недостающее право. ### Что Airflow получает от репозитория Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`, `data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого собирается канонический образ генератора, из второго читается опись мира, а последний не пускает в контекст сборки локальные окружения и кэши. Запросы стенда приезжают прежним монтированием `sql/`. Меняющиеся API сверены через Context7 и затем с установленными пакетами стенда. Выбраны публичный REST API v2 и ждущий `TriggerDagRunOperator` Airflow, сборка образа через `DockerHook.get_conn().build()`, futures `AdminClient` для Kafka и по одному `clickhouse-connect.command()` на выражение DDL. Последний выбор требует явного разбора файлов: клиент сам многозапросный файл не делит, а в каноническом SQL конец выражения уже задан точкой с запятой в конце строки. ## Контракты дагов Оба дага публичные: без расписания, не на паузе, `max_active_runs = 1`. Их запускают руками из интерфейса и командой `make` через REST API. ### `world_initialize` — создать мир, если его нет Даг безопасен по построению: на готовом мире он ничего не меняет. 1. Читает `world_position` и решает, что делать дальше: инициализировать, успешно завершиться без изменений или покраснеть. Разбор состояний — в таблице ниже. 2. Создаёт топики: `hits` с двумя партициями, `orders` с одной. Топики идут **до** DDL: чтец ClickHouse начинает читать топик, когда над ним появляется матвью, и топик, созданный брокером сам, приехал бы с одним разделом ([ADR 0008](../adr/0008-order-ingestion.md)). 3. Применяет канонический DDL из `sql/ddl/` по порядку имён файлов, с ноды 1, учётной записью `lifecycle`. Правило «матвью приёма создаётся последней» остаётся за раскладкой файлов и не переезжает в даг. 4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого кода: сборкой образа при подъёме больше никто не занимается. 5. Играет стартовые дни одним прогоном `batch` того же образа. 6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк `ods.event_errors_dist` не меньше суммы событий из [`data/world-inventory.json`](../../data/world-inventory.json). «Что-то приехало» условием не считается. 7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6 ([заказы в стартовом мире](../architecture/orders/start-world.md)). 8. Дёргает `orders_ingest` ждущим триггером и зеленеет только вслед за ним. 9. Последней успешной задачей ставит позицию в `WORLD_STARTING_DAYS`. Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная задача называет место остановки. ### `world_recreate` — снести мир и создать заново Сначала даг выполняет всю разрушительную часть, затем делегирует создание мира. 1. Ставит позицию в `-1`: прежний мир объявлен недействительным раньше, чем с ним что-то произошло. 2. Удаляет прикладные базы ClickHouse `stg`, `dm`, `dds`, `ods`, `dic` — первым делом, чтобы чтецы Kafka перестали существовать вместе с ними. 3. Удаляет позиции групп потребителей `clickstream_hits` и `clickstream_orders`, затем топики `hits` и `orders`. 4. Ставит позицию в `0` — только после успешной очистки. 5. Дёргает `world_initialize` ждущим триггером и ждёт его результата. **Общего модуля с шагами двух дагов нет.** Переиспользование здесь — это делегирование: пересборка вызывает создание мира целиком. Такой модуль стал бы третьим местом с той же последовательностью, а первое же расхождение между дагами пришлось бы прятать в его параметрах. ### Сторожа у работников мира `world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше `WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг: поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа не получает — он краснеет вслед за работником. `orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда готового мира ещё нет. ## Состояния позиции `world_position` остаётся единственным состоянием пульта. | Позиция | `world_initialize` | `world_recreate` | Работники | |---|---|---|---| | переменной нет | создаёт мир | очищает и создаёт | красный отказ | | `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ | | `0` | создаёт мир | очищает и создаёт | красный отказ | | `WORLD_STARTING_DAYS` и больше | мир уже есть: пишет это в журнал и зеленеет | очищает и создаёт | работают | Значения между `1` и `WORLD_STARTING_DAYS` штатно не возникают: позиция становится либо `-1`, либо `0`, либо номером сыгранного дня за стартовым миром. Попав на такое значение, `world_initialize` краснеет тем же отказом, что на `-1`, и называет прочитанное число. ## Поведение при отказах **Пересборка упала на очистке.** Позиция остаётся `-1`. Обычный `make up` эту поломку не заглаживает: `world_initialize` краснеет и говорит, что мир недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую очистку вслух и не продолжать работу молча. **Очистка прошла, потомок не доработал.** Позиция остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто пробует её заново — с того же места и с тем же результатом. **Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.** События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда мир считается созданным. **Порядок очистки Kafka входит в контракт.** Пока живы чтецы ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо. Поэтому сначала удаляются базы, затем топики. **Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти делает себе сам и тут же видит. **Слоты Airflow.** Их четыре, и вложенные ожидания в них укладываются, пока пересборка не наложилась на движение мира: `world_recreate` ждёт `world_initialize`, тот ждёт `orders_ingest` — три слота из четырёх. Одновременно работающий выключатель добавляет свои и загоняет стенд в тупик ожиданий. Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу и даёт работникам доиграть. Предусловие остаётся в документации; механизм блокировок стоил бы дороже этой строки. ## Границы - **`make up` не мигратор схемы.** На готовом мире изменённый DDL не применяется. Обновляет схему явная пересборка `world_recreate` либо адресная правка объекта в разработке. Размен принят с открытыми глазами. - **Ни блокировок, ни статуса, ни автоматической паузы.** Предусловие обслуживания названо словами. - **`etl` не расширяется**, `orders_ingest` не получает сторожа. - **`make clean` сохраняет свой смысл**: полное удаление постоянного состояния стенда. Ни в пересборку, ни в обычную проверку он не входит. - **`make generate-batch` и `make generate-live` остаются** — с них снимаются унаследованные зависимости от исчезающих одноразовых служб. - Содержание DDL, код генератора и устройство слоёв не меняются. ## Проверка Цели `make` — обычные: `config-test` и `lint` без стенда, `smoke`, `check-clickhouse` и `check-services` на поднятом. Сверх них проверяются сценарии, которых у стенда раньше не было. - **Повторный `make up` на живом мире** проходит быстро и ничего не меняет: позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что мир уже есть. - **`make rebuild-storage`** возвращает канонический мир: позиция проходит `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы заново, офсеты групп идут с нуля. Цена прогона называется числом и сверяется с прежними 2 м 25 с. - **Красные пути проверяются своими отказами.** Сторож работника при отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`; оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки, доказывает только то, что умеет печатать «ЗЕЛЁНО». - **Отказ в правах читается.** Учётная запись `lifecycle` применяет весь DDL без единой ошибки прав, а административный запрос к системной базе ей отклоняют — и текст отказа называет учётку. - **Один холодный `make clean && make up`** оправдан на итоговой приёмке и только этой работой: она меняет самый первый подъём стенда, а его иначе не увидеть. Обобщать этот случай не нужно — обычная проверка по-прежнему обходится узкой областью очистки. Замеры холодного и повторного `make up` и пересборки записываются в [карту проверок](../architecture/testing.md), раздел «Что проверено». ## Отклонённые варианты - **Спрятать одноразовые службы за профилем или звать их `compose run`.** Дешевле всего в правках, но оставляет двух оркестраторов одного процесса — ровно то, ради чего затевался переезд. - **Оставить применение DDL и заливку мира в shell, а Airflow отдать только позицию.** Тогда «один наблюдаемый процесс» остаётся обещанием: половина графа живёт в журнале терминала. - **Вынести общие шаги двух дагов в отдельный модуль.** Третье место для той же последовательности; расхождение дагов пряталось бы в его параметрах. - **Применять изменённый DDL при каждом `make up`.** Это мигратор схемы; ему нужен отдельный разговор, в граф создания мира он не помещается. - **Выдать права на удаление баз учётной записи `etl`.** Отвергнуто ещё ADR 0013: конвейер не получает административные права из-за редкой операции. - **Завести блокировку или статус обслуживания, чтобы пересборка не сталкивалась с движением мира.** Механизм на редкий ручной случай дороже предусловия, названного словами. - **Сторож на `orders_ingest`.** Даг зовут до того, как мир существует; сторож запретил бы саму инициализацию.