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
@@ -82,6 +82,11 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
||||||
правишь генератор — зови из `generator/`.
|
правишь генератор — зови из `generator/`.
|
||||||
- Изменения держать минимальными и в границах задания.
|
- Изменения держать минимальными и в границах задания.
|
||||||
|
- `make clean` полностью удаляет постоянное состояние стенда. Перед этой
|
||||||
|
командой назови состояние вне прикладного мира, которое требуется удалить,
|
||||||
|
и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL
|
||||||
|
или проверке на чистых данных по умолчанию используй адресную очистку либо
|
||||||
|
`make rebuild-storage`.
|
||||||
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
||||||
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
||||||
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ make check-services
|
|||||||
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
|
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
|
||||||
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
||||||
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
||||||
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
подготавливает администратора и два подключения к `clickhouse-01`. Второй
|
||||||
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
обновляет Superset, создаёт администратора и импортирует подключение к
|
||||||
|
`clickhouse-02`.
|
||||||
С нуля подъём занимает около трёх минут, на живом стенде — около минуты.
|
С нуля подъём занимает около трёх минут, на живом стенде — около минуты.
|
||||||
|
|
||||||
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
||||||
@@ -118,29 +119,33 @@ Kafka по той же причине спрашивают снаружи. Её
|
|||||||
поднятый стенд, сколько стоит прогон и куда класть новую — в
|
поднятый стенд, сколько стоит прогон и куда класть новую — в
|
||||||
[карте проверок](docs/architecture/testing.md).
|
[карте проверок](docs/architecture/testing.md).
|
||||||
|
|
||||||
Остановить контейнеры без удаления данных можно командой `make down`. Для
|
Остановить контейнеры без удаления данных можно командой `make down`. Если
|
||||||
полного сброса с удалением всех именованных томов используйте `make clean`.
|
нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в
|
||||||
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
|
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и
|
||||||
|
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их
|
||||||
|
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными
|
||||||
|
томами и для обычной разработки не нужен. Перед пересборкой поставьте
|
||||||
|
`world_live` на паузу и дайте уже начатому работнику закончить день.
|
||||||
|
|
||||||
## Стартовый мир
|
## Стартовый мир
|
||||||
|
|
||||||
Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits`
|
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
|
||||||
первые восемь дней модельного времени — понедельник по понедельник, 401 185
|
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
|
||||||
событий. Следом второй запуск того же генератора отправляет в топик `orders`
|
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||||||
семь слепков — за дни 0…6. Дальше оба источника разбирает хранилище, и к концу
|
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||||||
`make up` события лежат в `ods.event`, а заказы — в `ods.order_v`. Так у всякой
|
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||||||
лабы есть данные, и всегда одни и те же.
|
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
|
||||||
|
те же.
|
||||||
|
|
||||||
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||||||
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
||||||
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
||||||
виден разный темп потокового трекера и ночной выгрузки магазина.
|
виден разный темп потокового трекера и ночной выгрузки магазина.
|
||||||
|
|
||||||
Ждать приходится дольше, чем работает заливка. `make up` сначала опрашивает
|
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
|
||||||
ClickHouse, пока события не доедут, затем разово запускает даг `orders_ingest`
|
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
|
||||||
и ждёт его конца. Повторный `make up` заливает мир заново; это не ошибка, а
|
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||||||
свойство: номера событий и версии заказов те же, и повторы схлопнут
|
явной командой `make rebuild-storage`.
|
||||||
`ReplacingMergeTree`.
|
|
||||||
|
|
||||||
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
||||||
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
||||||
@@ -202,9 +207,9 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
|||||||
docker compose --profile generator build generator
|
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_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||||
@@ -248,8 +255,10 @@ uv run --project generator python -m clickstream_generator batch \
|
|||||||
|
|
||||||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
Позицию хранит переменная 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`;
|
- Prometheus — `http://127.0.0.1:29090`;
|
||||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
- 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` и будущие
|
- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие
|
||||||
слои DDS и DM;
|
слои DDS и DM;
|
||||||
- `analyst` предназначен для подключения человека и читает все слои и
|
- `analyst` предназначен для подключения человека и читает все слои и
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен
|
|||||||
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
|
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
|
||||||
границу владения данными.
|
границу владения данными.
|
||||||
|
|
||||||
Airflow получает подготовленное общее подключение к `clickhouse-01`, а
|
Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее
|
||||||
Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер
|
`clickhouse_default` и административное `clickhouse_lifecycle` для дагов
|
||||||
ClickHouse для Airflow не нужен: локальный образ содержит прямой клиент.
|
жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Superset получает
|
||||||
|
подключение `clickhousedb://` к `clickhouse-02`. Провайдер ClickHouse для
|
||||||
|
Airflow не нужен: локальный образ содержит прямой клиент.
|
||||||
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
|
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
|
||||||
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную
|
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную
|
||||||
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow
|
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
|
|
||||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||||
изнутри контейнеров. Приложения им не ходят.
|
изнутри контейнеров. Приложения им не ходят.
|
||||||
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие
|
||||||
Airflow и применяется DDL при подъёме стенда.
|
даги Airflow.
|
||||||
|
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
|
||||||
|
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
|
||||||
|
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
|
||||||
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
||||||
Им ходит Superset.
|
Им ходит Superset.
|
||||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||||
@@ -127,10 +130,11 @@ DDS под ними, потому что так чаще всего и быва
|
|||||||
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
||||||
пришла раньше своих потребителей.
|
пришла раньше своих потребителей.
|
||||||
|
|
||||||
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
|
||||||
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
|
||||||
сохранят нынешнего владельца, а на собранном заново стенде им станет `etl`.
|
вставку в цель. Так отдельная учётка показывает границу между редким
|
||||||
Работают оба — права есть у обоих, — но два стенда в этом расходятся.
|
управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам
|
||||||
|
право удалять базы.
|
||||||
|
|
||||||
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
||||||
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
||||||
@@ -182,8 +186,8 @@ ClickHouse через MCP Context7 (#114).
|
|||||||
- Секрет кластера принимается внутри `remote_servers` в виде
|
- Секрет кластера принимается внутри `remote_servers` в виде
|
||||||
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
||||||
ноды выполнился на каждой под пользователем `etl`.
|
ноды выполнился на каждой под пользователем `etl`.
|
||||||
- `from_env` работает у элемента `password`: `etl`, `bi` и `analyst` вошли с
|
- `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и
|
||||||
паролями из окружения и получили свои роли.
|
`analyst` вошли с паролями из окружения и получили свои роли.
|
||||||
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
||||||
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
||||||
применяются права инициатора, а не общей учётной записи.
|
применяются права инициатора, а не общей учётной записи.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
||||||
к оси мира отношения не имеет.
|
к оси мира отношения не имеет.
|
||||||
|
|
||||||
|
Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и
|
||||||
|
состояния обслуживания: переменной нет до первого создания мира, `-1` означает
|
||||||
|
незавершённую очистку, `0` — чистый мир до генерации, а работники принимают
|
||||||
|
только позицию `8` и больше.
|
||||||
|
|
||||||
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
||||||
**отложена, а не отвергнута**.
|
**отложена, а не отвергнута**.
|
||||||
|
|
||||||
|
|||||||
@@ -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` право удаления баз: рабочий конвейер не
|
||||||
|
должен получать административные права из-за редкой операции восстановления.
|
||||||
@@ -6,13 +6,14 @@
|
|||||||
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||||
выгрузку магазина; разные темпы дают всё остальное.
|
выгрузку магазина; разные темпы дают всё остальное.
|
||||||
|
|
||||||
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`;
|
Даг `world_initialize` играет восемь дней одним прогоном
|
||||||
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два
|
`batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot`
|
||||||
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом
|
того же диапазона (два источника — два запуска,
|
||||||
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и
|
[слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки
|
||||||
уедет первым же ходом мира. Особого режима у стартового мира нет: правило
|
дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7
|
||||||
отправки живёт в одном месте — в проигрывателе; генератору это решение не
|
снят на границе суток и уедет первым же ходом мира. Особого режима у стартового
|
||||||
стоит ничего.
|
мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору
|
||||||
|
это решение не стоит ничего.
|
||||||
|
|
||||||
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||||
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||||
|
|||||||
@@ -480,44 +480,35 @@ Airflow читает и собирает эти файлы штатным шаб
|
|||||||
|
|
||||||
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
||||||
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
|
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому
|
||||||
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
|
нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему
|
||||||
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
||||||
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
||||||
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся
|
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся
|
||||||
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной
|
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной
|
||||||
отладке.
|
отладке.
|
||||||
|
|
||||||
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
|
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||||
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топики
|
`lifecycle`: всё `ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой
|
||||||
— `hits` с двумя партициями и `orders` с одной ([ADR
|
ноде. Перед DDL даг создаёт `hits` с двумя партициями и `orders` с одной
|
||||||
0008](../adr/0008-order-ingestion.md)), — затем `clickhouse-init` дожидается его
|
([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
|
||||||
завершения и применяет файлы с ноды 1: всё `ON CLUSTER`, кроме чтеца заказов —
|
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
|
||||||
он объявлен только на этой ноде. Порядок страхует `hits` от автосоздания с
|
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
|
||||||
одной партицией. Переключателей тут два, и путать их не надо: брокер
|
результаты приведены в разделе «Что проверено». Успех дага означает больше,
|
||||||
автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не
|
чем применённый DDL: оба стартовых
|
||||||
просит — оба конца измерены, см. «Что проверено». То есть стенд держится на
|
источника приняты, а позиция мира установлена последней задачей. Полный граф и
|
||||||
умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и
|
состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md).
|
||||||
создаётся явно, до применения DDL.
|
|
||||||
|
|
||||||
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
|
Повторный `make up` поверх готового мира проходит зелёным без изменений:
|
||||||
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
|
`world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным
|
||||||
таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init` —
|
подъёмом не применяется. В разработке меняют или пересоздают только затронутый
|
||||||
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
|
объект, партицию либо базу слоя. Если нужен целиком канонический мир,
|
||||||
от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом
|
`make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы
|
||||||
без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает
|
`stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики,
|
||||||
его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому
|
затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция
|
||||||
зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а
|
возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не
|
||||||
`airflow-init` — `clickhouse-init`, и цепочка упирается в долгоживущий Airflow.
|
нужен. Когда какая область очистки уместна перед проверкой, задаёт
|
||||||
Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо
|
[карта проверок](testing.md).
|
||||||
применён.
|
|
||||||
|
|
||||||
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
|
|
||||||
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
|
|
||||||
запуском не применяется, причём молча. Отдельного механизма для этого нет и не
|
|
||||||
нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство
|
|
||||||
уже есть — `make clean && make up`. Мир регенерируется, сырьё живёт трое суток,
|
|
||||||
терять нечего.
|
|
||||||
|
|
||||||
## Карта таблиц
|
## Карта таблиц
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| Правка в работе | `make config-test` и `make smoke` |
|
||||||
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
||||||
| Приёмка этапа | `make up` с нуля и все три цели на стенде |
|
| Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб |
|
||||||
|
|
||||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||||
@@ -194,12 +208,12 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
| `make check-services` | `scripts/stand-services.sh` |
|
| `make check-services` | `scripts/stand-services.sh` |
|
||||||
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
||||||
|
|
||||||
Особняком — два сценария продолжения `make up`, оба не проверки.
|
Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от
|
||||||
`scripts/wait-for-world.sh` ждёт, пока события доедут из Kafka в ODS:
|
`make` к Airflow. `make up` запускает через него `world_initialize`, а
|
||||||
`docker compose up --wait` видит конец отправки, но приём идёт асинхронно.
|
`make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API,
|
||||||
Затем `scripts/ingest-starting-orders.sh` разово запускает единственный даг
|
печатает идентификаторы дага и прогона и возвращает вызывающему его итог.
|
||||||
приёма заказов `orders_ingest` и ждёт его конца. Оба ждут ограниченным циклом,
|
Ожидание доставки событий и запуск `orders_ingest` теперь видны задачами
|
||||||
а не паузой наугад.
|
`world_initialize`, а не спрятаны в shell.
|
||||||
|
|
||||||
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
||||||
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
||||||
@@ -227,9 +241,38 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После
|
19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После
|
||||||
возврата сервиса прогон снова дал 21/21.
|
возврата сервиса прогон снова дал 21/21.
|
||||||
|
|
||||||
**Перезамер 20 августа 2026 года при исполнении #96.** `make up` после
|
**Приёмка #119 21 августа 2026 года.** `make rebuild-storage` через Airflow
|
||||||
`make clean` прошёл за 8 м 18 с, повтор на живом стенде — за 3 м 35 с. Служба
|
прошёл за 3 м 9 с: позиция прошла задачи `-1` → `0` → `8`, топики получили
|
||||||
семи слепков заняла около 28 секунд между концом `world-init` и своим концом.
|
новые идентификаторы, группы дочитали новые топики без отставания, а 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 с до появления приёма заказов и пульта
|
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
|
||||||
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
||||||
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
||||||
|
|||||||
@@ -365,11 +365,22 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
— через `FINAL` или равносильный выбор последней версии; батчевая
|
— через `FINAL` или равносильный выбор последней версии; батчевая
|
||||||
переобработка нижележащих объектов — по дневным партициям
|
переобработка нижележащих объектов — по дневным партициям
|
||||||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||||
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
применяется вовсе: обычная переобработка ограничена партицией, а полное
|
||||||
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
|
||||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
|
||||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
|
||||||
осознанная цена, не баг).
|
честно моргает (это осознанная цена, не баг).
|
||||||
|
- **Область очистки следует за областью состояния**: обычная разработка не
|
||||||
|
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
|
||||||
|
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
|
||||||
|
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
|
||||||
|
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
|
||||||
|
требуется удалить состояние вне прикладного мира либо проверить запуск именно
|
||||||
|
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
|
||||||
|
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
|
||||||
|
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
|
||||||
|
стирает следы неисправности и превращает редкий холодный запуск в постоянную
|
||||||
|
цену разработки.
|
||||||
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||||
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||||
а не при проектировании приёма.
|
а не при проектировании приёма.
|
||||||
@@ -607,9 +618,11 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
|||||||
|
|
||||||
Что гонять на приёмке этапа — строка в [карте
|
Что гонять на приёмке этапа — строка в [карте
|
||||||
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
||||||
запускать»: стенд собирается с нуля и на нём зелены все три цели, которым
|
запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
|
||||||
нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд,
|
проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
|
||||||
и молчит о его предмете — дашборд может быть не нарисован, а все три останутся
|
томов обязателен только для изменения начальной сборки или постоянного состояния
|
||||||
|
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
|
||||||
|
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
|
||||||
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
||||||
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
||||||
|
|
||||||
|
|||||||
@@ -402,9 +402,10 @@
|
|||||||
|
|
||||||
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||||||
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||||||
логе, а `make up` после #42 гоняет генератор по восемь дней при каждом
|
логе, а `world_initialize` гоняет генератор по восемь дней при первом создании
|
||||||
подъёме стенда: замедлись он на порядок — подъём стенда встанет колом, и
|
мира и явной пересборке. Замедлись он на порядок — операция встанет колом, и
|
||||||
заметит это первый же человек, который его поднял.
|
это увидит первый же человек, который создаёт мир; повторный `make up` готовый
|
||||||
|
мир не пересчитывает.
|
||||||
|
|
||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
@@ -480,12 +481,16 @@
|
|||||||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||||
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
она переменной Airflow: отсутствие переменной означает, что стартовый мир
|
||||||
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
|
||||||
«последний сыгранный + 1» и только по успеху.
|
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||||||
|
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
|
||||||
|
и ставят первый несыгранный день только после приёма стартового мира
|
||||||
|
([ADR 0013](../adr/0013-world-lifecycle.md)).
|
||||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
|
||||||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
|
||||||
|
заново залитым миром.
|
||||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
в норме ([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