docs(world): описан жизненный цикл прикладного мира
- Зачем: - границы владения Compose и Airflow должны быть видны менти из одного источника истины. - Что: - добавлены спека и ADR жизненного цикла мира; - обновлены README, архитектурные справочники и прежние решения; - правило полной очистки записано как явное и проверяемое требование к агенту. - Проверка: - документация вычитана через `ai-text-lint` и `writing-for-agents`; - выполнена литературная вычитка русского текста; - `git diff --check`.
This commit is contained in:
1 parent
fc5244cc67
commit
7e6554507a
12 files changed
+499
-104
No files matched your search
@@ -365,11 +365,22 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
— через `FINAL` или равносильный выбор последней версии; батчевая
|
||||
переобработка нижележащих объектов — по дневным партициям
|
||||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
||||
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
||||
осознанная цена, не баг).
|
||||
применяется вовсе: обычная переобработка ограничена партицией, а полное
|
||||
восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
|
||||
PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
|
||||
Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
|
||||
честно моргает (это осознанная цена, не баг).
|
||||
- **Область очистки следует за областью состояния**: обычная разработка не
|
||||
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
|
||||
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
|
||||
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
|
||||
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
|
||||
требуется удалить состояние вне прикладного мира либо проверить запуск именно
|
||||
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
|
||||
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
|
||||
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
|
||||
стирает следы неисправности и превращает редкий холодный запуск в постоянную
|
||||
цену разработки.
|
||||
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||
а не при проектировании приёма.
|
||||
@@ -607,9 +618,11 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
||||
|
||||
Что гонять на приёмке этапа — строка в [карте
|
||||
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
||||
запускать»: стенд собирается с нуля и на нём зелены все три цели, которым
|
||||
нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд,
|
||||
и молчит о его предмете — дашборд может быть не нарисован, а все три останутся
|
||||
запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
|
||||
проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
|
||||
томов обязателен только для изменения начальной сборки или постоянного состояния
|
||||
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
|
||||
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
|
||||
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
||||
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
||||
|
||||
|
||||
@@ -402,9 +402,10 @@
|
||||
|
||||
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||||
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||||
логе, а `make up` после #42 гоняет генератор по восемь дней при каждом
|
||||
подъёме стенда: замедлись он на порядок — подъём стенда встанет колом, и
|
||||
заметит это первый же человек, который его поднял.
|
||||
логе, а `world_initialize` гоняет генератор по восемь дней при первом создании
|
||||
мира и явной пересборке. Замедлись он на порядок — операция встанет колом, и
|
||||
это увидит первый же человек, который создаёт мир; повторный `make up` готовый
|
||||
мир не пересчитывает.
|
||||
|
||||
Отклонено с доводами:
|
||||
|
||||
@@ -480,12 +481,16 @@
|
||||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
||||
«последний сыгранный + 1» и только по успеху.
|
||||
она переменной Airflow: отсутствие переменной означает, что стартовый мир
|
||||
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
|
||||
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||||
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
|
||||
и ставят первый несыгранный день только после приёма стартового мира
|
||||
([ADR 0013](../adr/0013-world-lifecycle.md)).
|
||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
||||
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
|
||||
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
|
||||
заново залитым миром.
|
||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||||
|
||||
@@ -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`.** Даг зовут до того, как мир существует; сторож
|
||||
запретил бы саму инициализацию.
|
||||
Reference in new issue
Block a user