From bec56da1c3efc780fe4cb771aa62f13668c4f898 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 19 Jul 2026 22:17:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(airflow):=20=D0=BD=D0=B5=D0=BE=D0=B1=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20generator?= =?UTF-8?q?=5Fcontrol=20=D1=81=D1=82=D0=B0=D0=BB=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - форма Trigger DAG требовала заполнить все поля: канонический запуск «выбрать профиль, остальное пусто» через UI был невозможен (находка F1 ручного HITL); артефакты backfill (data/*.json, 49 МБ) рисковали попасть в коммит. - Что: - пять необязательных Param переведены на Param(None, type=["null", "string"]) — идиома необязательного поля, проверено по Context7 (Airflow 2.10.5); все места чтения уже None-безопасны (or ""). - контрактный тест дополнен: у необязательных Param есть "null" в type, у operation/profile — нет. - .gitignore: правило data/*.json (сиды data/*.jsonl остаются под git); задача 21 закрыта в .scratch. - Проверка: - make test (205 + 31 passed) и make lint — зелёные. --- .gitignore | 2 + ...21-merge-prep-gitignore-nullable-params.md | 73 +++++++++++++++++++ airflow/dags/generator_control_dag.py | 21 +++--- .../test_generator_control_dag_contract.py | 42 +++++++++++ 4 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 .scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md diff --git a/.gitignore b/.gitignore index 3338a5e..8d774d6 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,8 @@ desktop.ini # Тестовые данные и артефакты # ============================================================================= # Файлы с данными (но не папки data/ — там исходные данные проекта) +# JSON-артефакты генератора; сиды data/*.jsonl остаются под Git. +data/*.json *.csv.gz *.jsonl.gz *.parquet diff --git a/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md b/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md new file mode 100644 index 0000000..1a39651 --- /dev/null +++ b/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md @@ -0,0 +1,73 @@ +Status: done + +# Подготовка ветки к слиянию: .gitignore для артефактов и необязательные поля формы + +## Parent + +`.scratch/generator-model-time-startup-history/PRD.md` — закрывающая +гигиена перед слиянием `feature/data-generator` в `main`. Находка F1 — из +`.scratch/generator-model-time-startup-history/hitl-findings.md`. + +## Что не так + +1. **Побочные артефакты backfill не покрыты `.gitignore`.** После HITL в + `data/` лежит `ci_backfill.json` (49 МБ) — риск случайного коммита. + Сиды проекта в `data/` — это `*.jsonl`, они под git и должны остаться. +2. **F1 (BUG):** в форме `generator_control` (Trigger DAG w/ config) все + пять необязательных полей (`duration`, `seed`, `model_time_speed`, + `artifact_path`, `expected_t_end`) показаны обязательными: красная `*`, + браузер не даёт отправить форму с пустым полем. Канонический запуск + «выбрать профиль, остальное пусто» через UI невозможен. + +## Причина F1 (проверено) + +В `airflow/dags/generator_control_dag.py:230-265` эти `Param(...)` +объявлены с `type="string"`. Шаблон формы Airflow вешает `*` и HTML-атрибут +`required` на каждое поле, у которого в `type` нет `"null"`. + +Проверено по Context7 (`/apache/airflow/2.10.5`, 2026-07-19): идиома +необязательного строкового параметра — `Param(None, type=["null", "string"])`; +поле с `"null"` в `type` форма не помечает обязательным. + +Чтение параметров в DAG уже терпимо к `None`: везде +`str(_param(...) or "").strip()` (строки 71-161), поэтому смена пустого +значения с `""` на `None` ничего не ломает — но это надо перепроверить +глазами по каждому месту чтения. + +## Что сделать + +- [x] В `.gitignore` добавить правило `data/*.json` (раздел «Тестовые + данные и артефакты») с комментарием: артефакты генератора; сиды + `data/*.jsonl` остаются под git. +- [x] Пять необязательных `Param` в `airflow/dags/generator_control_dag.py` + перевести на `Param(None, type=["null", "string"])`. Обязательные + (`operation`, `profile`) не трогать. У изменённых Param коротким + комментарием зафиксировать: `"null"` в type = необязательное поле + формы (проверено по Context7, Airflow 2.10.5). +- [x] Убедиться, что каждое место чтения этих параметров в DAG переживает + `None` (сегодня везде `or ""` — сверить полный список). +- [x] В `generator/tests/test_generator_control_dag_contract.py` дополнить + контракт: у пяти необязательных Param в объявлении есть `"null"`, + у `operation`/`profile` — нет. +- [x] `docs/OPERATIONS.md`: сверить описание формы `generator_control`; + если там есть обход «заполняйте все поля» — убрать, поведение + «пусто = из профиля» оставить как есть. +- [x] Дешёвые проверки зелёные: `make test` и `make lint` (нужен docker; + если в песочнице исполнителя недоступен — явно сказать в отчёте, + прогонит оркестратор). + +## Границы + +- Логику генератора (`generator/src/`) не менять. +- Профили и их состав не трогать (это отдельная задача редизайна). +- Форму DAG не перестраивать (отдельный DAG `next-day` — тоже отдельная + задача, F3). +- `data/ci_backfill.json` не удалять и не коммитить. +- Коммиты не делать. + +## Сначала прочитать + +1. Этот файл. +2. `.scratch/generator-model-time-startup-history/hitl-findings.md` — F1. +3. `airflow/dags/generator_control_dag.py` — объявления и чтение Param. +4. `generator/tests/test_generator_control_dag_contract.py`. diff --git a/airflow/dags/generator_control_dag.py b/airflow/dags/generator_control_dag.py index 38bc76f..3b80735 100644 --- a/airflow/dags/generator_control_dag.py +++ b/airflow/dags/generator_control_dag.py @@ -227,27 +227,28 @@ with DAG( title="Профиль", description="Именованный набор настроек генератора.", ), + # "null" в type делает поля формы необязательными (Context7, Airflow 2.10.5). "duration": Param( - "", - type="string", + None, + type=["null", "string"], title="Длительность", description="Например 6h или 2d. Пусто — взять длительность из профиля.", ), "seed": Param( - "", - type="string", + None, + type=["null", "string"], title="GEN_SEED", description="Пусто — взять seed из профиля.", ), "model_time_speed": Param( - "", - type="string", + None, + type=["null", "string"], title="GEN_MODEL_TIME_SPEED", description="Пусто — взять скорость модельного времени из профиля.", ), "artifact_path": Param( - "", - type="string", + None, + type=["null", "string"], title="Артефакт", description=( "Backfill: куда сохранить файл; пусто — не сохранять. " @@ -255,8 +256,8 @@ with DAG( ), ), "expected_t_end": Param( - "", - type="string", + None, + type=["null", "string"], title="Ожидаемая граница next-day", description=( "Необязательный model_t_end до запуска. Защищает от " diff --git a/generator/tests/test_generator_control_dag_contract.py b/generator/tests/test_generator_control_dag_contract.py index 916c4b4..3412034 100644 --- a/generator/tests/test_generator_control_dag_contract.py +++ b/generator/tests/test_generator_control_dag_contract.py @@ -16,6 +16,32 @@ def _tree(): return ast.parse(DAG_PATH.read_text(encoding="utf-8")) +def _declared_param_types() -> dict[str, set[str]]: + param_types = {} + for node in ast.walk(_tree()): + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values, strict=True): + if not ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "Param" + ): + continue + type_keyword = next( + keyword.value for keyword in value.keywords if keyword.arg == "type" + ) + if isinstance(type_keyword, ast.List): + param_types[key.value] = { + item.value for item in type_keyword.elts if isinstance(item, ast.Constant) + } + else: + param_types[key.value] = {type_keyword.value} + return param_types + + def test_dag_file_exists_and_uses_dynamic_profiles(): """DAG берёт варианты профилей из PROFILES, а не из ручного списка.""" text = DAG_PATH.read_text(encoding="utf-8") @@ -37,6 +63,22 @@ def test_trigger_form_has_expected_param_enums(): assert '"expected_t_end": Param(' in text +def test_trigger_form_marks_only_optional_params_as_nullable(): + """Форма разрешает оставить необязательные поля пустыми.""" + param_types = _declared_param_types() + + for name in { + "duration", + "seed", + "model_time_speed", + "artifact_path", + "expected_t_end", + }: + assert "null" in param_types[name] + for name in {"operation", "profile"}: + assert "null" not in param_types[name] + + def test_dag_branches_and_waits_for_etl_completion(): """Backfill/import/next-day запускают ETL и ждут завершения перед check.""" text = DAG_PATH.read_text(encoding="utf-8")