diff --git a/CONTEXT.md b/CONTEXT.md index 3f726a4..4288cf2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -120,6 +120,11 @@ _Избегать_: манифест, мини-манифест Проигрывание готового дня пачкой, без темпа: заливка снимка при старте стенда, пересборки и проверки. +**Пульт мира**: +Даги Airflow, которыми двигают ось модельного времени: работники играют день — +пачкой или в темпе, — а выключатель держит стенд живущим, пока включён. +_Избегать_: управляющие даги + **Граница суток**: Единственный структурный шов модели: сессии режутся по ней, слепок заказов снимается на ней, день проживается только целиком. diff --git a/docs/adr/0009-world-control.md b/docs/adr/0009-world-control.md new file mode 100644 index 0000000..b3092a5 --- /dev/null +++ b/docs/adr/0009-world-control.md @@ -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`) достаётся дагу без ручной подстановки; что +триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд +двигают позицию на два дня, а обрыв — ни на один. diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index a66e7b5..b874ab4 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -105,7 +105,8 @@ - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг - «дышат»; включается по требованию, не постоянный фон. + «дышат»; включается по требованию — постоянным фоном идёт, только пока + включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)). - **Граница суток — единственный структурный шов.** Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день @@ -445,10 +446,12 @@ теперь есть. - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. -- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит - (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она - переменной Airflow: отсутствие переменной означает мир в стартовом - состоянии, заводит и двигает её только даг `next_day` и только по успеху. +- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 — + [ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не + хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт + она переменной Airflow: отсутствие переменной означает мир в стартовом + состоянии, а ставит её тот даг пульта, который сыграл день, — значением + «последний сыгранный + 1» и только по успеху. Довод — генератор отдельная и заменяемая сущность, привязывать его к хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с данными ClickHouse, так что позиция и мир чистятся одной командой.