docs(world): описан жизненный цикл прикладного мира

- Зачем:
  - границы владения Compose и Airflow должны быть видны менти из одного источника истины.
- Что:
  - добавлены спека и ADR жизненного цикла мира;
  - обновлены README, архитектурные справочники и прежние решения;
  - правило полной очистки записано как явное и проверяемое требование к агенту.
- Проверка:
  - документация вычитана через `ai-text-lint` и `writing-for-agents`;
  - выполнена литературная вычитка русского текста;
  - `git diff --check`.
This commit is contained in:
ddadmin committed 2026-08-21 23:02:15 +03:00
1 parent fc5244cc67
commit 7e6554507a
12 files changed
+499 -104

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
# Жизненный цикл мира: создание и пересоздание ведёт 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`.** Даг зовут до того, как мир существует; сторож
запретил бы саму инициализацию.