Files
clickstream-data-platform/docs/specs/2026-08-21-world-lifecycle.md
T
ddadmin 61e662c19c refactor(etl): упрощена проверка готовности Kafka
- Зачем:
  - верхние смещения можно получить тем же административным клиентом без одноразового Consumer и фиктивной группы.
- Что:
  - чтение хвоста переведено на AdminClient.list_offsets с OffsetSpec.latest.
  - назначение system.one названо рядом с запросом, а документация и запись проверок сокращены и переставлены по смыслу.
- Проверка:
  - make config-test; make lint.
  - живые проверки готового и отсутствующего разделов Kafka; полный etl_pipeline.
2026-08-27 11:17:43 +03:00

22 KiB
Raw Blame History

Жизненный цикл мира: создание и пересоздание ведёт Airflow

Дата: 21 августа 2026 года. Решение и доводы — ADR 0013; здесь устройство, которое из него следует.

Задача

До этой работы начало оси мира собирали трое. Compose создавал топики, применял DDL и двумя одноразовыми службами заливал восемь дней. Shell-сценарии ждали, пока события доедут до ODS, и разово дёргали даг приёма заказов. Airflow вёл мир после стартовой заливки.

Граница владения не совпадала с процессом, и это видно по следствиям. Успешная отправка в Kafka выдавалась за созданный мир: world-init завершался сразу после записи в топик, до приёма сообщений хранилищем. Повторный make up смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем, кто миром не управляет.

Чему учит переезд. Менти видит один наблюдаемый граф, которому принадлежат прикладная схема, данные мира и позиция модельного времени, — и Compose, который держит только инфраструктуру. Зеленая ветка создания в world_initialize подтверждает: схема создана, оба источника приняты, следующий день известен, слои DDS и DM собраны.

Решение

Граница владения одна: 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). Ей разрешено администрировать прикладные базы — заводить и удалять их и объекты внутри, — читать их и выполнять кластерный DDL. Ей разрешено чтение system.settings: без него драйвер clickhouse-connect под официальным провайдером Airflow не открывает соединение. Административные запросы к системным базам ей запрещены.

etl при этом не расширяется. Право удаления баз нужно редкой операции восстановления и остаётся у lifecycle. Граница проверяема: при лишнем запросе менти увидит в ошибке имя учётной записи и недостающее право.

Что Airflow получает от репозитория

Каталог /opt/airflow/world — то, что нужно для создания мира: generator/, data/ и корневой .dockerignore смонтированы только для чтения. Из первого собирается канонический образ генератора, из второго читается опись мира, а последний не пускает в контекст сборки локальные окружения и кэши. Запросы стенда приезжают прежним монтированием sql/.

Меняющиеся API сверены через Context7 и затем с установленными пакетами стенда. Выбраны публичный REST API v2 и ждущий TriggerDagRunOperator Airflow, сборка образа через DockerHook.get_conn().build(), futures AdminClient для Kafka, SQLExecuteQueryOperator для исполняемых SQL-файлов ClickHouse и ClickHouseHook для запросов, чей результат обрабатывает Python. Оператор сам шаблонизирует файл и при split_statements=True исполняет выражения по порядку; собственный разбор SQL дагу больше не нужен.

Контракты дагов

Оба дага публичные: без расписания, не на паузе, max_active_runs = 1. Их запускают руками из интерфейса и командой make через REST API.

world_initialize — создать мир, если его нет

Даг безопасен по построению: на готовом мире он ничего не меняет.

  1. Читает world_position и решает, что делать дальше: инициализировать, успешно завершиться без изменений или покраснеть. Разбор состояний — в таблице ниже.
  2. Создаёт топики: hits с двумя партициями, orders с одной. Топики идут до DDL: чтец ClickHouse начинает читать топик, когда над ним появляется матвью, и топик, созданный брокером сам, приехал бы с одним разделом (ADR 0008).
  3. Применяет канонический DDL из sql/ddl/ по порядку имён файлов, с ноды 1, учётной записью lifecycle. Каждый файл — отдельная последовательная задача SQLExecuteQueryOperator; выражения внутри неё исполняются по порядку с split_statements=True. Красная задача называет файл и не пускает последующие. Правило «матвью приёма создаётся последней» остаётся за раскладкой файлов и не переезжает в даг.
  4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого кода: сборкой образа при подъёме больше никто не занимается.
  5. Играет стартовые дни одним прогоном batch того же образа.
  6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего сценария, и оно точное: сумма строк ods.event_dist за даты описи и строк ods.event_errors_dist не меньше суммы событий из data/world-inventory.json. «Что-то приехало» условием не считается.
  7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6 (заказы в стартовом мире).
  8. Дергает orders_ingest ждущим триггером и продолжает только после его успеха.
  9. Ставит позицию в WORLD_STARTING_DAYS: источники приняты, мир уже создан.
  10. Последним шагом дергает etl_pipeline ждущим триггером. Конвейер последовательно ждет dds_transform и dm_transform; отказ слоя красит инициализацию.

Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная задача называет место остановки. Ветка "мир уже существует" конвейер не запускает: обычный повтор make up по-прежнему ничего не меняет.

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 просто пробует её заново — с того же места и с тем же результатом.

ETL упал после установки позиции. Мир и оба источника уже готовы, поэтому позиция остается в WORLD_STARTING_DAYS. После исправления запускают рукой etl_pipeline: он сам выводит объем из состояния данных. Повторный world_initialize здесь не поможет: при готовой позиции инициализация уйдет в короткую ветку без изменений.

Позицию в WORLD_STARTING_DAYS ставит только успех обоих источников. События доехали, слепки приняты, orders_ingest зелёный — тогда и только тогда мир считается созданным.

Порядок очистки Kafka входит в контракт. Пока живы чтецы ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо. Поэтому сначала удаляются базы, затем топики.

Позицию удалили руками на живом мире. Даг честно зальёт мир заново: сырьё в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти делает себе сам и тут же видит.

Слоты Airflow. Их четыре. При пересборке world_recreate ждет world_initialize, тот ждет etl_pipeline, а конвейер ждет слой. В живом режиме такую же цепочку образуют world_live, world_live_day, etl_pipeline и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот. Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому пересборка — явная операция обслуживания: оператор ставит 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 ровно даты описи, топики созданы заново, офсеты групп идут с нуля, DDS и DM собраны.
  • Красные пути проверяются своими отказами. Сторож работника при отсутствующей, нулевой и отрицательной позиции; world_initialize на -1; оборванная очистка, оставляющая -1. Проверка, не видевшая своей поломки, доказывает только то, что умеет печатать «ЗЕЛЁНО».
  • Отказ в правах читается. Учётная запись lifecycle применяет весь DDL без единой ошибки прав, а административный запрос к системной базе ей отклоняют — и текст отказа называет учётку.
  • Один холодный make clean && make up оправдан на итоговой приёмке и только этой работой: она меняет самый первый подъём стенда, а его иначе не увидеть. Обобщать этот случай не нужно — обычная проверка по-прежнему обходится узкой областью очистки.

Замеры холодного и повторного make up и пересборки записываются в карту проверок, раздел «Что проверено».

Отклонённые варианты

  • Спрятать одноразовые службы за профилем или звать их compose run. Дешевле всего в правках, но оставляет двух оркестраторов одного процесса — ровно то, ради чего затевался переезд.
  • Оставить применение DDL и заливку мира в shell, а Airflow отдать только позицию. Тогда «один наблюдаемый процесс» остаётся обещанием: половина графа живёт в журнале терминала.
  • Вынести общие шаги двух дагов в отдельный модуль. Третье место для той же последовательности; расхождение дагов пряталось бы в его параметрах.
  • Применять изменённый DDL при каждом make up. Это мигратор схемы; ему нужен отдельный разговор, в граф создания мира он не помещается.
  • Выдать права на удаление баз учётной записи etl. Отвергнуто ещё ADR 0013: конвейер не получает административные права из-за редкой операции.
  • Завести блокировку или статус обслуживания, чтобы пересборка не сталкивалась с движением мира. Механизм на редкий ручной случай дороже предусловия, названного словами.
  • Сторож на orders_ingest. Даг зовут до того, как мир существует; сторож запретил бы саму инициализацию.