fix(airflow): необязательные поля формы generator_control стали необязательными
- Зачем:
- форма 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 — зелёные.
This commit is contained in:
@@ -124,6 +124,8 @@ desktop.ini
|
|||||||
# Тестовые данные и артефакты
|
# Тестовые данные и артефакты
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Файлы с данными (но не папки data/ — там исходные данные проекта)
|
# Файлы с данными (но не папки data/ — там исходные данные проекта)
|
||||||
|
# JSON-артефакты генератора; сиды data/*.jsonl остаются под Git.
|
||||||
|
data/*.json
|
||||||
*.csv.gz
|
*.csv.gz
|
||||||
*.jsonl.gz
|
*.jsonl.gz
|
||||||
*.parquet
|
*.parquet
|
||||||
|
|||||||
+73
@@ -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`.
|
||||||
@@ -227,27 +227,28 @@ with DAG(
|
|||||||
title="Профиль",
|
title="Профиль",
|
||||||
description="Именованный набор настроек генератора.",
|
description="Именованный набор настроек генератора.",
|
||||||
),
|
),
|
||||||
|
# "null" в type делает поля формы необязательными (Context7, Airflow 2.10.5).
|
||||||
"duration": Param(
|
"duration": Param(
|
||||||
"",
|
None,
|
||||||
type="string",
|
type=["null", "string"],
|
||||||
title="Длительность",
|
title="Длительность",
|
||||||
description="Например 6h или 2d. Пусто — взять длительность из профиля.",
|
description="Например 6h или 2d. Пусто — взять длительность из профиля.",
|
||||||
),
|
),
|
||||||
"seed": Param(
|
"seed": Param(
|
||||||
"",
|
None,
|
||||||
type="string",
|
type=["null", "string"],
|
||||||
title="GEN_SEED",
|
title="GEN_SEED",
|
||||||
description="Пусто — взять seed из профиля.",
|
description="Пусто — взять seed из профиля.",
|
||||||
),
|
),
|
||||||
"model_time_speed": Param(
|
"model_time_speed": Param(
|
||||||
"",
|
None,
|
||||||
type="string",
|
type=["null", "string"],
|
||||||
title="GEN_MODEL_TIME_SPEED",
|
title="GEN_MODEL_TIME_SPEED",
|
||||||
description="Пусто — взять скорость модельного времени из профиля.",
|
description="Пусто — взять скорость модельного времени из профиля.",
|
||||||
),
|
),
|
||||||
"artifact_path": Param(
|
"artifact_path": Param(
|
||||||
"",
|
None,
|
||||||
type="string",
|
type=["null", "string"],
|
||||||
title="Артефакт",
|
title="Артефакт",
|
||||||
description=(
|
description=(
|
||||||
"Backfill: куда сохранить файл; пусто — не сохранять. "
|
"Backfill: куда сохранить файл; пусто — не сохранять. "
|
||||||
@@ -255,8 +256,8 @@ with DAG(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
"expected_t_end": Param(
|
"expected_t_end": Param(
|
||||||
"",
|
None,
|
||||||
type="string",
|
type=["null", "string"],
|
||||||
title="Ожидаемая граница next-day",
|
title="Ожидаемая граница next-day",
|
||||||
description=(
|
description=(
|
||||||
"Необязательный model_t_end до запуска. Защищает от "
|
"Необязательный model_t_end до запуска. Защищает от "
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ def _tree():
|
|||||||
return ast.parse(DAG_PATH.read_text(encoding="utf-8"))
|
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():
|
def test_dag_file_exists_and_uses_dynamic_profiles():
|
||||||
"""DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
|
"""DAG берёт варианты профилей из PROFILES, а не из ручного списка."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
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
|
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():
|
def test_dag_branches_and_waits_for_etl_completion():
|
||||||
"""Backfill/import/next-day запускают ETL и ждут завершения перед check."""
|
"""Backfill/import/next-day запускают ETL и ждут завершения перед check."""
|
||||||
text = DAG_PATH.read_text(encoding="utf-8")
|
text = DAG_PATH.read_text(encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user