Зачем: линия дефектов нашла опору на дефект провайдера, линия постановки — незаписанный ответ на вопрос ADR 0009 и переменную образца, чьё значение на чужой машине неверно, а узнаёт об этом читатель через двести строк. Что: `auto_remove` у прогона генератора переведён с `success` на `force`. Значение `success` тоже убирало контейнер в любом исходе, но случайно — удаление у провайдера 4.5.7 стоит в `finally` вопреки собственной документации; `force` то же поведение называет прямо и переживёт починку. У выключателя назван `catchup=False`: умолчание Airflow 3 то же самое, но у дага с тиком в 25 минут и `start_date` в январе это первый вопрос читателя. «Быстрый старт» предупреждает про `DOCKER_GID` — единственное значение образца, неверное вне этой машины. В образе Airflow записано, что провайдер docker приходит с базой (4.5.7 к 3.3.0), — это ответ на вопрос, который ADR 0009 оставил тикету. Список томов планировщика получил ту же пометку «правя одно, правьте второе», что стоит у числа дней. Проход на вычитание срезал три комментария, пересказывавших ADR. Проверка: `make lint`, `make config-test`, `make smoke` (20/0), `make check-clickhouse` (9/9) — зелёные. Живой пульт на чистом стенде: снятый с паузы `world_live` сыграл два дня подряд без нажатия (24 м 07 с и 24 м 06 с, пауза между ними 55 секунд), дочерний прогон виден ссылкой из задачи выключателя; пауза остановила мир на границе суток — тик прошёл, третьего прогона нет. Контейнер после прогона с `force` не остался. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.6 KiB
Python
184 lines
8.6 KiB
Python
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
||
|
||
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
||
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
||
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||
границе модельных суток.
|
||
|
||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||
|
||
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
||
спрашивает у переменной, а не у логической даты прогона.
|
||
|
||
Решения и доводы целиком — ADR 0009.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
import os
|
||
|
||
from airflow.providers.docker.operators.docker import DockerOperator
|
||
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||
from airflow.sdk import Param, Variable, dag, get_current_context, task
|
||
|
||
# Факты стенда — образ генератора, сеть, адрес брокера, топик и размер
|
||
# стартового мира — приходят окружением, и называет их compose: тот же, что
|
||
# называет их разовой службе генератора.
|
||
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
|
||
STAND_NETWORK = os.environ["STAND_NETWORK"]
|
||
GENERATOR_ENVIRONMENT = {
|
||
"KAFKA_BOOTSTRAP_SERVERS": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
|
||
"KAFKA_TOPIC": os.environ["KAFKA_TOPIC"],
|
||
}
|
||
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||
|
||
# Позиция на оси: номер первого несыгранного дня. Переменной нет — мир в
|
||
# стартовом состоянии, и играть надо сразу за ним.
|
||
#
|
||
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||
# ReplacingMergeTree. Увеличение молча съело бы день, и в мире осталась бы
|
||
# дыра, которой никто не заметит.
|
||
WORLD_POSITION = "world_position"
|
||
|
||
# Тик выключателя. Каденцию задаёт не он, а сама длина живого дня — около
|
||
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||
LIVE_TICK = datetime.timedelta(minutes=25)
|
||
|
||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||
TAGS = ["пульт мира"]
|
||
|
||
|
||
@task
|
||
def first_unplayed_day() -> int:
|
||
"""Номер дня, с которого играть."""
|
||
return int(Variable.get(WORLD_POSITION, default=STARTING_DAYS))
|
||
|
||
|
||
def _play(task_id: str, command: list[str]) -> DockerOperator:
|
||
"""Задача, играющая дни в каноническом контейнере генератора.
|
||
|
||
Внутрь образа Airflow генератор не поставить: он требует Python 3.14, а
|
||
образ несёт 3.13. Да и обещание побайтовой воспроизводимости дано для
|
||
зафиксированного образа генератора — держится оно только там.
|
||
"""
|
||
return DockerOperator(
|
||
task_id=task_id,
|
||
image=GENERATOR_IMAGE,
|
||
command=command,
|
||
network_mode=STAND_NETWORK,
|
||
environment=GENERATOR_ENVIRONMENT,
|
||
# Контейнер убирается в любом исходе: вывод генератора оператор уже
|
||
# перелил в журнал задачи, а мёртвые контейнеры копить незачем.
|
||
auto_remove="force",
|
||
# По умолчанию оператор монтирует контейнеру временный каталог. Здесь
|
||
# это ловушка: путь он заводит внутри Airflow, а монтирует демон с
|
||
# хоста, где такого пути нет. Генератору временный каталог не нужен.
|
||
mount_tmp_dir=False,
|
||
)
|
||
|
||
|
||
@dag(
|
||
dag_id="world_next_day",
|
||
schedule=None,
|
||
start_date=START_DATE,
|
||
is_paused_upon_creation=False,
|
||
max_active_runs=1,
|
||
tags=TAGS,
|
||
params={"days": Param(1, type="integer", minimum=1, title="Сколько дней прожить")},
|
||
)
|
||
def world_next_day():
|
||
"""Прожить следующие дни пачкой, без пауз.
|
||
|
||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||
нажимом, а не десятью: сколько дней играть — параметр запуска.
|
||
"""
|
||
|
||
@task
|
||
def remember_played(first_day: int) -> None:
|
||
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||
days = get_current_context()["params"]["days"]
|
||
Variable.set(WORLD_POSITION, str(first_day + days))
|
||
|
||
first_day = first_unplayed_day()
|
||
played = _play(
|
||
"play_days",
|
||
[
|
||
"batch",
|
||
"--day",
|
||
"{{ ti.xcom_pull(task_ids='first_unplayed_day') }}",
|
||
"--days",
|
||
"{{ params.days }}",
|
||
],
|
||
)
|
||
|
||
first_day >> played >> remember_played(first_day)
|
||
|
||
|
||
@dag(
|
||
dag_id="world_live_day",
|
||
schedule=None,
|
||
start_date=START_DATE,
|
||
is_paused_upon_creation=False,
|
||
max_active_runs=1,
|
||
tags=TAGS,
|
||
)
|
||
def world_live_day():
|
||
"""Прожить следующий день в темпе модельного времени.
|
||
|
||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||
"""
|
||
|
||
@task
|
||
def remember_played(first_day: int) -> None:
|
||
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||
|
||
first_day = first_unplayed_day()
|
||
played = _play(
|
||
"play_day",
|
||
["live", "--day", "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"],
|
||
)
|
||
|
||
first_day >> played >> remember_played(first_day)
|
||
|
||
|
||
@dag(
|
||
dag_id="world_live",
|
||
schedule=LIVE_TICK,
|
||
start_date=START_DATE,
|
||
catchup=False,
|
||
is_paused_upon_creation=True,
|
||
max_active_runs=1,
|
||
tags=TAGS,
|
||
)
|
||
def world_live():
|
||
"""Выключатель: пока включён, мир живёт день за днём.
|
||
|
||
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
|
||
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
|
||
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
|
||
промчался бы по ней без всякого темпа.
|
||
|
||
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
|
||
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
|
||
краснеет и выключатель.
|
||
"""
|
||
TriggerDagRunOperator(
|
||
task_id="trigger_live_day",
|
||
trigger_dag_id="world_live_day",
|
||
wait_for_completion=True,
|
||
)
|
||
|
||
|
||
world_next_day()
|
||
world_live_day()
|
||
world_live()
|