From 7e6554507a9f1072d296be7f494f86579c9130d9 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 21 Aug 2026 22:18:37 +0300 Subject: [PATCH] =?UTF-8?q?docs(world):=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=86=D0=B8=D0=BA=D0=BB=20=D0=BF=D1=80=D0=B8=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BC=D0=B8=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - границы владения Compose и Airflow должны быть видны менти из одного источника истины. - Что: - добавлены спека и ADR жизненного цикла мира; - обновлены README, архитектурные справочники и прежние решения; - правило полной очистки записано как явное и проверяемое требование к агенту. - Проверка: - документация вычитана через `ai-text-lint` и `writing-for-agents`; - выполнена литературная вычитка русского текста; - `git diff --check`. --- AGENTS.md | 5 + README.md | 63 +++--- docs/adr/0001-stand-services.md | 8 +- docs/adr/0007-clickhouse-access.md | 20 +- docs/adr/0009-world-control.md | 5 + docs/adr/0013-world-lifecycle.md | 67 ++++++ docs/architecture/orders/start-world.md | 15 +- docs/architecture/storage.md | 53 ++--- docs/architecture/testing.md | 69 ++++-- docs/specs/2026-07-30-stand-v2-realism.md | 29 ++- docs/specs/2026-08-01-generator.md | 21 +- docs/specs/2026-08-21-world-lifecycle.md | 248 ++++++++++++++++++++++ 12 files changed, 499 insertions(+), 104 deletions(-) create mode 100644 docs/adr/0013-world-lifecycle.md create mode 100644 docs/specs/2026-08-21-world-lifecycle.md diff --git a/AGENTS.md b/AGENTS.md index 5e9f478..3239c12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,11 @@ Airflow) и названия из кода. Если для понятия ес `generator/` — код генератора. Правишь даги или Superset — зови корневую, правишь генератор — зови из `generator/`. - Изменения держать минимальными и в границах задания. +- `make clean` полностью удаляет постоянное состояние стенда. Перед этой + командой назови состояние вне прикладного мира, которое требуется удалить, + и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL + или проверке на чистых данных по умолчанию используй адресную очистку либо + `make rebuild-storage`. - Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md). - Секреты не коммитить: настройки — через `.env`, образец — `.env.example`. diff --git a/README.md b/README.md index f2c3822..46ba54a 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,9 @@ make check-services стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и `superset-init` завершаются с кодом 0. Первый обновляет схему Airflow, -подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет -Superset, создаёт администратора и импортирует подключение к `clickhouse-02`. +подготавливает администратора и два подключения к `clickhouse-01`. Второй +обновляет Superset, создаёт администратора и импортирует подключение к +`clickhouse-02`. С нуля подъём занимает около трёх минут, на живом стенде — около минуты. `make smoke` за секунды спрашивает, собран ли стенд: зависимости машины, @@ -118,29 +119,33 @@ Kafka по той же причине спрашивают снаружи. Её поднятый стенд, сколько стоит прогон и куда класть новую — в [карте проверок](docs/architecture/testing.md). -Остановить контейнеры без удаления данных можно командой `make down`. Для -полного сброса с удалением всех именованных томов используйте `make clean`. -Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна. +Остановить контейнеры без удаления данных можно командой `make down`. Если +нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в +Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и +позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их +тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными +томами и для обычной разработки не нужен. Перед пересборкой поставьте +`world_live` на паузу и дайте уже начатому работнику закончить день. ## Стартовый мир -Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits` -первые восемь дней модельного времени — понедельник по понедельник, 401 185 -событий. Следом второй запуск того же генератора отправляет в топик `orders` -семь слепков — за дни 0…6. Дальше оба источника разбирает хранилище, и к концу -`make up` события лежат в `ods.event`, а заказы — в `ods.order_v`. Так у всякой -лабы есть данные, и всегда одни и те же. +Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow +`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ +генератора и играет в `hits` первые восемь дней модельного времени — понедельник +по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в +`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только +тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и +те же. Последний день намеренно неполон: события дня 7 уже приехали, а его слепок уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так виден разный темп потокового трекера и ночной выгрузки магазина. -Ждать приходится дольше, чем работает заливка. `make up` сначала опрашивает -ClickHouse, пока события не доедут, затем разово запускает даг `orders_ingest` -и ждёт его конца. Повторный `make up` заливает мир заново; это не ошибка, а -свойство: номера событий и версии заказов те же, и повторы схлопнут -`ReplacingMergeTree`. +Повторный `make up` безопасен: `world_initialize` видит готовую позицию, +сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает +образ и не заливает данные повторно. Изменённую схему применяют адресно либо +явной командой `make rebuild-storage`. Сам мир в git не хранится — он чистая функция зерна, и держать его в репозитории значило бы держать там кэш. Вместо него лежит **опись мира**, @@ -202,9 +207,9 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000 docker compose --profile generator build generator ``` -Тот же образ несёт заливка стартового мира, поэтому свежим его держит и -обычный `make up`: он собирает образы всего стенда, и генератор теперь среди -них. +Тот же образ несёт заливка стартового мира. Перед такой заливкой +`world_initialize` сам собирает его из текущего дерева; на готовом мире +повторный `make up` до сборки не доходит. Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ @@ -231,11 +236,13 @@ uv run --project generator python -m clickstream_generator batch \ ### Пульт мира -Считать дни самому необязательно. В Airflow живёт **пульт мира** — три дага, -которые зовут тот же образ генератора и ведут позицию на оси за вас. +Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага +создают его начальное состояние, а три двигают ось дальше. | Даг | Что делает | | --- | --- | +| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет | +| `world_recreate` | удаляет только прикладной мир и запускает его создание заново | | `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один | | `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут | | `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём | @@ -248,8 +255,10 @@ uv run --project generator python -m clickstream_generator batch \ Позицию хранит переменная Airflow `world_position` — номер первого несыгранного дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон -позицию не двигает, и следующий запуск играет тот же день заново. Нет -переменной — мир в стартовом состоянии. +позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая +переменная означает, что мир ещё не создавали; `-1` — очистка не закончена, +`0` — очистка закончена, а начальная генерация ещё не завершена. Работники +начинают играть только с позиции `8` и больше. Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы @@ -286,10 +295,12 @@ uv run --project generator python -m clickstream_generator batch \ - Prometheus — `http://127.0.0.1:29090`; - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. -В ClickHouse шесть пользователей и пять ролей: +В ClickHouse семь пользователей и шесть ролей: -- `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет - слои хранилища; +- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои + хранилища; +- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль + `lifecycle_owner` не даёт эти административные права рабочим дагам; - `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие слои DDS и DM; - `analyst` предназначен для подключения человека и читает все слои и diff --git a/docs/adr/0001-stand-services.md b/docs/adr/0001-stand-services.md index 0282380..a4f186d 100644 --- a/docs/adr/0001-stand-services.md +++ b/docs/adr/0001-stand-services.md @@ -24,9 +24,11 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен пользователи и строки подключения. Это экономит один контейнер, но сохраняет границу владения данными. -Airflow получает подготовленное общее подключение к `clickhouse-01`, а -Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер -ClickHouse для Airflow не нужен: локальный образ содержит прямой клиент. +Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее +`clickhouse_default` и административное `clickhouse_lifecycle` для дагов +жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Superset получает +подключение `clickhousedb://` к `clickhouse-02`. Провайдер ClickHouse для +Airflow не нужен: локальный образ содержит прямой клиент. Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через `remote` читает `Distributed` на второй. Разные ноды сохраняют учебную ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index afb09c5..a9a2043 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -9,8 +9,11 @@ - `default` — с паролем, только служебный: проверки здоровья нод и работа изнутри контейнеров. Приложения им не ходят. -- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит - Airflow и применяется DDL при подъёме стенда. +- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие + даги Airflow. +- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только + прикладных баз и объектов. Им даги жизненного цикла применяют DDL и + пересоздают мир ([ADR 0013](0013-world-lifecycle.md)). - `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`. Им ходит Superset. - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им @@ -127,10 +130,11 @@ DDS под ними, потому что так чаще всего и быва Единственное, что ему доступно сегодня, — справочники: с ними зона `dic` пришла раньше своих потребителей. -Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда -идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью -сохранят нынешнего владельца, а на собранном заново стенде им станет `etl`. -Работают оба — права есть у обоих, — но два стенда в этом расходятся. +DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале +запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и +вставку в цель. Так отдельная учётка показывает границу между редким +управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам +право удалять базы. Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL новую роль и нового пользователя ничто не мешает, и новую роль разрешено @@ -182,8 +186,8 @@ ClickHouse через MCP Context7 (#114). - Секрет кластера принимается внутри `remote_servers` в виде ``. Запрос от `etl` через обе ноды выполнился на каждой под пользователем `etl`. -- `from_env` работает у элемента `password`: `etl`, `bi` и `analyst` вошли с - паролями из окружения и получили свои роли. +- `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и + `analyst` вошли с паролями из окружения и получили свои роли. - При запросе от `bi` через Distributed на второй ноде `system.query_log` показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде применяются права инициатора, а не общей учётной записи. diff --git a/docs/adr/0009-world-control.md b/docs/adr/0009-world-control.md index 2cdbbc7..8ad13b4 100644 --- a/docs/adr/0009-world-control.md +++ b/docs/adr/0009-world-control.md @@ -30,6 +30,11 @@ работник берёт из переменной, а не из логической даты прогона: календарь Airflow к оси мира отношения не имеет. +Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и +состояния обслуживания: переменной нет до первого создания мира, `-1` означает +незавершённую очистку, `0` — чистый мир до генерации, а работники принимают +только позицию `8` и больше. + Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, — **отложена, а не отвергнута**. diff --git a/docs/adr/0013-world-lifecycle.md b/docs/adr/0013-world-lifecycle.md new file mode 100644 index 0000000..8b48a40 --- /dev/null +++ b/docs/adr/0013-world-lifecycle.md @@ -0,0 +1,67 @@ +# ADR 0013. Жизненный цикл мира ведёт Airflow + +Дата: 21 августа 2026 года. Статус: принято. Реализация — тикет #119. + +## Решение + +Compose поднимает службы стенда. Airflow владеет прикладным миром: топиками +Kafka, DDL прикладных баз ClickHouse, стартовыми данными и позицией модельного +времени. `make` поднимает инфраструктуру и через REST API запускает нужный даг, +дожидаясь его результата. + +Безопасный даг `world_initialize` создаёт мир, если тот ещё не создан. На +готовом мире он ничего не меняет и успешно сообщает об этом в журнале. +Разрушительный даг `world_recreate` очищает прикладные базы, топики и позиции +их потребителей, затем запускает `world_initialize` и ждёт его завершения. +Канонический DDL применяет отдельная учётная запись Airflow с правами только на +прикладную структуру стенда. + +`world_position` остаётся единственным состоянием пульта: + +- переменной нет — стенд ещё не инициализировали; +- `-1` — прежний мир признан недействительным, очистка не завершена; +- `0` — мир очищен и готов к начальной генерации; +- `8` и больше — первый несыгранный день готового мира. + +`world_recreate` ставит `-1` до первого удаления и `0` только после успешной +очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение +`8` и больше считает готовым миром. Позиция становится `8` последней задачей, +после генерации стартовых дней и успешного приёма обоих источников. Обычные +даги движения мира работают только с готовой позицией. + +## Почему + +Прежняя цепочка делила один процесс между Compose, shell-скриптами и Airflow: +одноразовые службы создавали стартовые данные, отдельный сценарий ждал их +приёма, а пересборка сама исправляла внутреннюю переменную пульта. Из-за этой +границы успешная отправка в Kafka выдавалась за создание мира, повторный +`make up` смешивался с инициализацией данных, а узкая пересборка сохраняла в +Kafka сообщения и офсеты прежнего мира. + +Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси, +получаем один наблюдаемый процесс и одну точку успеха: зелёный +`world_initialize` означает, что схема создана, стартовый мир принят и его +следующий день известен. + +## Что это решение уточняет + +- В [ADR 0007](0007-clickhouse-access.md) DDL больше не принадлежит `etl`: + прикладную структуру ведёт отдельный `lifecycle`. +- В [ADR 0009](0009-world-control.md) отсутствие `world_position` больше не + означает готовый стартовый мир. Начальную позицию ставит + `world_initialize`, а работники принимают только `8` и больше. +- Одноразовые службы Compose и shell-ожидание, описанные прежним устройством + хранилища, отменены. Их последовательность стала наблюдаемым графом Airflow. + +## Отклонено + +- Оставить создание мира в Compose, а позицию исправлять из + `make rebuild-storage`: работает технически, но сохраняет двух + оркестраторов одного процесса. +- Один безусловно разрушительный даг для первого и повторного запуска: обычный + `make up` мог бы стереть уже прожитые дни. +- Отдельная переменная состояния мира: она дублировала бы + `world_position` и могла разойтись с ней. Значения `-1` и `0` выражают + нужные переходы в одном месте. +- Выдать обычной учётной записи `etl` право удаления баз: рабочий конвейер не + должен получать административные права из-за редкой операции восстановления. diff --git a/docs/architecture/orders/start-world.md b/docs/architecture/orders/start-world.md index b1639b0..083cf11 100644 --- a/docs/architecture/orders/start-world.md +++ b/docs/architecture/orders/start-world.md @@ -6,13 +6,14 @@ Генератор играет два источника с разными темпами — поток Метрики и ночную выгрузку магазина; разные темпы дают всё остальное. -`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`; -слепки отправляет второй запуск — команда `snapshot` того же диапазона (два -источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом -отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и -уедет первым же ходом мира. Особого режима у стартового мира нет: правило -отправки живёт в одном месте — в проигрывателе; генератору это решение не -стоит ничего. +Даг `world_initialize` играет восемь дней одним прогоном +`batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot` +того же диапазона (два источника — два запуска, +[слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки +дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7 +снят на границе суток и уедет первым же ходом мира. Особого режима у стартового +мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору +это решение не стоит ничего. Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет; глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 5702a25..551b199 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -480,44 +480,35 @@ Airflow читает и собирает эти файлы штатным шаб Порядок остальных задают два правила. Первое: матвью принадлежит слою своей цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что -наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает -нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему +наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому +нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной отладке. -Применение — двумя одноразовыми сервисами при `make up`, по образцу уже -работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топики -— `hits` с двумя партициями и `orders` с одной ([ADR -0008](../adr/0008-order-ingestion.md)), — затем `clickhouse-init` дожидается его -завершения и применяет файлы с ноды 1: всё `ON CLUSTER`, кроме чтеца заказов — -он объявлен только на этой ноде. Порядок страхует `hits` от автосоздания с -одной партицией. Переключателей тут два, и путать их не надо: брокер -автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не -просит — оба конца измерены, см. «Что проверено». То есть стенд держится на -умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и -создаётся явно, до применения DDL. +Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью +`lifecycle`: всё `ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой +ноде. Перед DDL даг создаёт `hits` с двумя партициями и `orders` с одной +([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits` +автоматически создаться с одной партицией. Брокер автосоздание разрешает, а +потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены; +результаты приведены в разделе «Что проверено». Успех дага означает больше, +чем применённый DDL: оба стартовых +источника приняты, а позиция мира установлена последней задачей. Полный граф и +состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md). -Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать -готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по -таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init` — -только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что -от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом -без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает -его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому -зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а -`airflow-init` — `clickhouse-init`, и цепочка упирается в долгоживущий Airflow. -Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо -применён. - -Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через -`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же -запуском не применяется, причём молча. Отдельного механизма для этого нет и не -нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство -уже есть — `make clean && make up`. Мир регенерируется, сырьё живёт трое суток, -терять нечего. +Повторный `make up` поверх готового мира проходит зелёным без изменений: +`world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным +подъёмом не применяется. В разработке меняют или пересоздают только затронутый +объект, партицию либо базу слоя. Если нужен целиком канонический мир, +`make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы +`stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики, +затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция +возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не +нужен. Когда какая область очистки уместна перед проверкой, задаёт +[карта проверок](testing.md). ## Карта таблиц diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index e11cbce..7790713 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -71,9 +71,15 @@ тогда окружение окупится, и условие возврата — именно это, а не «стало неудобно». -Цена самого `make up` — 8 м 18 с с нуля и 3 м 35 с на живом стенде. В цену -целей она не входит, но записана здесь по той же причине: подъём пересчитывает -оба источника стартового мира и ждёт их приёма. +Цена самого `make up` с пустых томов — 8 м 18 с до переезда жизненного цикла в +Airflow. В цену целей она не входит. На готовом мире подъём больше не +пересчитывает данные: `world_initialize` зеленеет без изменений. + +`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет +прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL, +заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и +их тома она сохраняет, поэтому возврат к каноническим данным не требует +холодного запуска всего стенда. ## Правило: смоук обязан оставаться быстрым @@ -96,11 +102,19 @@ ClickHouse отвечает сразу. Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять всегда всё». +Состояние перед проверкой восстанавливается в самой узкой достаточной области: +сначала объект или партиция, затем база слоя. Если нужен целиком чистый +прикладной мир, `make rebuild-storage` пересобирает его базы, топики, позиции +потребителей и данные. `make clean` нужен, только когда меняется или сломано +постоянное состояние вне этих баз либо проверяется сам запуск с пустых томов. +Требование к обоснованию полного сброса задаёт `AGENTS.md`. Неизвестную поломку +сначала исследуют: полный сброс уничтожает её следы. + | Когда | Что гонять | |---|---| | Правка в работе | `make config-test` и `make smoke` | | PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` | -| Приёмка этапа | `make up` с нуля и все три цели на стенде | +| Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб | Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test` из каталога `generator/`: стенд им не нужен, а `make test` из них самая @@ -194,12 +208,12 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`. | `make check-services` | `scripts/stand-services.sh` | | `make check-clickhouse` | `scripts/check-clickhouse.sh` | -Особняком — два сценария продолжения `make up`, оба не проверки. -`scripts/wait-for-world.sh` ждёт, пока события доедут из Kafka в ODS: -`docker compose up --wait` видит конец отправки, но приём идёт асинхронно. -Затем `scripts/ingest-starting-orders.sh` разово запускает единственный даг -приёма заказов `orders_ingest` и ждёт его конца. Оба ждут ограниченным циклом, -а не паузой наугад. +Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от +`make` к Airflow. `make up` запускает через него `world_initialize`, а +`make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API, +печатает идентификаторы дага и прогона и возвращает вызывающему его итог. +Ожидание доставки событий и запуск `orders_ingest` теперь видны задачами +`world_initialize`, а не спрятаны в shell. Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается. @@ -227,9 +241,38 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`. 19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После возврата сервиса прогон снова дал 21/21. -**Перезамер 20 августа 2026 года при исполнении #96.** `make up` после -`make clean` прошёл за 8 м 18 с, повтор на живом стенде — за 3 м 35 с. Служба -семи слепков заняла около 28 секунд между концом `world-init` и своим концом. +**Приёмка #119 21 августа 2026 года.** `make rebuild-storage` через Airflow +прошёл за 3 м 9 с: позиция прошла задачи `-1` → `0` → `8`, топики получили +новые идентификаторы, группы дочитали новые топики без отставания, а ODS принял +401 185 событий без брака и 2 851 актуальный заказ. Повторный `make up` занял +2 м 41 с вместе со штатным повторным запуском служб инициализации; +`world_initialize` завершился за один цикл опроса и оставил счётчики +неизменными. Отдельно +проверены красные отказы `world_initialize` при `-1` и `world_next_day` при +отсутствующей позиции, `0` и `-1`; `lifecycle` отказано в чтении +`system.users`, но разрешено чтение `system.settings`. + +Итоговый холодный `make up` из пустых томов занял 9 м 39 с после 25 с на +`make clean`. Даг создал отсутствующую позицию и закончил на `8`; ODS снова +получил 401 185 событий без брака и 2 851 заказ. У `hits` два раздела, у +`orders` один, отставание обеих групп равно нулю. На этом стенде прошли смоук +20/20, проверка ClickHouse 10/10 и межсервисная проверка 7/7. + +Оборванную очистку проверили после записи `world_position=-1`: остановили +первую ноду ClickHouse, и `world_recreate` покраснел на удалении баз. Следующий +`make up` тоже покраснел и оставил `-1`. После возврата ноды повторный +`make rebuild-storage` восстановил мир. + +**Прототип 21 августа 2026 года при исследовании #110.** Предшествующая +shell-версия узкой пересборки за 2 м 25 с пересоздала прикладные базы, отправила +401 185 событий и 6 656 версий заказов и вернула позицию с 11 на 8. Замер +оставлен как ориентир цены, но не подтверждает нынешнюю реализацию Airflow: тот +вариант не очищал Kafka и был отклонён как второй оркестратор мира. + +**Перезамер 20 августа 2026 года при исполнении #96, до переезда в Airflow.** +`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за +3 м 35 с. Отправка семи слепков заняла около 28 секунд. + Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index ec2acd2..1b34496 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -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). diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index c2434bf..aa1ae61 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.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)). diff --git a/docs/specs/2026-08-21-world-lifecycle.md b/docs/specs/2026-08-21-world-lifecycle.md new file mode 100644 index 0000000..3673798 --- /dev/null +++ b/docs/specs/2026-08-21-world-lifecycle.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`.** Даг зовут до того, как мир существует; сторож + запретил бы саму инициализацию.