feat(airflow): пульт стал world_init, добавлен DAG world_next_day (#4)

- Зачем:
  - список DAG'ов должен читаться лесенкой ddl_init → world_init →
    world_next_day, а путь менти — проходиться пустыми формами
    (issue #4, спека редизайна пути менти, решения 2–3).
- Что:
  - generator_control переименован в world_init, дефолт операции —
    import; next-day ушёл из выпадашки в отдельный DAG;
  - новый беспараметрный world_next_day: расписание */30 * * * *,
    создаётся на паузе, catchup=False, max_active_runs=1; общие
    задачи вынесены в airflow/dags/utils/startup_history_tasks.py;
  - доки и контрактные тесты обновлены синхронно; быстрый старт
    README — без make ddl, схему создаёт DAG ddl_init.
- Проверка:
  - make test (210 + 31) и make lint зелёные;
  - живая приёмка на чистом стенде: world_init пустой формой
    импортировал эталонный мир за 217 с (3 дня, 280 437 событий),
    world_next_day после снятия с паузы добавляет ровно один день
    за прогон, дашборд Superset собирается.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 21:45:02 +03:00
co-authored by Claude Fable 5
parent 76ff669cf7
commit ac7a973504
12 changed files with 268 additions and 192 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ user_domain_id (пользователь, постоянный)
это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` + это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` +
заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006 заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006
она **несущая**: именно с неё свежий стенд получает историю с первой минуты. она **несущая**: именно с неё свежий стенд получает историю с первой минуты.
Механизм реализован через Airflow DAG `generator_control` и служебный чистый Механизм реализован через Airflow DAG `world_init` и служебный чистый
путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md). путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md).
### Мир (стенда) ### Мир (стенда)
+19 -10
View File
@@ -9,7 +9,7 @@
витринами. витринами.
Поток данных коротко: Поток данных коротко:
- **стартовая история**: `generator backfill → Kafka → ClickHouse (STG) → - **стартовая история**: `world_init → Kafka → ClickHouse (STG) →
batch STG → ODS → DDS → DM → Superset`. batch STG → ODS → DDS → DM → Superset`.
- **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL - **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL
→ Superset`. → Superset`.
@@ -31,20 +31,29 @@
Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`, Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`,
`git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки. `git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки.
Для ручной работы поднимите стенд и создайте стартовую историю через Airflow: Для ручной работы поднимите стенд:
```bash ```bash
make up make up
make ddl
docker compose ps docker compose ps
``` ```
Откройте Airflow: `http://localhost:8080` (`admin/admin`). Сначала снимите Дальше всё делается в Airflow: `http://localhost:8080` (`admin/admin`).
паузу с DAG `etl_pipeline` (переключатель слева от имени): на свежем стенде он Список DAG'ов читается лесенкой сверху вниз; на свежем стенде все DAG'и
создаётся на паузе, и `backfill` откажется стартовать. Затем запустите создаются на паузе, поэтому перед запуском снимайте паузу переключателем
`generator_control` с операцией `backfill`: DAG создаст стартовую историю, слева от имени.
запустит ETL и выполнит `check`. `make up` не запускает live-генератор; live
включается отдельно командой `make generator-continue`. 1. `ddl_init` — снимите паузу и запустите: DAG создаст схему ClickHouse
(отдельная команда в терминале не нужна).
2. `etl_pipeline` — только снимите паузу: его запустит следующий шаг.
3. `world_init` — снимите паузу и запустите с пустой формой: DAG импортирует
эталонный мир, запустит ETL и сверит витрины.
4. `world_next_day` — когда захотите добавить ровно один модельный день,
запустите его с пустой формой. Расписание задано каждые 30 минут, но по
умолчанию DAG стоит на паузе.
`make up` не запускает live-генератор; live включается отдельно командой
`make generator-continue`.
После обновления репозитория снова выполните `make up`: команда пересобирает После обновления репозитория снова выполните `make up`: команда пересобирает
Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд
@@ -53,7 +62,7 @@ Airflow-образ и подтягивает новые зависимости
Для полностью автоматического чистого прогона из консоли есть команда — это Для полностью автоматического чистого прогона из консоли есть команда — это
тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов
(DDL она применяет сама, отдельный `make ddl` не нужен): (схему ClickHouse она применяет сама):
```bash ```bash
make generated-history-analytics make generated-history-analytics
@@ -0,0 +1,59 @@
"""Общие задачи Airflow для операций над миром стенда."""
from airflow.exceptions import AirflowException
from airflow.models.dag import DagModel
from airflow.utils.session import provide_session
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
from clickstream_generator.airflow_control import (
assert_clickhouse_matches_manifest,
assert_live_generator_not_running_for_next_day,
assert_next_day_snapshot,
build_next_day_env,
load_manifest_from_kafka,
load_state_from_kafka,
run_next_day,
target_dag_trigger_error,
)
def precheck_next_day(**context) -> None:
"""Проверяет точку продолжения и готовит настройки мира."""
assert_live_generator_not_running_for_next_day()
manifest = load_manifest_from_kafka()
state = load_state_from_kafka()
assert_next_day_snapshot(manifest, state)
context["ti"].xcom_push(
key="generator_env",
value=build_next_day_env(manifest),
)
def run_next_day_task(**context) -> None:
"""Генерирует следующий модельный день от проверенного state."""
env = context["ti"].xcom_pull(
task_ids="precheck_next_day",
key="generator_env",
)
run_next_day(env)
def check_manifest_task(**context) -> None:
"""Сверяет витрину ClickHouse с manifest стартовой истории."""
manifest = load_manifest_from_kafka()
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
assert_clickhouse_matches_manifest(manifest, hook)
@provide_session
def assert_target_dag_not_paused(dag_id: str, session=None) -> None:
"""
Проверяет, что зависимый DAG можно запустить.
Context7: для Airflow 2.10.5 у старого TriggerDagRunOperator нет надёжного
fail_when_dag_is_paused, поэтому паузу проверяем заранее через DagModel.
"""
dag_model = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none()
error = target_dag_trigger_error(dag_id, dag_model)
if error:
raise AirflowException(error)
@@ -10,36 +10,28 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow import DAG from airflow import DAG
from airflow.exceptions import AirflowException
from airflow.models.dag import DagModel
from airflow.models.param import Param from airflow.models.param import Param
from airflow.operators.empty import EmptyOperator from airflow.operators.empty import EmptyOperator
from airflow.operators.python import BranchPythonOperator, PythonOperator from airflow.operators.python import BranchPythonOperator, PythonOperator
from airflow.operators.trigger_dagrun import TriggerDagRunOperator from airflow.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.utils.session import provide_session
from airflow.utils.trigger_rule import TriggerRule from airflow.utils.trigger_rule import TriggerRule
from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook
from clickstream_generator.airflow_control import ( from clickstream_generator.airflow_control import (
KAFKA_BOOTSTRAP_SERVERS, KAFKA_BOOTSTRAP_SERVERS,
assert_clickhouse_matches_manifest,
assert_expected_t_end,
assert_live_generator_not_running, assert_live_generator_not_running,
assert_live_generator_not_running_for_next_day,
assert_next_day_snapshot,
assert_stand_clean, assert_stand_clean,
build_control_env, build_control_env,
build_next_day_env,
default_artifact_path, default_artifact_path,
load_manifest_from_kafka,
load_state_from_kafka,
run_backfill, run_backfill,
run_import, run_import,
run_next_day,
target_dag_trigger_error,
validate_import_artifact, validate_import_artifact,
) )
from clickstream_generator.launch import PROFILES from clickstream_generator.launch import PROFILES
from utils.startup_history_tasks import (
assert_target_dag_not_paused,
check_manifest_task,
)
default_args = { default_args = {
@@ -107,8 +99,6 @@ def choose_operation(**context) -> str:
return "check_etl_not_paused_before_backfill" return "check_etl_not_paused_before_backfill"
if operation == "import": if operation == "import":
return "check_etl_not_paused_before_import" return "check_etl_not_paused_before_import"
if operation == "next-day":
return "check_etl_not_paused_before_next_day"
if operation == "check": if operation == "check":
return "check_only" return "check_only"
raise ValueError(f"Неизвестная операция: {operation}") raise ValueError(f"Неизвестная операция: {operation}")
@@ -151,56 +141,9 @@ def run_import_task(**context) -> None:
run_import(env, artifact_path) run_import(env, artifact_path)
def precheck_next_day(**context) -> None: # Context7, Airflow 2.10.5: Param поддерживает enum.
"""Проверяет точку продолжения и готовит неизменные настройки мира."""
assert_live_generator_not_running_for_next_day()
manifest = load_manifest_from_kafka()
state = load_state_from_kafka()
assert_next_day_snapshot(manifest, state)
assert_expected_t_end(
str(_param(context, "expected_t_end") or "").strip(),
str(manifest["model_t_end"]),
)
context["ti"].xcom_push(
key="generator_env",
value=build_next_day_env(manifest),
)
def run_next_day_task(**context) -> None:
"""Генерирует следующий модельный день от проверенного state."""
env = context["ti"].xcom_pull(
task_ids="precheck_next_day",
key="generator_env",
)
run_next_day(env)
def check_manifest_task(**context) -> None:
"""Сверяет витрину ClickHouse с manifest стартовой истории."""
manifest = load_manifest_from_kafka()
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
assert_clickhouse_matches_manifest(manifest, hook)
@provide_session
def assert_target_dag_not_paused(dag_id: str, session=None) -> None:
"""
Проверяет, что зависимый DAG можно запустить.
Context7: для Airflow 2.10.5 у старого TriggerDagRunOperator нет надёжного
fail_when_dag_is_paused, поэтому паузу проверяем заранее через DagModel.
"""
dag_model = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none()
error = target_dag_trigger_error(dag_id, dag_model)
if error:
raise AirflowException(error)
# Context7, Airflow 2.10.5: Param поддерживает enum, а max_active_runs ограничивает
# число одновременных DAG run. Поэтому форму и блокировку next-day держим в DAG.
with DAG( with DAG(
dag_id="generator_control", dag_id="world_init",
description="Пульт стартовой истории генератора", description="Пульт стартовой истории генератора",
default_args=default_args, default_args=default_args,
schedule=None, schedule=None,
@@ -211,13 +154,13 @@ with DAG(
tags=["generator", "startup-history"], tags=["generator", "startup-history"],
params={ params={
"operation": Param( "operation": Param(
"backfill", "import",
type="string", type="string",
enum=["backfill", "import", "next-day", "check"], enum=["backfill", "import", "check"],
title="Операция", title="Операция",
description=( description=(
"Что сделать: создать историю, импортировать артефакт, " "Что сделать: импортировать артефакт, создать историю "
"добавить следующий день или проверить витрины." "или проверить витрины."
), ),
), ),
"profile": Param( "profile": Param(
@@ -255,15 +198,6 @@ with DAG(
"Import: что читать; пусто — эталонный мир из репозитория." "Import: что читать; пусто — эталонный мир из репозитория."
), ),
), ),
"expected_t_end": Param(
None,
type=["null", "string"],
title="Ожидаемая граница next-day",
description=(
"Необязательный model_t_end до запуска. Защищает от "
"повторной доливки того же дня."
),
),
}, },
) as dag: ) as dag:
route = BranchPythonOperator( route = BranchPythonOperator(
@@ -289,15 +223,6 @@ with DAG(
python_callable=run_import_task, python_callable=run_import_task,
) )
precheck_next_day_task = PythonOperator(
task_id="precheck_next_day",
python_callable=precheck_next_day,
)
next_day_task = PythonOperator(
task_id="run_next_day",
python_callable=run_next_day_task,
)
check_etl_not_paused_before_backfill = PythonOperator( check_etl_not_paused_before_backfill = PythonOperator(
task_id="check_etl_not_paused_before_backfill", task_id="check_etl_not_paused_before_backfill",
python_callable=assert_target_dag_not_paused, python_callable=assert_target_dag_not_paused,
@@ -308,12 +233,6 @@ with DAG(
python_callable=assert_target_dag_not_paused, python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID}, op_kwargs={"dag_id": ETL_DAG_ID},
) )
check_etl_not_paused_before_next_day = PythonOperator(
task_id="check_etl_not_paused_before_next_day",
python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID},
)
trigger_etl = TriggerDagRunOperator( trigger_etl = TriggerDagRunOperator(
task_id="trigger_etl", task_id="trigger_etl",
trigger_dag_id=ETL_DAG_ID, trigger_dag_id=ETL_DAG_ID,
@@ -343,14 +262,11 @@ with DAG(
route >> [ route >> [
check_etl_not_paused_before_backfill, check_etl_not_paused_before_backfill,
check_etl_not_paused_before_import, check_etl_not_paused_before_import,
check_etl_not_paused_before_next_day,
check_only, check_only,
] ]
check_etl_not_paused_before_backfill >> precheck_backfill_task check_etl_not_paused_before_backfill >> precheck_backfill_task
check_etl_not_paused_before_import >> precheck_import_task check_etl_not_paused_before_import >> precheck_import_task
check_etl_not_paused_before_next_day >> precheck_next_day_task
precheck_backfill_task >> backfill_task >> trigger_etl precheck_backfill_task >> backfill_task >> trigger_etl
precheck_import_task >> import_task >> trigger_etl precheck_import_task >> import_task >> trigger_etl
precheck_next_day_task >> next_day_task >> trigger_etl
trigger_etl >> check_after_etl >> done trigger_etl >> check_after_etl >> done
check_only >> done check_only >> done
+69
View File
@@ -0,0 +1,69 @@
"""DAG добавления одного модельного дня в мир стенда."""
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
from utils.startup_history_tasks import (
assert_target_dag_not_paused,
check_manifest_task,
precheck_next_day,
run_next_day_task,
)
ETL_DAG_ID = "etl_pipeline"
default_args = {
"owner": "airflow",
"depends_on_past": False,
"email_on_failure": False,
"email_on_retry": False,
"retries": 0,
"retry_delay": timedelta(minutes=1),
}
# Context7, Airflow 2.10.5: schedule принимает cron-строку. Расписание задано
# заранее, но новый DAG остаётся на паузе до отдельного решения.
with DAG(
dag_id="world_next_day",
description="Добавление одного модельного дня в мир стенда",
default_args=default_args,
schedule="*/30 * * * *",
start_date=datetime(2024, 1, 1),
catchup=False,
max_active_runs=1,
is_paused_upon_creation=True,
tags=["generator", "startup-history"],
) as dag:
check_etl_not_paused = PythonOperator(
task_id="check_etl_not_paused",
python_callable=assert_target_dag_not_paused,
op_kwargs={"dag_id": ETL_DAG_ID},
)
precheck = PythonOperator(
task_id="precheck_next_day",
python_callable=precheck_next_day,
)
generate = PythonOperator(
task_id="run_next_day",
python_callable=run_next_day_task,
)
trigger_etl = TriggerDagRunOperator(
task_id="trigger_etl",
trigger_dag_id=ETL_DAG_ID,
conf={"full_refresh": True},
wait_for_completion=True,
allowed_states=["success"],
failed_states=["failed"],
poke_interval=30,
)
check = PythonOperator(
task_id="check_after_etl",
python_callable=check_manifest_task,
)
check_etl_not_paused >> precheck >> generate >> trigger_etl >> check
+28 -17
View File
@@ -27,8 +27,9 @@
flowchart LR flowchart LR
subgraph AF["Airflow"] subgraph AF["Airflow"]
DAG1["ddl_init"] DAG1["ddl_init"]
DAG2["generator_control"] DAG2["world_init"]
DAG3["etl_pipeline"] DAG3["world_next_day"]
DAG4["etl_pipeline"]
end end
subgraph GEN["Generator"] subgraph GEN["Generator"]
@@ -57,7 +58,8 @@ flowchart LR
V[витрины VIEW] V[витрины VIEW]
end end
DAG2 -->|startup-history backfill/import| K DAG2 -->|import/backfill| K
DAG3 -->|следующий день| K
G -->|live после make generator-continue| K G -->|live после make generator-continue| K
K -->|MV| S K -->|MV| S
S -->|batch| O S -->|batch| O
@@ -66,12 +68,13 @@ flowchart LR
D1 & D2 -->|VIEW| V D1 & D2 -->|VIEW| V
DAG1 -.->|DDL| STG & ODS & DDS & DM DAG1 -.->|DDL| STG & ODS & DDS & DM
DAG3 -.->|batch| ODS & DDS DAG4 -.->|batch| ODS & DDS
``` ```
В учебном стенде предусмотрены два пути загрузки: В учебном стенде предусмотрены два пути загрузки:
- `startup-history`: DAG `generator_control` создаёт или импортирует историю, добавляет следующий модельный день, запускает ETL и проверяет витрины; - `startup-history`: `world_init` импортирует или создаёт историю, а
`world_next_day` добавляет один модельный день; оба запускают ETL и проверяют витрины;
- `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий. - `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий.
### Слои и их назначение ### Слои и их назначение
@@ -80,8 +83,9 @@ flowchart LR
flowchart LR flowchart LR
subgraph AF["Airflow"] subgraph AF["Airflow"]
DAG1["ddl_init"] DAG1["ddl_init"]
DAG2["generator_control"] DAG2["world_init"]
DAG3["etl_pipeline"] DAG3["world_next_day"]
DAG4["etl_pipeline"]
end end
subgraph GEN["Generator"] subgraph GEN["Generator"]
@@ -106,7 +110,8 @@ flowchart LR
DM_T["VIEW"] DM_T["VIEW"]
end end
DAG2 -->|startup-history| KAFKA DAG2 -->|стартовый мир| KAFKA
DAG3 -->|следующий день| KAFKA
G -->|live| KAFKA G -->|live| KAFKA
KAFKA -->|MV| STG_T KAFKA -->|MV| STG_T
STG_T -->|batch| ODS_T STG_T -->|batch| ODS_T
@@ -114,7 +119,7 @@ flowchart LR
ODS_T -.->|ошибки| DQ ODS_T -.->|ошибки| DQ
DAG1 -.->|DDL| L1 & L2 & L3 & L4 DAG1 -.->|DDL| L1 & L2 & L3 & L4
DAG3 -.->|batch| ODS_T & DDS_T DAG4 -.->|batch| ODS_T & DDS_T
``` ```
--- ---
@@ -387,10 +392,12 @@ sequenceDiagram
CH-->>User: ✅ Структура БД создана CH-->>User: ✅ Структура БД создана
alt Startup-history режим alt Startup-history режим
User->>Airflow: Trigger generator_control (backfill/import) User->>Airflow: Trigger world_init с пустой формой
Airflow->>K: события стартовой истории Airflow->>K: события стартовой истории
Airflow->>Airflow: trigger etl_pipeline + check Airflow->>Airflow: trigger etl_pipeline + check
K-->>User: ✅ История в Kafka и витринах K-->>User: ✅ История в Kafka и витринах
User->>Airflow: Trigger world_next_day с пустой формой
Airflow->>K: события следующего модельного дня
else Live режим else Live режим
User->>Compose: make generator-continue User->>Compose: make generator-continue
loop каждые 1-10 секунд loop каждые 1-10 секунд
@@ -637,7 +644,8 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
```python ```python
# airflow/dags/ddl_init_dag.py — создание баз/таблиц # airflow/dags/ddl_init_dag.py — создание баз/таблиц
# airflow/dags/generator_control_dag.py — backfill/import/next-day/check стартовой истории # airflow/dags/world_init_dag.py — import/backfill/check стартового мира
# airflow/dags/world_next_day_dag.py — добавление одного модельного дня
# airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM) # airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM)
# airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур # airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур
@@ -645,22 +653,25 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
# - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator; # - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator;
# - SQL-файлы вызываются по фиксированным путям; # - SQL-файлы вызываются по фиксированным путям;
# - загрузка может идти двумя путями: # - загрузка может идти двумя путями:
# 1) startup-history через DAG `generator_control`; # 1) стартовый мир через `world_init` и рост через `world_next_day`;
# 2) live-поток через явный `make generator-continue`. # 2) live-поток через явный `make generator-continue`.
# #
# Базовый demo-сценарий: # Базовый demo-сценарий:
# ddl_init -> generator_control(backfill/import) -> etl_pipeline -> check # ddl_init -> world_init(import) -> etl_pipeline -> check
# Расширенный учебный сценарий: # Расширенный учебный сценарий:
# make generator-continue + периодический etl_pipeline # make generator-continue + периодический etl_pipeline
``` ```
**DAG `generator_control`**: **DAG `world_init`**:
- `backfill`: создаёт стартовую историю через генератор - `import` по умолчанию импортирует портативный артефакт стартового мира
- `import`: импортирует портативный артефакт стартовой истории - `backfill` создаёт стартовую историю через генератор
- `next-day`: пакетно добавляет следующий модельный день от текущего слепка мира
- `check`: сверяет ClickHouse с manifest стартовой истории - `check`: сверяет ClickHouse с manifest стартовой истории
- После `backfill` и `import` запускает `etl_pipeline` с `full_refresh` - После `backfill` и `import` запускает `etl_pipeline` с `full_refresh`
**DAG `world_next_day`** без параметров пакетно добавляет следующий модельный
день, запускает `etl_pipeline` с `full_refresh` и сверяет manifest. У него задано
расписание каждые 30 минут, но DAG создаётся на паузе и не выполняет пропущенные интервалы.
**Подключение к ClickHouse:** **Подключение к ClickHouse:**
- Connection: `clickhouse_default` - Connection: `clickhouse_default`
- URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin) - URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin)
+29 -25
View File
@@ -48,45 +48,48 @@ volumes или live-генератором. Для стыка backfill/live от
## Airflow DAGs ## Airflow DAGs
Штатный ручной путь начинается с `generator_control`: чистый стенд получает Штатный ручной путь начинается с `world_init`: пустая форма импортирует
стартовую историю генератора, затем этот же DAG запускает ETL и проверку. эталонный мир, затем этот же DAG запускает ETL и проверку.
`kafka_load` остаётся для экспериментов и совместимости учебного стенда. `kafka_load` остаётся для экспериментов и совместимости учебного стенда.
### `generator_control` ### `world_init`
- Запуск: ручной (`Trigger DAG`). - Запуск: ручной (`Trigger DAG`).
- Назначение: пульт стартовой истории генератора. - Назначение: импорт или служебная сборка стартового мира.
- Операции: - Операции:
- `import` — операция по умолчанию: импортировать портативный артефакт, затем
запустить `etl_pipeline` и дождаться `success`;
- `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и - `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и
дождаться `success`; дождаться `success`;
- `import` — импортировать портативный артефакт, затем запустить
`etl_pipeline` и дождаться `success`;
- `next-day` — восстановить мир из state, добавить 24 модельных часа,
затем запустить `etl_pipeline` и дождаться `success`;
- `check` — сверить ClickHouse с manifest из Kafka. - `check` — сверить ClickHouse с manifest из Kafka.
- Параметры: - Параметры:
- `operation` (`backfill` / `import` / `next-day` / `check`); - `operation` (`import` / `backfill` / `check`);
- `profile` — список берётся из `PROFILES` генератора; - `profile` — список берётся из `PROFILES` генератора;
- `duration``6h`, `2d` и т.п.; пусто означает длительность профиля; - `duration``6h`, `2d` и т.п.; пусто означает длительность профиля;
- `seed`, `model_time_speed` — необязательные переопределения мира; - `seed`, `model_time_speed` — необязательные переопределения мира;
- `artifact_path` — для `backfill` путь сохранения; для `import` путь чтения. - `artifact_path` — для `backfill` путь сохранения; для `import` путь чтения.
При пустом поле импортируется эталонный мир из репозитория; При пустом поле импортируется эталонный мир из репозитория.
- `expected_t_end` — необязательная ожидаемая граница перед `next-day`.
При расхождении запуск показывает ожидаемое и фактическое значения.
Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые
`stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue` `stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue`
в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через
`make generator-continue`. `make generator-continue`.
`next-day` работает на непустом стенде и не использует проверку чистоты. ### `world_next_day`
Перед записью пульт требует manifest, state ровно на его `T_end` и остановленный
live-генератор. Настройки мира берутся из manifest; поля `profile`, `duration`, - Запуск: вручную с пустой формой.
`seed` и `model_time_speed` формы для этой операции не применяются. Один запуск - Параметров нет.
добавляет полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница - Один запуск восстанавливает мир из state, добавляет 24 модельных часа,
появляется в `boundaries`; старый manifest без поля читается как `[T0, T_end]`. запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest.
Расписание остаётся выключенным (`schedule=None`), а `max_active_runs=1` не даёт - Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`.
двум доливкам выполняться параллельно. Не включайте расписание до внедрения накопительных счётчиков manifest.
- `max_active_runs=1` не даёт двум доливкам выполняться параллельно.
`world_next_day` работает на непустом стенде и не использует проверку чистоты.
Перед записью он требует manifest, state ровно на его `T_end` и остановленный
live-генератор. Настройки мира берутся из manifest. Один запуск добавляет
полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница появляется в
`boundaries`; старый manifest без поля читается как `[T0, T_end]`.
После двух доливок проверьте завершённые стыки: После двух доливок проверьте завершённые стыки:
@@ -98,10 +101,11 @@ make generated-history-chain-check
смену browser/referer/utm внутри переходящих визитов. Она не меняет смену browser/referer/utm внутри переходящих визитов. Она не меняет
`make generated-history-runtime-check` для стыка backfill/live. `make generated-history-runtime-check` для стыка backfill/live.
Точка фиксации `next-day` новый manifest. Порядок записи: data-топики, state, Результат запуска `world_next_day` фиксируется новым manifest. Порядок записи:
manifest. Автоматического отката нет. Если запуск упал до публикации manifest, data-топики, state, manifest. Автоматического отката нет. Если запуск упал до
публикации manifest,
не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте
последний исправный портативный артефакт, затем повторите `next-day`. последний исправный портативный артефакт, затем повторите запуск `world_next_day`.
Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей
доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти
@@ -689,8 +693,8 @@ make generated-history-runtime-check
## Быстрые проверки ## Быстрые проверки
- Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`. - Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`.
- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `generator_control`, - Airflow UI: `http://localhost:8080` показывает лестницу `ddl_init`
`kafka_load`, `etl_pipeline`; основной ручной пульт генератора — `generator_control`. `world_init``world_next_day`, а также `kafka_load` и `etl_pipeline`.
- BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате. - BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате.
--- ---
+3 -1
View File
@@ -7,7 +7,9 @@
### Airflow (ручной и учебный путь запуска) ### Airflow (ручной и учебный путь запуска)
- `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse - `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse
- `airflow/dags/generator_control_dag.py` — Airflow-пульт стартовой истории: backfill/import/next-day/check - `airflow/dags/world_init_dag.py` — импорт или служебная сборка стартового мира и проверка витрин
- `airflow/dags/world_next_day_dag.py` — беспараметрное добавление одного модельного дня
- `airflow/dags/utils/startup_history_tasks.py` — общие задачи DAG для роста и проверки мира
- `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики - `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики
- `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM - `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM
- `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka - `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka
+1 -1
View File
@@ -16,7 +16,7 @@
- Для smoke и CI явно задаём служебный профиль `ci`. - Для smoke и CI явно задаём служебный профиль `ci`.
- Полный прогон выполняем отдельно через `PROFILE=daily-wave`. - Полный прогон выполняем отдельно через `PROFILE=daily-wave`.
- Основной ручной путь запуска — через Airflow DAG `generator_control`. - Основной ручной путь запуска — через Airflow DAG `world_init` с пустой формой.
- Консольный чистый прогон `make generated-history-analytics` остаётся коротким - Консольный чистый прогон `make generated-history-analytics` остаётся коротким
повторяемым сценарием для smoke и CI. повторяемым сценарием для smoke и CI.
- Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга. - Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга.
+12 -8
View File
@@ -58,9 +58,9 @@ git add data/startup_history/reference-world.json.xz
При импорте DAG дважды читает и распаковывает артефакт: во время предпроверки и При импорте DAG дважды читает и распаковывает артефакт: во время предпроверки и
перед записью в Kafka. Это увеличивает время импорта, но не меняет результат. перед записью в Kafka. Это увеличивает время импорта, но не меняет результат.
Менти в форме `generator_control` выбирает `import` и оставляет Менти запускает `world_init` с пустой формой. Тогда читается эталонный мир из
`artifact_path` пустым. Тогда читается эталонный мир из репозитория. Из консоли репозитория. Следующий модельный день добавляет отдельный беспараметрный DAG
тот же импорт запускается без указания пути: `world_next_day`. Из консоли тот же импорт запускается без указания пути:
```bash ```bash
make startup-history-import make startup-history-import
@@ -68,20 +68,24 @@ make startup-history-import
## Пульт в Airflow ## Пульт в Airflow
Основной ручной путь — DAG `generator_control` в Airflow UI: Основной учебный путь в Airflow UI:
1. Поднимите стенд: `make up`. 1. Поднимите стенд: `make up`.
2. Если DDL ещё не применён, запустите `ddl_init`. 2. Если DDL ещё не применён, запустите `ddl_init`.
3. Снимите паузу с `etl_pipeline`, если он ещё paused: 3. Снимите паузу с `etl_pipeline`, если он ещё paused:
`docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`. `docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`.
4. Откройте `generator_control` и выберите `operation`. 4. Запустите `world_init` с пустой формой. По умолчанию он импортирует эталонный мир.
5. Когда нужен ещё один модельный день, запустите `world_next_day` с пустой формой.
Операции: `world_next_day` имеет расписание каждые 30 минут, но по умолчанию стоит на паузе.
Не включайте расписание до внедрения накопительных счётчиков manifest.
## Операции сопровождающего
В форме `world_init` сопровождающему дополнительно доступны операции:
- `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает - `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает
`etl_pipeline`, ждёт завершения и выполняет `check`. `etl_pipeline`, ждёт завершения и выполняет `check`.
- `import` — прочитать артефакт из `artifact_path`. Несовместимый артефакт
отклоняется до записи в Kafka.
- `check` — сверить ClickHouse с manifest из Kafka. - `check` — сверить ClickHouse с manifest из Kafka.
Поля формы: Поля формы:
@@ -328,7 +328,7 @@ def target_dag_trigger_error(dag_id: str, dag_model) -> str | None:
if dag_model.is_paused: if dag_model.is_paused:
return ( return (
f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, " f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, "
"затем повторите generator_control." "затем повторите запуск DAG."
) )
return None return None
@@ -1,6 +1,4 @@
""" """Контракты DAG world_init и world_next_day без запуска Airflow."""
Контракт DAG generator_control без запуска Airflow.
"""
import ast import ast
from pathlib import Path from pathlib import Path
@@ -8,7 +6,8 @@ from pathlib import Path
from clickstream_generator.launch import PROFILES from clickstream_generator.launch import PROFILES
DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "generator_control_dag.py" DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_init_dag.py"
NEXT_DAY_DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_next_day_dag.py"
REPO_ROOT = Path(__file__).parents[2] REPO_ROOT = Path(__file__).parents[2]
@@ -65,7 +64,7 @@ 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")
assert "dag_id=\"generator_control\"" in text assert "dag_id=\"world_init\"" in text
assert "sorted(PROFILES)" in text assert "sorted(PROFILES)" in text
for profile in PROFILES: for profile in PROFILES:
assert profile not in {"hardcoded-profile"} assert profile not in {"hardcoded-profile"}
@@ -76,12 +75,13 @@ def test_trigger_form_has_expected_param_enums():
text = DAG_PATH.read_text(encoding="utf-8") text = DAG_PATH.read_text(encoding="utf-8")
param_defaults = _declared_param_defaults() param_defaults = _declared_param_defaults()
assert 'enum=["backfill", "import", "next-day", "check"]' in text assert 'enum=["backfill", "import", "check"]' in text
assert param_defaults["operation"] == "import"
assert "enum=sorted(PROFILES)" in text assert "enum=sorted(PROFILES)" in text
assert param_defaults["profile"] == "daily-wave" assert param_defaults["profile"] == "daily-wave"
assert '"duration": Param(' in text assert '"duration": Param(' in text
assert '"artifact_path": Param(' in text assert '"artifact_path": Param(' in text
assert '"expected_t_end": Param(' in text assert '"expected_t_end": Param(' not in text
def test_trigger_form_marks_only_optional_params_as_nullable(): def test_trigger_form_marks_only_optional_params_as_nullable():
@@ -93,7 +93,6 @@ def test_trigger_form_marks_only_optional_params_as_nullable():
"seed", "seed",
"model_time_speed", "model_time_speed",
"artifact_path", "artifact_path",
"expected_t_end",
}: }:
assert "null" in param_types[name] assert "null" in param_types[name]
for name in {"operation", "profile"}: for name in {"operation", "profile"}:
@@ -101,7 +100,7 @@ def test_trigger_form_marks_only_optional_params_as_nullable():
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 запускают ETL и ждут завершения перед check."""
text = DAG_PATH.read_text(encoding="utf-8") text = DAG_PATH.read_text(encoding="utf-8")
assert "BranchPythonOperator" in text assert "BranchPythonOperator" in text
@@ -112,41 +111,43 @@ def test_dag_branches_and_waits_for_etl_completion():
assert 'failed_states=["failed"]' in text assert 'failed_states=["failed"]' in text
def test_next_day_has_own_boundary_precheck_and_serial_execution(): def test_world_next_day_is_parameterless_paused_half_hour_dag():
"""Next-day не использует clean-guard и не допускает параллельных запусков.""" """DAG следующего дня запускается пустой формой каждые полчаса."""
text = DAG_PATH.read_text(encoding="utf-8") text = NEXT_DAY_DAG_PATH.read_text(encoding="utf-8")
assert "schedule=None" in text assert 'dag_id="world_next_day"' in text
assert 'schedule="*/30 * * * *"' in text
assert "catchup=False" in text
assert "is_paused_upon_creation=True" in text
assert "max_active_runs=1" in text assert "max_active_runs=1" in text
assert 'return "check_etl_not_paused_before_next_day"' in text assert "params=" not in text
assert "assert_next_day_snapshot" in text assert "precheck_next_day" in text
assert "assert_expected_t_end" in text assert "run_next_day_task" in text
next_day_precheck = text.split("def precheck_next_day", maxsplit=1)[1].split( assert "TriggerDagRunOperator" in text
"\ndef ", maxsplit=1 assert "wait_for_completion=True" in text
)[0] assert "check_manifest_task" in text
assert "assert_stand_clean" not in next_day_precheck assert "check_etl_not_paused >> precheck" in text
assert "assert_live_generator_not_running" in next_day_precheck
assert "run_next_day" in text
def test_generator_control_prechecks_etl_dag_not_paused_before_waiting(): def test_world_dags_precheck_etl_dag_not_paused_before_waiting():
"""Пульт проверяет паузу etl_pipeline до долгого ожидания.""" """Пульт проверяет паузу etl_pipeline до долгого ожидания."""
text = DAG_PATH.read_text(encoding="utf-8") text = DAG_PATH.read_text(encoding="utf-8")
shared_tasks = (
REPO_ROOT / "airflow" / "dags" / "utils" / "startup_history_tasks.py"
).read_text(encoding="utf-8")
assert "assert_target_dag_not_paused" in text assert "assert_target_dag_not_paused" in text
assert 'ETL_DAG_ID = "etl_pipeline"' in text assert 'ETL_DAG_ID = "etl_pipeline"' in text
assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text
assert "session.query(DagModel)" in text assert "session.query(DagModel)" in shared_tasks
assert "DagModel.dag_id == dag_id" in text assert "DagModel.dag_id == dag_id" in shared_tasks
assert "target_dag_trigger_error" in text assert "target_dag_trigger_error" in shared_tasks
assert "Airflow 2.10.5" in text assert "Airflow 2.10.5" in shared_tasks
assert "fail_when_dag_is_paused" in text assert "fail_when_dag_is_paused" in shared_tasks
assert 'return "check_etl_not_paused_before_backfill"' in text assert 'return "check_etl_not_paused_before_backfill"' in text
assert 'return "check_etl_not_paused_before_import"' in text assert 'return "check_etl_not_paused_before_import"' in text
assert 'return "check_etl_not_paused_before_next_day"' in text
assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text
assert "check_etl_not_paused_before_import >> precheck_import_task" in text assert "check_etl_not_paused_before_import >> precheck_import_task" in text
assert "check_etl_not_paused_before_next_day >> precheck_next_day_task" in text
assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index( assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index(
"precheck_backfill_task >> backfill_task" "precheck_backfill_task >> backfill_task"
) )
@@ -170,15 +171,16 @@ def test_make_up_rebuilds_airflow_images_after_repo_update():
assert "superset-init" not in up_block assert "superset-init" not in up_block
def test_readme_quick_start_lists_prerequisites_and_runs_ddl_before_generator_control(): def test_readme_quick_start_creates_schema_via_ddl_init_before_world_init():
"""Быстрый старт называет инструменты и DDL до generator_control.""" """Быстрый старт: терминал — только make up, схему создаёт ddl_init до world_init."""
text = (REPO_ROOT / "README.md").read_text(encoding="utf-8") text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
assert "uv" in text assert "uv" in text
assert "Docker" in text assert "Docker" in text
assert "docker compose" in text assert "docker compose" in text
assert "make up\nmake ddl" in text assert "make ddl" not in text
assert text.index("make ddl") < text.index("generator_control") quick_start = text.split("## Быстрый старт", maxsplit=1)[1]
assert quick_start.index("ddl_init") < quick_start.index("world_init")
def test_course_readme_lists_uv_before_first_command(): def test_course_readme_lists_uv_before_first_command():