- Зачем: - границы владения Compose и Airflow должны быть видны менти из одного источника истины. - Что: - добавлены спека и ADR жизненного цикла мира; - обновлены README, архитектурные справочники и прежние решения; - правило полной очистки записано как явное и проверяемое требование к агенту. - Проверка: - документация вычитана через `ai-text-lint` и `writing-for-agents`; - выполнена литературная вычитка русского текста; - `git diff --check`.
249 lines
21 KiB
Markdown
249 lines
21 KiB
Markdown
# Жизненный цикл мира: создание и пересоздание ведёт 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`.** Даг зовут до того, как мир существует; сторож
|
||
запретил бы саму инициализацию.
|