Files
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

16 KiB
Raw Permalink Blame History

ADR 0009. Пульт мира: работники, выключатель и канонический контейнер

Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.

Решение

Ось модельного времени двигают даги пульта, и они двух родов.

Работники — без расписания и без паузы, запускаются руками:

  • world_next_day — день пачкой; параметр «сколько дней» (умолчание 1) даёт разгон вперёд одним нажимом;
  • world_live_day — один день в темпе живого режима.

Выключатель — без своей работы: world_live несёт расписание около двадцати пяти минут, создаётся на паузе и дёргает world_live_day через TriggerDagRunOperator, дожидаясь его завершения. Снят с паузы — стенд живёт день за днём; поставлен на паузу — мир встал на границе модельных суток.

Генератор задача зовёт через DockerOperator: тот поднимает образ clickstream-generator:local — тот самый канонический контейнер, внутри которого спека генератора обещает побайтовую воспроизводимость, — в сети стенда, командой 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 снял сенсор дневного батча. Настоящая механика показана и без него: родитель, ждущий чужого прогона, со ссылкой на дочерний прогон в интерфейсе и явными состояниями отказа — упал день, покраснел и выключатель. Это ADR 0003 в действии: зелёный конец графа не должен переживать отказ выше.

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

Худший исход у правила всё-таки есть, и он мягкий: работник, кончивший позже, ставит позицию по своему последнему дню и может отодвинуть её назад — так бывает, если долгий живой день закончится после разгона на три дня вперёд. Тогда часть дней переигрывается. Это дешевле дыры: переигранный день склеится, пропущенный не найдётся никогда.

Плата — сокет докера внутри Airflow. Доступ к нему равен праву root на машине, и пользователю airflow он открывается добавлением GID группы docker в контейнер. GID у каждой машины свой, поэтому он — локальная настройка и живёт в .env рядом с остальными такими, а не в описании стенда. Для локального учебного стенда размен принят — тот же, что уже принят для пароля в trace-журнале пробника, — но плата называется вслух и в README. Вторая плата мелкая: пока стенд живёт, из четырёх слотов parallelism заняты два, ждущий выключатель и работающий контейнер. Освободить слот ожидания умеет deferrable, но он требует компонент triggerer, которого стенд не поднимает; заводить службу ради одного ожидания дороже занятого слота.

Пакетная автоматика отложена, потому что своего желания у неё пока одно. «Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет (ускорение сверх ×60 спека отвергла как перемотку). Желание правдоподобное, но пока не названное, а форма известна и стоит тридцать строк: второй экземпляр выключателя на второго работника. Отложить дешевле, чем завести и объяснять.

Что отвергнуто ещё. Пульт на make с позицией в файле: дёшево сегодня, но раздел 8 спеки уже отдал позицию переменной Airflow, а этап 3 без дага не существует вовсе (ADR 0008) — платить пришлось бы дважды, и второй хранитель позиции разошёлся бы с первым. Один долгий прогон live на много дней: поток без швов, но позиция двигается только по успеху всего прогона, а такой прогон всегда обрывают — переменная не сдвинулась бы никогда, и обрыв на десятом дне откатывал бы все десять. Цепочка на ресурсах Airflow 3 (работник выпускает событие, выключатель на него подписан): каденция вышла бы точной и без ожидания, но снять выключатель с паузы недостаточно — цепочку надо толкнуть первым ручным прогоном, а читателю разбирать петлю «работник → ресурс → выключатель → работник». Цена расшифровки выше урока.

Следствия

Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов (ADR 0008) прирастает к тому же работнику, а 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) достаётся дагу без ручной подстановки; что триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд двигают позицию на два дня, а обрыв — ни на один.