docs(adr): решён пульт мира — работники, выключатель, контейнер
Зачем: модельный день прогоняется только руками, и владельцу нечем проверять процессы стенда вживую. Этап 3 без дага не существует вовсе (ADR 0008), поэтому форму пульта и способ вызова генератора надо решить раньше кода. Что: ADR 0009 — работники без расписания (`world_next_day` пачкой, `world_live_day` в темпе) и выключатель `world_live` без своей работы, ждущий дочерний прогон триггером, а не сенсором; генератор зовётся `DockerOperator` в каноническом контейнере, потому что в образе Airflow ему не жить (Python 3.14 против 3.13); позиция ставится как «последний сыгранный + 1» — правило вместо сторожа, и его мягкий худший исход назван. Плата за сокет докера снята замером: доступ открывается добавлением GID в контейнер, правка прав на хосте не нужна, а сам GID — локальная настройка в `.env`. Пакетная автоматика отложена, не отвергнута. Расхождения внесены в спеку генератора: позицию ставит сыгравший, у живого дня появился выключатель. В словарь добавлен «пульт мира». Проверка: `make config-test` зелёный; API Airflow 3.3 (пауза и ручной запуск, ожидание триггера по идентификатору прогона, привязка сенсора к логической дате, `DockerOperator`) сверено через MCP Context7; версия Python в образе и права сокета — замером. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,11 @@ _Избегать_: манифест, мини-манифест
|
|||||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||||
стенда, пересборки и проверки.
|
стенда, пересборки и проверки.
|
||||||
|
|
||||||
|
**Пульт мира**:
|
||||||
|
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
||||||
|
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||||
|
_Избегать_: управляющие даги
|
||||||
|
|
||||||
**Граница суток**:
|
**Граница суток**:
|
||||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||||
снимается на ней, день проживается только целиком.
|
снимается на ней, день проживается только целиком.
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# ADR 0009. Пульт мира: работники, выключатель и канонический контейнер
|
||||||
|
|
||||||
|
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||||
|
|
||||||
|
**Работники** — без расписания и без паузы, запускаются руками:
|
||||||
|
|
||||||
|
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1) даёт
|
||||||
|
разгон вперёд одним нажимом;
|
||||||
|
- `world_live_day` — один день в темпе живого режима.
|
||||||
|
|
||||||
|
**Выключатель** — без своей работы: `world_live` несёт расписание около двадцати
|
||||||
|
пяти минут, создаётся на паузе и дёргает `world_live_day`
|
||||||
|
через `TriggerDagRunOperator`, дожидаясь его завершения. Снят с паузы — стенд
|
||||||
|
живёт день за днём; поставлен на паузу — мир встал на границе модельных суток.
|
||||||
|
|
||||||
|
Генератор задача зовёт через `DockerOperator`: тот поднимает образ
|
||||||
|
`clickstream-generator:local` — тот самый канонический контейнер, внутри
|
||||||
|
которого [спека генератора](../specs/2026-08-01-generator.md) обещает побайтовую
|
||||||
|
воспроизводимость, — в сети стенда, командой `batch` или `live`.
|
||||||
|
|
||||||
|
Позиция на оси живёт переменной Airflow, как обещает раздел 8 той же спеки.
|
||||||
|
**Ставит её тот, кто сыграл день** — работник, и только по успеху, значением
|
||||||
|
«последний сыгранный день + 1». Выключатель к переменной не прикасается. День
|
||||||
|
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
||||||
|
к оси мира отношения не имеет.
|
||||||
|
|
||||||
|
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
||||||
|
**отложена, а не отвергнута**.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
**Работники без расписания, выключатели без работы.** Иначе кнопка паузы значит
|
||||||
|
две вещи разом: «мир не едет сам» и «даг выключен». Расписание на самом
|
||||||
|
работнике формально живёт с ручным запуском — пауза, по документации Airflow 3,
|
||||||
|
запрещает планирование, но ручной запуск разрешает, — только в списке дагов
|
||||||
|
работник выглядит выключенным, а нажимают его каждый день. У предшественника
|
||||||
|
стоит ровно эта форма: `world_next_day` с расписанием и на паузе, к ней
|
||||||
|
объясняющий комментарий в коде и сторож `assert_target_dag_not_paused` на случай
|
||||||
|
триггера в спящий даг. Разделение снимает и комментарий, и сторожа: работника
|
||||||
|
никогда не ставят на паузу, поэтому дёргать спящего некому.
|
||||||
|
|
||||||
|
**Канонический контейнер — условие обещания, а не вкус.** Детерминизм генератора
|
||||||
|
обещан внутри зафиксированного образа, и обещание держится только там. Поставить
|
||||||
|
пакет прямо в образ Airflow, как делал предшественник, здесь нельзя даже
|
||||||
|
механически: генератор требует Python 3.14, а `apache/airflow:3.3.0` несёт 3.13.
|
||||||
|
Собрать в образе Airflow второе окружение из того же `uv.lock` — можно, но это
|
||||||
|
второе место установки генератора, пересборка образа Airflow на каждую правку
|
||||||
|
мира и раскладка каталога товаров, повторённая монтированиями руками: образ
|
||||||
|
генератора делает эту работу сам. Заодно `DockerOperator` держит генератор
|
||||||
|
отдельной и заменяемой сущностью, как решил раздел 8 спеки, и показывает
|
||||||
|
приём, у которого есть боевой родственник — так в бою запускают шаг подом.
|
||||||
|
|
||||||
|
**Выключатель ждёт, и от этого зависит устойчивость.** Не ждущий выключатель
|
||||||
|
тикает по расписанию независимо от хода дня: живой день длится около двадцати
|
||||||
|
четырёх минут, а у работника `max_active_runs = 1`, поэтому лишние триггеры
|
||||||
|
скопились бы очередью прогонов, и мир потом промчался бы по ней без темпа. С
|
||||||
|
ожиданием каденцию задаёт сама длина дня, а расписание остаётся грубым тиком «не
|
||||||
|
пора ли снова»: пауза между днями равна остатку до следующего тика, до минуты.
|
||||||
|
|
||||||
|
**Ждём триггером, а не сенсором.** Различаются они тем, чем опознают прогон.
|
||||||
|
`TriggerDagRunOperator` с `wait_for_completion` ждёт тот прогон, который сам и
|
||||||
|
создал, — опрос идёт по его идентификатору. `ExternalTaskSensor` ждёт задачу
|
||||||
|
чужого дага **за конкретную логическую дату**: пришлось бы передать работнику
|
||||||
|
логическую дату и повторить её в сенсоре, то есть завести вторую бухгалтерию
|
||||||
|
времени сразу после того, как решение выше её запретило. Сенсор здесь и как урок
|
||||||
|
был бы поддельным: его дом там, где ждут события, которого сам не производишь, а
|
||||||
|
у пульта производитель и потребитель — свои же даги. Тем же доводом
|
||||||
|
[ADR 0008](0008-order-ingestion.md) снял сенсор дневного батча. Настоящая
|
||||||
|
механика показана и без него: родитель, ждущий чужого прогона, со ссылкой на
|
||||||
|
дочерний прогон в интерфейсе и явными состояниями отказа — упал день, покраснел
|
||||||
|
и выключатель. Это [ADR 0003](0003-dag-run-state.md) в действии: зелёный конец
|
||||||
|
графа не должен переживать отказ выше.
|
||||||
|
|
||||||
|
**Позиция ставится, а не увеличивается.** Правило дешевле сторожа. Если прогон
|
||||||
|
выключателя наложится на ручной, худшее — день сыгран дважды: номера событий
|
||||||
|
детерминированы, повтор схлопнет `ReplacingMergeTree`, а позиция останется
|
||||||
|
верной. Увеличение на единицу в том же случае молча проглотило бы день, и мир
|
||||||
|
получил бы дыру, которую никто не заметит. Ни пул как мьютекс, ни проверка «а не
|
||||||
|
идёт ли другой прогон» после этого не нужны.
|
||||||
|
|
||||||
|
Худший исход у правила всё-таки есть, и он мягкий: работник, кончивший позже,
|
||||||
|
ставит позицию по своему последнему дню и может отодвинуть её назад — так
|
||||||
|
бывает, если долгий живой день закончится после разгона на три дня вперёд.
|
||||||
|
Тогда часть дней переигрывается. Это дешевле дыры: переигранный день склеится,
|
||||||
|
пропущенный не найдётся никогда.
|
||||||
|
|
||||||
|
**Плата — сокет докера внутри Airflow.** Доступ к нему равен праву root на
|
||||||
|
машине, и пользователю `airflow` он открывается добавлением GID группы `docker`
|
||||||
|
в контейнер. GID у каждой машины свой, поэтому он — локальная настройка и живёт
|
||||||
|
в `.env` рядом с остальными такими, а не в описании стенда. Для локального учебного стенда размен принят —
|
||||||
|
тот же, что уже принят для пароля в trace-журнале пробника, — но плата
|
||||||
|
называется вслух и в README. Вторая плата мелкая: пока стенд живёт, из четырёх
|
||||||
|
слотов `parallelism` заняты два, ждущий выключатель и работающий контейнер.
|
||||||
|
Освободить слот ожидания умеет `deferrable`, но он требует компонент triggerer,
|
||||||
|
которого стенд не поднимает; заводить службу ради одного ожидания дороже
|
||||||
|
занятого слота.
|
||||||
|
|
||||||
|
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
|
||||||
|
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
|
||||||
|
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
|
||||||
|
живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет
|
||||||
|
(ускорение сверх ×60 спека отвергла как перемотку). Желание правдоподобное, но
|
||||||
|
пока не названное, а форма известна и стоит тридцать строк: второй экземпляр
|
||||||
|
выключателя на второго работника. Отложить дешевле, чем завести и объяснять.
|
||||||
|
|
||||||
|
**Что отвергнуто ещё.** Пульт на `make` с позицией в файле: дёшево сегодня, но
|
||||||
|
раздел 8 спеки уже отдал позицию переменной Airflow, а этап 3 без дага не
|
||||||
|
существует вовсе ([ADR 0008](0008-order-ingestion.md)) — платить пришлось бы
|
||||||
|
дважды, и второй хранитель позиции разошёлся бы с первым. Один долгий прогон
|
||||||
|
`live` на много дней: поток без швов, но позиция двигается только по успеху всего
|
||||||
|
прогона, а такой прогон всегда обрывают — переменная не сдвинулась бы никогда, и
|
||||||
|
обрыв на десятом дне откатывал бы все десять. Цепочка на ресурсах Airflow 3
|
||||||
|
(работник выпускает событие, выключатель на него подписан): каденция вышла бы
|
||||||
|
точной и без ожидания, но снять выключатель с паузы недостаточно — цепочку надо
|
||||||
|
толкнуть первым ручным прогоном, а читателю разбирать петлю «работник → ресурс →
|
||||||
|
выключатель → работник». Цена расшифровки выше урока.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
|
||||||
|
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
|
||||||
|
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
|
||||||
|
правило позиции.
|
||||||
|
|
||||||
|
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
|
||||||
|
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
|
||||||
|
«сыгранный + 1») и раздел 1 там же вместе со словарём (живой день «не постоянный
|
||||||
|
фон»; теперь фон у него есть, и у фона есть выключатель).
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
По документации Airflow 3 через MCP Context7 и живым замером образа, 13 августа
|
||||||
|
2026 года.
|
||||||
|
|
||||||
|
- Пауза дага запрещает планирование, но **разрешает ручной запуск**
|
||||||
|
(`core-concepts/dags.rst`).
|
||||||
|
- `TriggerDagRunOperator` с `wait_for_completion` опрашивает прогон, который сам
|
||||||
|
создал, по его идентификатору; без этого флага `allowed_states`,
|
||||||
|
`failed_states` и `poke_interval` не действуют вовсе. Вариант `deferrable`
|
||||||
|
требует компонент triggerer.
|
||||||
|
- `ExternalTaskSensor` ждёт задачу чужого дага за конкретные логические даты;
|
||||||
|
сдвиги задаются `execution_delta` и `execution_date_fn`.
|
||||||
|
- Сенсор в режиме `poke` держит слот всё время, в режиме `reschedule` —
|
||||||
|
только на время проверки.
|
||||||
|
- У `DockerOperator` `network_mode` принимает имя пользовательской сети, есть
|
||||||
|
`auto_remove` и `skip_on_exit_code`.
|
||||||
|
- Образ `apache/airflow:3.3.0` несёт Python 3.13.14 (замер запуском образа);
|
||||||
|
генератор требует `>=3.14,<3.15`.
|
||||||
|
- Сокет докера на машине стенда — `root:docker` с правами `660`, GID группы 127;
|
||||||
|
пользователь образа Airflow — `uid=50000(airflow) gid=0(root)`, в группе
|
||||||
|
`docker` его нет. Значит доступ открывается добавлением GID в контейнер, а
|
||||||
|
правка прав на хосте не нужна.
|
||||||
|
|
||||||
|
Осталось проверить при исполнении, и это работа тикета: какая версия провайдера
|
||||||
|
`apache-airflow-providers-docker` встаёт к 3.3.0; что имя сети стенда
|
||||||
|
(`${COMPOSE_PROJECT_NAME}_default`) достаётся дагу без ручной подстановки; что
|
||||||
|
триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд
|
||||||
|
двигают позицию на два дня, а обрыв — ни на один.
|
||||||
@@ -105,7 +105,8 @@
|
|||||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||||
«дышат»; включается по требованию, не постоянный фон.
|
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||||||
|
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||||
@@ -445,10 +446,12 @@
|
|||||||
теперь есть.
|
теперь есть.
|
||||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||||
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
|
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||||
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
|
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||||
переменной Airflow: отсутствие переменной означает мир в стартовом
|
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||||
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
|
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||||
|
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
||||||
|
«последний сыгранный + 1» и только по успеху.
|
||||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||||||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
||||||
|
|||||||
Reference in New Issue
Block a user