Files
clickstream-data-platform/docs/adr/0009-world-control.md
T
ddadminandClaude Opus 5 61bc156d6a 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>
2026-08-13 12:35:30 +03:00

163 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) достаётся дагу без ручной подстановки; что
триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд
двигают позицию на два дня, а обрыв — ни на один.