Files
clickstream-data-platform/docs/specs/2026-08-21-world-lifecycle.md
T
ddadmin 7e6554507a docs(world): описан жизненный цикл прикладного мира
- Зачем:
  - границы владения Compose и Airflow должны быть видны менти из одного источника истины.
- Что:
  - добавлены спека и ADR жизненного цикла мира;
  - обновлены README, архитектурные справочники и прежние решения;
  - правило полной очистки записано как явное и проверяемое требование к агенту.
- Проверка:
  - документация вычитана через `ai-text-lint` и `writing-for-agents`;
  - выполнена литературная вычитка русского текста;
  - `git diff --check`.
2026-08-21 23:02:15 +03:00

249 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Жизненный цикл мира: создание и пересоздание ведёт 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`.** Даг зовут до того, как мир существует; сторож
запретил бы саму инициализацию.