Compare commits

..
7 Commits
Author SHA1 Message Date
ddmitry 6d96097d50 Merge pull request 'Обновить практику Airflow: основной маршрут и задания по желанию' (#1) from work/airflow-practice-refresh into main 2026-09-28 17:20:49 +03:00
ddadmin 26730c6483 docs(practice): согласовать теорию с учебным стендом 2026-09-28 09:44:28 -04:00
ddadmin f6b450c922 docs(practice): перенести задания в корень репозитория 2026-09-28 09:19:48 -04:00
ddadmin 1277bff690 docs(practice): уточнить инструкции по результатам прохождения
Уточнены порядок CSV/COPY, расположение логов callbacks, шаблонов и управления TaskGroup. Сохранены условия проверки, результаты 69 контрольных запусков и одного диагностического, SHA-256 DAG и снимки UI. Все этапы плана закрыты.

Основной и дополнительный маршруты проверены на Airflow 2.9.2. SMTP проверен локальным приемником, включая отказ доставки; внешняя почта и Windows отдельно не проверялись. Тестовые контейнеры и тома удалены.
2026-09-28 09:10:09 -04:00
ddadmin d8dfe83651 docs(practice): разделить основной маршрут и задания по желанию
Восемь блоков с явным началом, последовательными изменениями и наблюдаемыми результатами. Дополнительные упражнения имеют отдельные зависимости и условия возврата. Обновлены README, справочник DAG и пример уведомлений.

Проверены ссылки, якоря, отрисовка Markdown и основные сценарии в Airflow 2.9.2. Проверки дополнительных упражнений продолжаются и фиксируются в плане.
2026-09-28 08:49:02 -04:00
ddadmin cf1b770a65 fix(dags): сделать учебные сценарии воспроизводимыми
Управляемые ветки и ошибки, зависимости общих обработчиков, сохранение SQL-результата и фактическое чтение результатов Extract. Добавлен пример пулов и XCom; DQ-сводка сохраняет отказ при all_done.

Проверено на Airflow 2.9.2: импорт и запуск девяти исходных DAG, выбор веток, повтор и окончательные отказы обеих рабочих задач, отрицательный DQ-сценарий и восстановление, фильтрация Extract и ожидание слотов.
2026-09-28 08:46:50 -04:00
ddadmin 623d5861c1 docs(practice): добавить план обновления практики 2026-09-28 08:00:01 -04:00
23 changed files with 1282 additions and 1641 deletions

No files matched your search

@@ -107,12 +107,14 @@ dag = DAG('greeting_dag',
start_date=datetime(2023, 1, 1),
catchup=False)
start_task = DummyOperator(task_id='start_process', retries=2)
greeting_task = PythonOperator(task_id='greeting_task', python_callable=greet_user)
start_task = DummyOperator(task_id='start_process', retries=2, dag=dag)
greeting_task = PythonOperator(task_id='greeting_task', python_callable=greet_user, dag=dag)
start_task >> greeting_task
```
Параметр `dag=dag` связывает каждый оператор с созданным DAG. Если объявить задачи внутри `with DAG(...) as dag:`, привязка произойдет автоматически. Стрелка `>>` задает порядок выполнения задач.
В этом примере задача `greeting_task` использует `PythonOperator` для запуска функции `greet_user`, которая выводит приветственное сообщение в логи.
Airflow предоставляет множество готовых операторов:
@@ -8,45 +8,53 @@
Синтаксис шаблонов интуитивно понятен — переменная заключается в двойные фигурные скобки с пробелами по краям. Для эффективного использования достаточно знать, какое значение возвращает конкретный шаблон, и разместить его в нужном месте кода.
Подробный справочник доступных шаблонов можно найти в [официальной документации Airflow](https://airflow.apache.org/docs/apache-airflow/stable/templates-ref.html#variables).
Подробный справочник доступных шаблонов можно найти в [документации Airflow 2.9.2](https://airflow.apache.org/docs/apache-airflow/2.9.2/templates-ref.html#variables).
Вот практические примеры использования:
Каждый пример ниже - отдельный DAG. Для запуска сохраните один блок кода в файл `.py` внутри `airflow-docker/dags/`. Затем найдите DAG в UI и запустите его вручную.
**Передача даты выполнения в задачу:**
В этом примере создается DAG с идентификатором "template_example", который запускается каждые 15 минут. Задача "display_date" использует шаблон {{ ds }} для вывода текущей даты выполнения в формате YYYY-MM-DD.
В этом примере DAG `template_example` запускается каждые 15 минут. Задача `display_date` выводит логическую дату запуска в формате YYYY-MM-DD через шаблон `{{ ds }}`.
```python
...
from datetime import datetime
from airflow import DAG
from airflow.operators.bash import BashOperator
dag = DAG(
dag_id="template_example",
schedule_interval="*/15 * * * *",
default_args=default_args
start_date=datetime(2023, 1, 1),
catchup=False,
)
t1 = BashOperator(task_id="display_date", bash_command="echo {{ ds }}")
t1
t1 = BashOperator(task_id="display_date", bash_command="echo {{ ds }}", dag=dag)
```
**Использование шаблонов для лучшей отслеживаемости задач:**
В этом примере создается DAG с идентификатором "template_tracking_example", который запускается каждые 20 минут. Вторая задача использует шаблон {{ ds }} в команде bash, чтобы явно указывать дату обработки в логах.
```python
...
from datetime import datetime
from airflow import DAG
from airflow.operators.bash import BashOperator
dag = DAG(
dag_id="template_tracking_example",
schedule_interval="*/20 * * * *",
default_args=default_args
start_date=datetime(2023, 1, 1),
catchup=False,
)
t1 = BashOperator(task_id="show_date", bash_command="echo {{ ds }}")
t2 = BashOperator(task_id="process_for_date", bash_command="echo Processing for {{ ds }}")
t1 = BashOperator(task_id="show_date", bash_command="echo {{ ds }}", dag=dag)
t2 = BashOperator(task_id="process_for_date", bash_command="echo Processing for {{ ds }}", dag=dag)
t1 >> t2
```
В первом DAG одна задача, во втором - две связанные задачи. В Details выбранной задачи откройте Rendered Templates: вместо `{{ ds }}` будет дата. Та же дата появится в логе после запуска. Привязку операторов к DAG здесь задает `dag=dag`.
# Безопасные переменные (Variables)
Переменные Airflow представляют собой пары "ключ-значение", хранящиеся в метадатабазе системы. Они идеально подходят для хранения конфигурационных параметров, таких как пути к скриптам, имена таблиц или другие настройки, которые должны быть доступны в разных DAG.
@@ -134,69 +142,62 @@ task = BashOperator(
- Системы уведомлений (Email, Telegram)
- И многие другие через Airflow Providers
При использовании операторов, взаимодействующих с внешними системами, Airflow автоматически ищет соответствующее подключение с суффиксом `_default`. Однако можно явно указать альтернативное подключение:
Оператор получает настройки по идентификатору подключения. Например, `PostgresOperator` использует параметр `postgres_conn_id`. PostgreSQL provider уже установлен в учебном стенде.
В приведенном примере используется PostgresOperator для создания таблицы в базе данных. Вместо использования подключения по умолчанию, явно указывается подключение с идентификатором 'my_postgres_conn'.
```python
from airflow.providers.postgres.operators.postgres import PostgresOperator
create_table = PostgresOperator(
task_id='create_user_table',
sql='''
CREATE TABLE users(
user_id integer NOT NULL,
created_at TIMESTAMP NOT NULL
);''',
postgres_conn_id='my_postgres_conn'
)
```
Управление подключениями доступно через интерфейс Airflow (Admin → Connections). Если требуемый тип подключения отсутствует, его можно добавить установкой соответствующего Airflow Provider из [официального репозитория](https://airflow.apache.org/docs/#providers-packages-docs-apache-airflow-providers-index-html).
Управление подключениями доступно через интерфейс Airflow (раздел **Admin → Connections**). Подключения хранятся в метадатабазе Airflow, а пароли и другие чувствительные поля шифруются с помощью Fernet и маскируются в UI и логах.
Подключения хранятся в базе метаданных Airflow. Пароли и другие чувствительные поля шифруются с помощью Fernet и маскируются в UI и логах.
### Как создать подключение к PostgreSQL через UI
Интерфейс ниже соответствует Airflow 2.9.x и стандартному Docker-стенду из документации:
Используйте учебную БД из [README стенда](airflow-docker/README.md#запуск). В контейнере Airflow она доступна по имени сервиса `postgres-training`; адрес `localhost:5432` предназначен для подключения с вашего компьютера.
1. Откройте веб-интерфейс Airflow.
2. В верхнем меню выберите **Admin → Connections**.
3. В правом верхнем углу нажмите кнопку **+ Add a new record**.
4. В форме укажите параметры:
1. Откройте **Admin > Connections** и нажмите **+ Add a new record**.
2. Заполните форму:
- **Connection Id**: `my_postgres_conn`
Это имя мы будем использовать в коде DAG (параметр `postgres_conn_id`).
- **Connection Id**: `my_postgres_conn`. Это имя будет указано в коде DAG.
- **Connection Type**: `Postgres`
- **Host**: `postgres`
(так называется контейнер PostgreSQL в типовом `docker-compose.yaml` из официальной инструкции).
- **Schema**: `airflow`
(имя базы данных; в учебном стенде можно использовать стандартную БД).
- **Login**: `airflow`
- **Password**: `airflow`
- **Host**: `postgres-training`
- **Database / Schema**: `training`
- **Login**: `student`
- **Password**: `student`
- **Port**: `5432`
5. Нажмите кнопку **Test** (если доступна) — Airflow попробует подключиться к базе.
6. Если тест успешен, нажмите **Save**.
3. Нажмите **Save**. Кнопка **Test** может быть отключена в конфигурации Airflow; подключение можно проверить запуском DAG ниже.
Теперь подключение с идентификатором `my_postgres_conn` доступно во всех DAG’ах. В примере ниже PostgresOperator явно использует это подключение:
База `airflow` на сервисе `postgres-metadata` хранит служебные данные Airflow. Для учебных таблиц используйте `training`.
Сохраните пример в `airflow-docker/dags/connection_example_dag.py` и запустите `connection_example` через UI:
```python
from datetime import datetime
from airflow import DAG
from airflow.providers.postgres.operators.postgres import PostgresOperator
create_table = PostgresOperator(
task_id="create_user_table",
sql="""
CREATE TABLE users(
user_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
""",
postgres_conn_id="my_postgres_conn",
)
with DAG(
dag_id="connection_example",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
) as dag:
create_table = PostgresOperator(
task_id="create_user_table",
sql="""
CREATE TABLE IF NOT EXISTS public.connection_demo_users (
user_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
""",
postgres_conn_id="my_postgres_conn",
)
```
Для успешной работы с внешними системами сначала необходимо создать соответствующее подключение, а затем использовать его идентификатор в операторах вашего DAG.
Задача должна завершиться успешно. В учебной БД появится пустая таблица `public.connection_demo_users`; повторный запуск сохранит ее. Проверить наличие таблицы можно из SQL-консоли стенда:
```sql
SELECT to_regclass('public.connection_demo_users');
```
Результат - `connection_demo_users`. Если задача упала, откройте ее лог и сверьте Conn Id в коде с именем подключения, затем Host и остальные поля формы. Правка настроек действует при следующем выполнении задачи.
Более подробную информацию о настройке подключений можно найти в
[документации Airflow](https://airflow.apache.org/docs/apache-airflow/2.9.3/howto/connection.html).
@@ -2,15 +2,15 @@
В этом материале мы познакомимся с управлением ресурсами через пулы задач, обменом данными между задачами через XCom, группировкой задач с помощью TaskGroup и настройкой системы оповещений (алертинга) в Airflow.
Начнем с улучшения нашего базового пайплайна — проведем рефакторинг кода для лучшей читаемости и поддержки.
Опыты с этими возможностями собраны в [блоке 8 практики](educational-tasks.md#resources); callback и ошибки разобраны в [блоке 7](educational-tasks.md#errors).
## Управление ресурсами с помощью пулов задач
В системах с высокой нагрузкой, где одновременно запускается множество задач и DAG-ов, может возникнуть чрезмерная нагрузка на исполнителей и серверную часть. Это может привести к ошибкам выполнения и даже к отказу системы, если не установить соответствующие ограничения.
В Airflow для решения этой проблемы существует механизм управления ресурсами — **пулы задач** (pools). По умолчанию в Airflow настроен один пул задач — `default_pool` с 128 слотами, что означает возможность параллельного выполнения 128 задач одновременно. Пул `default_pool` нельзя удалить, но можно изменить его размер — увеличить или уменьшить количество слотов.
В Airflow для решения этой проблемы существует механизм управления ресурсами — **пулы задач** (pools). По умолчанию в Airflow настроен один пул задач — `default_pool` с 128 слотами. При стандартном `pool_slots=1` это верхняя граница в 128 одновременно работающих задач; ограничения executor и DAG могут уменьшить ее. Пул `default_pool` нельзя удалить, но можно изменить его размер — увеличить или уменьшить количество слотов.
Когда планировщик обнаруживает, что наступило время выполнения DAG, он запускает задачу согласно заданной последовательности. При этом задача занимает один слот в пуле и освобождает его после завершения.
Когда планировщик обнаруживает, что наступило время выполнения DAG, он запускает задачу согласно заданной последовательности. По умолчанию задача занимает один слот в пуле и освобождает его после завершения.
Создать новый пул задач и установить его размер можно через веб-интерфейс Airflow. Рассмотрим пример пула `data_processing_pool` для тяжёлых задач:
@@ -29,7 +29,7 @@
- Организовать запуск процессов в системе
- Предотвратить перегрузку системы при выполнении большого количества ресурсоемких задач
Вы можете задать "вес" задачи через параметр `pool_slots`, чтобы оптимизировать распределение нагрузки. Если общее количество задач превышает доступные слоты, планировщик поставит задачу в очередь и запустит ее, как только появятся свободные ресурсы.
Вы можете задать "вес" задачи через параметр `pool_slots`, чтобы оптимизировать распределение нагрузки. Планировщик учитывает сумму занятых слотов. Например, в пуле из двух слотов задачи с `pool_slots=2` и `pool_slots=1` не смогут работать одновременно: одна ждет завершения другой.
Пример настройки веса задач:
@@ -58,7 +58,7 @@ BashOperator(
)
```
Более подробную информацию о механизме пулов можно найти в [официальной документации](https://airflow.apache.org/docs/apache-airflow/stable/concepts/pools.html#pools).
Более подробную информацию о механизме пулов можно найти в [официальной документации](https://airflow.apache.org/docs/apache-airflow/2.9.2/administration-and-deployment/pools.html).
## Обмен данными между задачами: XCom и контекст выполнения
@@ -151,338 +151,136 @@ XCom упрощает взаимодействие между задачами
## Группировка задач с помощью TaskGroup
Для удобства визуализации задачи можно группировать в веб-интерфейсе Airflow (начиная с версии 2.0). Повторяющиеся или логически связанные задачи можно объединить в группы:
TaskGroup объединяет задачи в сворачиваемую группу на графе. Зависимости и состояния остаются у отдельных задач. Группировку задают в Python-коде; UI позволяет раскрыть ее и посмотреть содержимое.
В этом примере задачи сгруппированы в три логические группы: 'data_extraction', 'data_transformation' и 'data_loading'. Каждая группа содержит несколько задач, которые выполняются последовательно внутри группы. Затем группы связаны между собой, чтобы показать общий порядок выполнения этапов обработки данных.
В примере три группы. Внутри каждой задачи идут последовательно. Группы извлечения и преобразования независимы, а загрузка ждет завершения обеих. `EmptyOperator` ничего не обрабатывает: здесь мы смотрим только на устройство графа.
```python
with TaskGroup("data_extraction") as extraction_group:
extract_1 = DummyOperator(task_id="extract_source_1")
extract_2 = DummyOperator(task_id="extract_source_2")
extract_3 = DummyOperator(task_id="extract_source_3")
extract_1 >> extract_2 >> extract_3
from datetime import datetime
with TaskGroup("data_transformation") as transformation_group:
transform_1 = DummyOperator(task_id="transform_step_1")
transform_2 = DummyOperator(task_id="transform_step_2")
transform_1 >> transform_2
from airflow import DAG
from airflow.operators.empty import EmptyOperator
from airflow.utils.task_group import TaskGroup
with TaskGroup("data_loading") as loading_group:
load_1 = DummyOperator(task_id="load_to_target_1")
load_2 = DummyOperator(task_id="load_to_target_2")
load_1 >> load_2
with DAG(
dag_id="task_group_example",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
) as dag:
with TaskGroup("data_extraction") as extraction_group:
extract_1 = EmptyOperator(task_id="extract_source_1")
extract_2 = EmptyOperator(task_id="extract_source_2")
extract_3 = EmptyOperator(task_id="extract_source_3")
extract_1 >> extract_2 >> extract_3
[extraction_group, transformation_group] >> loading_group
with TaskGroup("data_transformation") as transformation_group:
transform_1 = EmptyOperator(task_id="transform_step_1")
transform_2 = EmptyOperator(task_id="transform_step_2")
transform_1 >> transform_2
with TaskGroup("data_loading") as loading_group:
load_1 = EmptyOperator(task_id="load_to_target_1")
load_2 = EmptyOperator(task_id="load_to_target_2")
load_1 >> load_2
[extraction_group, transformation_group] >> loading_group
```
Обратите внимание: при использовании TaskGroup последовательность задач указывается внутри группы после объявления всех задач, а в конце DAG описывается последовательность выполнения самих групп.
Здесь TaskGroup создается внутри `with DAG(...)`, поэтому группа и ее задачи получают один DAG. При отдельном объявлении `dag = DAG(...)` группу нужно привязать явно: `TaskGroup("data_extraction", dag=dag)`.
Ниже приведена упрощённая схема зависимостей между тремя группами задач:
Схема повторяет зависимости из кода:
```mermaid
flowchart LR
%% group1
subgraph G1["group1"]
g1_t1["task1"]
g1_t2["task2"]
g1_t3["task3"]
g1_t1 --> g1_t2
g1_t1 --> g1_t3
subgraph extraction["data_extraction"]
e1["extract_source_1"] --> e2["extract_source_2"] --> e3["extract_source_3"]
end
%% group2
subgraph G2["group2"]
g2_t1["task1"]
g2_t2["task2"]
g2_t1 --> g2_t2
subgraph transformation["data_transformation"]
t1["transform_step_1"] --> t2["transform_step_2"]
end
%% group3
subgraph G3["group3"]
g3_t1["task1"]
g3_t2["task2"]
g3_t1 --> g3_t2
subgraph loading["data_loading"]
l1["load_to_target_1"] --> l2["load_to_target_2"]
end
%% зависимости между группами
g1_t2 --> g3_t1
g1_t3 --> g3_t1
g2_t2 --> g3_t1
e3 --> l1
t2 --> l1
```
Визуально в интерфейсе Airflow группы задач отображаются как один узел с небольшим индикатором. Клик по нему разворачивает или сворачивает вложенные задачи, что значительно улучшает восприятие DAG с большим количеством задач и связей, особенно когда в них десятки и сотни задач.
TaskGroup — это удобный способ логической группировки задач, который помогает упростить код и представить сложные пайплайны более компактно.
Для запуска сохраните пример в `airflow-docker/dags/task_group_example_dag.py`. После ручного запуска все семь задач должны завершиться успешно. Нажмите название группы со стрелкой, чтобы раскрыть ее. Полный идентификатор первой задачи - `data_extraction.extract_source_1`: имя группы становится префиксом `task_id`. Такой идентификатор нужен при обращении к задаче через XCom.
## Система оповещений (алертинг)
Алертинг — один из ключевых компонентов системы оркестрации, так как важно своевременно получать уведомления об ошибках для их оперативного анализа и решения.
В Airflow есть встроенная поддержка отправки уведомлений на электронную почту (при условии, что в конфигурации настроен SMTP-сервер). При создании DAG указываются email-адреса, на которые будут отправляться сообщения. С помощью параметров можно настроить различные сценарии оповещений.
Давайте модифицируем наш первый DAG так, чтобы получать уведомления на почту при возникновении ошибок. При этом настроим перезапуск задач в случае неудачи (например, 2 попытки), но без уведомлений о самих перезапусках:
В приведенном примере создан DAG с идентификатором 'customer_analysis_pipeline', который настроен на отправку уведомлений по электронной почте только при ошибках (email_on_failure=True), но не при повторных попытках (email_on_retry=False). Также установлено 2 попытки повторного запуска задач при ошибках с задержкой 2 минуты между попытками.
Для первого опыта достаточно callback с записью в лог. Он получает контекст задачи и помогает связать сообщение с конкретным запуском:
```python
import os
import datetime as dt
import pandas as pd
from airflow.models import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from sqlalchemy import create_engine
import logging
# основные параметры DAG
args = {
'owner': 'data_engineering_team',
'start_date': dt.datetime(2021, 6, 15),
'retries': 2,
'retry_delay': dt.timedelta(minutes=2),
'email': ["data-team@example.com"],
'email_on_failure': True,
'email_on_retry': False,
}
dag = DAG(
dag_id='customer_analysis_pipeline',
schedule_interval=None,
default_args=args,
)
def notify_failure(context):
ti = context["ti"]
logging.error(
"Ошибка: dag=%s task=%s run=%s причина=%s",
ti.dag_id, ti.task_id, context["run_id"], context["exception"],
)
```
Теперь вы будете получать email-уведомления при ошибках выполнения.
Укажите `on_failure_callback=notify_failure` в рабочем операторе. Функция вызывается после окончательного отказа, когда повторы исчерпаны. Для сообщения о предстоящем повторе существует `on_retry_callback`. Например, при `retries=2` задача может выполниться три раза: исходная попытка и два повтора.
Пример функции для отправки уведомлений с использованием параметров из контекста:
Callback запускается при реальном выполнении задачи. Ручная смена статуса в UI его не проверяет. В этом стенде сообщение callback оператора видно в Logs последней попытки задачи. Callback, назначенный самому DAG, пишет в файл логов планировщика. Например, для `error_handling_dag.py`:
```bash
docker compose exec airflow-scheduler cat /opt/airflow/logs/scheduler/latest/error_handling_dag.py.log
```
Этот файл может содержать несколько запусков: сверяйте `run_id` и время. Обычный `docker compose logs airflow-scheduler` не показывает все записи из него. Виды событий описаны в [документации Airflow 2.9.2 о callbacks](https://airflow.apache.org/docs/apache-airflow/2.9.2/administration-and-deployment/logging-monitoring/callbacks.html).
### Отправка почты по желанию
В учебном стенде SMTP не настроен. Для отправки нужен SMTP-сервер и адрес получателя, которому можно отправлять тестовые сообщения. Укажите настройки в общем окружении сервисов Airflow: `AIRFLOW__SMTP__SMTP_HOST`, `AIRFLOW__SMTP__SMTP_PORT`, `AIRFLOW__SMTP__SMTP_MAIL_FROM`, параметры TLS/SSL и учетные данные вашего сервера. Секреты храните локально, вне Git. После изменения окружения пересоздайте сервисы командой `docker compose up -d`. Полный список параметров находится в [конфигурации Airflow 2.9.2](https://airflow.apache.org/docs/apache-airflow/2.9.2/configurations-ref.html#smtp).
После настройки замените запись в лог отправкой сообщения:
```python
from datetime import datetime, timedelta, timezone
import dateutil
from html import escape
from airflow.utils.email import send_email_smtp
from airflow.operators.python import get_current_context
MAIL_LIST = [
"email_1@gmail.ru",
"email_2@gmail.ru"
]
MAIL_TO = ["student@example.com"] # Замените своим тестовым адресом.
def notify_email(calculation_dt: str, dagrun_begin_time: str):
# dag_run date is utc timezone, so add `timezone.utc` to calculation_end to combat 3 hour diff.
context = get_current_context()
calculation_start = dateutil.parser.isoparse(dagrun_begin_time.replace('Z', '+00:00'))
calculation_end = datetime.now(timezone.utc)
duration = str(timedelta(seconds=(calculation_end - calculation_start).seconds))
title = f"Ежедневные расчёты завершились успешно (dag: {context['task_instance'].dag_id})."
body = f"""
Привет, <br>
Я закончил работу над расчётом за "{calculation_dt}". Это заняло {duration} часов/минут/секунд.<br>
Логи и запуски тоже можно посмотреть <a href="http://airflow-monitoring.example.com/tree?dag_id=daily_guests_features">тут</a>.
<br>
<br>
<br>
Навеки твой,<br>
Airflow бот <br>
"""
send_email_smtp(";".join(MAIL_LIST), title, body)
```
Такую функцию можно разместить в отдельном файле (например, `utils.py`), импортировать как модуль в нужных DAG и вызывать отдельной задачей:
```python
from airflow.operators.python import PythonOperator
from airflow.utils.trigger_rule import TriggerRule
from utils import notify_email
LOCAL_CALCULATION_DT = '{{ dag.timezone.convert(execution_date).strftime("%Y-%m-%d") }}'
DAG_RUN_BEGIN_TIME = "{{ dag_run.start_date }}"
email_notification_task = PythonOperator(
task_id="send_email_notification",
python_callable=notify_email,
dag=dag,
trigger_rule=TriggerRule.ALL_DONE,
op_args=[LOCAL_CALCULATION_DT, DAG_RUN_BEGIN_TIME],
)
... >> email_notification_task
```
## Практическое применение: улучшенный DAG
Теперь применим изученные концепции для усовершенствования нашего DAG. Добавим переменные и разобьем задачи на логические группы.
Сначала создадим переменную `DATABASE_URL` со строкой подключения к базе данных через веб-интерфейс Airflow и импортируем ее в коде DAG:
В этом примере используется переменная 'database_connection_string', предварительно созданная в интерфейсе Airflow, для хранения строки подключения к базе данных. Это позволяет избежать жесткого кодирования конфиденциальной информации в коде DAG и упрощает настройку подключения для разных окружений.
```python
import os
import datetime as dt
import pandas as pd
from airflow.models import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.operators.dummy import DummyOperator
from airflow.utils.task_group import TaskGroup
from airflow.models import Variable
from sqlalchemy import create_engine
DATABASE_URL = Variable.get('database_connection_string')
args = {
'owner': 'analytics_team',
'start_date': dt.datetime(2021, 6, 15),
'retries': 2,
'retry_delay': dt.timedelta(minutes=2),
}
# функции для обработки данных
def get_file_path(file_name):
return os.path.join(os.path.expanduser('~/data'), file_name)
def load_customer_data():
file_path = get_file_path('customer_data.csv')
df = pd.read_csv(file_path)
engine = create_engine(DATABASE_URL)
df.to_sql('customers', engine, index=False, if_exists='replace', schema='staging')
def aggregate_customer_data():
engine = create_engine(DATABASE_URL)
customer_df = pd.read_sql('select * from staging.customers', con=engine)
df = customer_df.groupby(['region', 'category']).agg(
total_orders=('orders', 'sum'),
avg_amount=('amount', 'mean')
).reset_index()
df.to_sql('customer_summary', engine, index=False, if_exists='replace', schema='analytics')
dag = DAG(
dag_id='customer_pipeline_enhanced',
schedule_interval=None,
default_args=args,
)
```
Теперь разобьем задачи на логические группы и добавим Jinja-шаблоны для доступа к контексту выполнения:
В этом примере создается начальная задача 'pipeline_start', которая использует Jinja-шаблоны для вывода информации о запуске DAG, включая идентификатор запуска (run_id) и информацию о DAG Run. Затем задачи группируются в логическую группу 'data_processing_stage', что улучшает структуру и читаемость DAG.
```python
# Начальная задача с информацией о запуске
start_task = BashOperator(
task_id='pipeline_start',
bash_command='echo "Pipeline started! Run ID: {{ run_id }} | DAG Run: {{ dag_run }}"',
dag=dag,
)
# Группа задач по предварительной обработке данных
with TaskGroup(group_id="data_processing_stage") as data_processing:
# Загрузка данных
load_customer_dataset = PythonOperator(
task_id='load_customer_data',
python_callable=load_customer_data,
dag=dag,
def notify_failure(context):
ti = context["ti"]
send_email_smtp(
to=MAIL_TO,
subject=f"Ошибка {ti.dag_id}: {ti.task_id}",
html_content=(
f"<p>Запуск: {escape(context['run_id'])}</p>"
f"<p>Причина: {escape(str(context['exception']))}</p>"
f'<p><a href="{escape(ti.log_url, quote=True)}">Лог задачи</a></p>'
),
)
# Агрегация и запись данных
aggregate_customer_dataset = PythonOperator(
task_id='aggregate_customer_data',
python_callable=aggregate_customer_data,
dag=dag,
)
load_customer_dataset >> aggregate_customer_dataset
# Установка последовательности выполнения
start_task >> data_processing
def notify_success(context):
send_email_smtp(
to=MAIL_TO,
subject=f"DAG {context['dag'].dag_id} завершен успешно",
html_content=f"<p>Запуск: {escape(context['run_id'])}</p>",
)
```
Поскольку последовательность задач внутри групп указывается при их создании, в конце необходимо определить порядок выполнения самих групп, чтобы планировщик понимал общую логику выполнения.
Для примера с ошибками задайте `on_failure_callback=notify_failure` у двух рабочих задач, а `on_success_callback=notify_success` у самого `DAG(...)`. Если упадут обе задачи, придут два сообщения об отказе. Сообщение об успехе отправится только после успешного DAG Run. Не включайте одновременно `email_on_failure=True`, если не хотите получать еще и встроенное письмо о той же ошибке.
Итоговый DAG будет выглядеть следующим образом:
Не добавляйте ради уведомления единственную завершающую задачу с `trigger_rule="all_done"`: если она успешна, Airflow может признать весь запуск успешным, несмотря на отказ внутри графа. Callback не меняет граф и итог обработки. Ошибка отправки означает проблему уведомления; она не превращает успешно обработанные данные в неуспешные и не скрывает исходный отказ. Подробности определения результата - в [описании DAG Run](https://airflow.apache.org/docs/apache-airflow/2.9.2/core-concepts/dag-run.html#dag-run-status).
```python
import os
import datetime as dt
import pandas as pd
from airflow.models import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.operators.dummy import DummyOperator
from airflow.utils.task_group import TaskGroup
from airflow.models import Variable
from sqlalchemy import create_engine
Проверка: сначала воспроизведите окончательный отказ, затем успешный запуск. Сверьте текст каждого письма с состоянием соответствующего DAG Run. Упражнения находятся в [блоке ошибок](educational-tasks.md#errors) и [задании на почту](educational-tasks.md#resources).
DATABASE_URL = Variable.get('database_connection_string')
## Практика на учебном стенде
args = {
'owner': 'analytics_team',
'start_date': dt.datetime(2021, 6, 15),
'retries': 2,
'retry_delay': dt.timedelta(minutes=2),
}
Откройте [resource_management_dag.py](airflow-docker/dags/resource_management_dag.py). Он имитирует чтение двух источников и передает небольшие результаты через XCom. Для запуска нужен пул `training_pool` с двумя слотами; создайте его в Admin > Pools.
def get_file_path(file_name):
return os.path.join(os.path.expanduser('~/data'), file_name)
В исходном DAG задачи `read_customers` и `read_orders` не зависят друг от друга, но занимают два и один слот соответственно. Поэтому они выполняются последовательно. `calculate_metrics` получает их результаты через `xcom_pull`, а `log_metrics` пишет в лог словарь `customers=20`, `orders=30`.
def load_customer_data():
file_path = get_file_path('customer_data.csv')
df = pd.read_csv(file_path)
engine = create_engine(DATABASE_URL)
df.to_sql('customers', engine, index=False, if_exists='replace', schema='staging')
В [заданиях 8.1-8.3](educational-tasks.md#resources) вы измените вес задачи в пуле, добавите метрику и объедините задачи чтения в TaskGroup. Там описаны изменения, ожидаемые результаты и места проверки. Файлы CSV и подключение к БД для этого примера не нужны: чтение источников имитируется.
def aggregate_customer_data():
engine = create_engine(DATABASE_URL)
customer_df = pd.read_sql('select * from staging.customers', con=engine)
df = customer_df.groupby(['region', 'category']).agg(
total_orders=('orders', 'sum'),
avg_amount=('amount', 'mean')
).reset_index()
df.to_sql('customer_summary', engine, index=False, if_exists='replace', schema='analytics')
dag = DAG(
dag_id='customer_pipeline_enhanced',
schedule_interval=None,
default_args=args,
)
# Начальная задача
start_task = BashOperator(
task_id='pipeline_start',
bash_command='echo "Pipeline started! Run ID: {{ run_id }} | DAG Run: {{ dag_run }}"',
dag=dag,
)
# Группа предварительной обработки
with TaskGroup(group_id="data_processing_stage") as data_processing:
load_customer_dataset = PythonOperator(
task_id='load_customer_data',
python_callable=load_customer_data,
dag=dag,
)
aggregate_customer_dataset = PythonOperator(
task_id='aggregate_customer_data',
python_callable=aggregate_customer_data,
dag=dag,
)
load_customer_dataset >> aggregate_customer_dataset
start_task >> data_processing
```
В этом материале мы рассмотрели расширенные возможности Airflow, которые помогут улучшить работу ваших пайплайнов:
- Управление ресурсами с помощью пулов задач
- Обмен данными между задачами через XCom
- Логическая группировка задач с TaskGroup
- Настройка системы оповещений
Помните: не стоит использовать все доступные функции сразу. Выбирайте инструменты последовательно и находите оптимальный набор возможностей под конкретную задачу.
Для опытов с callback используйте [error_handling_dag.py](airflow-docker/dags/error_handling_dag.py) и [блок 7](educational-tasks.md#errors). Отправка настоящей почты остается заданием по желанию.
+3
View File
@@ -11,6 +11,9 @@ This repository contains an educational manual for Apache Airflow with runnable
- `data/`: sample input data for exercises.
- Note: `airflow-docker/AGENTS.md` adds extra, folder-specific guidance and takes precedence there.
## Practice Improvement Work
При переработке практических заданий, учебного поведения DAG или маршрута курса сначала прочитайте [план обновления практики](docs/specs/2026-09-28-practice-improvement-plan.md): согласованные решения, текущий этап и критерии его завершения.
## Build, Test, and Development Commands
Prerequisite: Docker + Docker Compose.
- Build images: `cd airflow-docker && docker-compose build`
+16 -36
View File
@@ -1,19 +1,8 @@
# Учебник по Apache Airflow для начинающих
Добро пожаловать в учебник по Apache Airflow! Этот курс создан специально для тех, кто уже освоил Python и SQL и готов познакомиться со своим первым инструментом для ETL-процессов. К учебнику прилагается готовый практический стенд, позволяющий сразу применить полученные знания на практике.
Учебник знакомит с Apache Airflow 2.9: устройством DAG, запуском задач, интерфейсом и поиском ошибок. К нему прилагается Docker-стенд с готовыми примерами. Перед началом нужны Python, pandas, SQL и основы Docker.
## 📚 Введение
Apache Airflow — это мощный оркестратор рабочих процессов с открытым исходным кодом, который стал стандартом де-факто в мире обработки данных. Если вы работаете с автоматизацией процессов обработки информации — будь то подготовка данных для машинного обучения, создание отчетов или интеграция данных из различных источников — Airflow поможет вам организовать эти процессы эффективно и надежно.
Этот учебник разработан с учетом того, что вы:
- Имеете базовые знания Python и SQL
- Хотите освоить свой первый ETL-инструмент
- Нуждаетесь в практическом руководстве с понятными объяснениями
Мы начнем с основ и постепенно перейдем к продвинутым возможностям, чтобы вы могли уверенно использовать Airflow в реальных проектах. Курс ориентирован на версию Airflow 2.9 и использует практический подход с множеством примеров и визуальных материалов.
## 🎯 Что вы узнаете
## Что вы узнаете
- **Основы Airflow**: что это такое, для каких задач используется и почему он стал таким популярным
- **Ключевые концепции**: DAG, задачи, операторы и другие фундаментальные понятия
@@ -23,7 +12,7 @@ Apache Airflow — это мощный оркестратор рабочих п
- **Отладка и мониторинг**: как понимать статусы задач и быстро находить проблемы
- **Продвинутые возможности**: шаблоны, параметры, управление временем, пулы, XCom, TaskGroup и алертинг
## 📖 Содержание курса
## Содержание курса
### [01. Введение в Airflow](01%20-%20Введение%20в%20Airflow.md)
- Почему Apache Airflow стал незаменимым инструментом для работы с данными
@@ -73,34 +62,25 @@ Apache Airflow — это мощный оркестратор рабочих п
- Логическая группировка задач с TaskGroup
- Настройка системы оповещений и уведомлений
## 🛠️ Практический стенд
## Практический стенд
К этому учебнику прилагается полноценный стенд Apache Airflow, который позволяет сразу приступить к практике и закрепить полученные знания. В папке [`airflow-docker`](airflow-docker/) вы найдете готовую среду для экспериментов с 6 готовыми DAG'ами разной сложности и практическими заданиями для каждого из них.
В [`airflow-docker`](airflow-docker/) находятся Airflow 2.9.2, учебная PostgreSQL и девять готовых DAG. [Инструкция запуска](airflow-docker/README.md) поможет поднять стенд, а [маршрут практики](educational-tasks.md) связывает восемь блоков заданий с главами учебника.
Стенд включает настроенную среду с PostgreSQL для тренировок, образцы данных и детальные инструкции для практических занятий. Подробную информацию о возможностях стенда и инструкции по запуску смотрите в [`README.md`](airflow-docker/README.md) папки `airflow-docker`.
Здесь вы запускаете готовый DAG, меняете одну часть и проверяете результат в графе, логах или данных. Самостоятельные процессы обработки данных относятся к курсовым работам дальше по [программе](https://de.dementev.space/).
## 🚀 Как использовать этот учебник
## Как использовать этот учебник
1. **Изучайте теорию**: начните с Введения в Airflow и переходите к следующим главам по порядку
2. **Практикуйтесь на стенде**: после каждой главы выполняйте соответствующие задания в практическом стенде
3. **Создавайте свои DAG**: применяйте полученные знания для решения реальных задач
4. **Используйте визуальные материалы**: в учебнике много изображений и диаграмм для лучшего понимания
5. **Экспериментируйте**: не бойтесь пробовать разные настройки и сценарии на учебном стенде
1. Прочитайте главы 1-5 и запустите стенд. Перед каждым блоком практики сверяйтесь с колонкой "Перед практикой": некоторые задания используют материал следующих глав.
2. Выполняйте основные задания блока по порядку и сохраняйте изменения. Каждый новый блок описывает, с какого состояния начать.
3. Проверяйте раздел "Что должно получиться". В опыте с ошибкой результатом может быть `failed`; важно найти причину и восстановить успешный запуск.
4. Задания "По желанию" можно пропустить. Они не нужны для продолжения основного маршрута, а высокая сложность отмечена отдельно.
## 📝 Технические требования
Практика завершена, когда пройдены основные задания всех восьми блоков и вы можете объяснить наблюдаемое поведение. Выполнение всех усложнений для этого не требуется.
- **Python**: базовое знание языка
## Технические требования
- **Python и pandas**: чтение функций, работа с таблицами
- **SQL**: понимание основных запросов и концепций
- **Airflow**: версия 2.9 (на которой ориентирован курс)
- **Docker и Docker Compose**: запуск учебного стенда
- **Среда разработки**: любой текстовый редактор или IDE с поддержкой Python
## 💡 Советы для успешного обучения
- **Не торопитесь**: лучше глубоко понять каждый концепт, чем быстро пройти весь курс
- **Задавайте вопросы**: если что-то непонятно, вернитесь к предыдущим материалам
- **Делайте заметки**: записывайте важные моменты и примеры кода
- **Применяйте на практике**: сразу пробуйте создавать свои DAG для реальных задач
---
*Этот учебник создан для начинающих специалистов в области данных и инженерии. Все материалы ориентированы на практическое применение и пошаговое освоение Apache Airflow.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+71 -239
View File
@@ -1,273 +1,105 @@
# Educational Airflow Setup for Beginners
# Учебный стенд Apache Airflow
Простой учебный стенд Apache Airflow для начинающих, изучающих SQL и Python.
Стенд содержит Airflow 2.9.2, учебную PostgreSQL и девять DAG. Он нужен для знакомства с инструментом: запустить пример, изменить его и посмотреть результат. Для работы понадобятся Docker с Compose, браузер и знания Python, pandas и SQL.
## 🎯 Цель проекта
## Запуск
Создать максимально простую среду для изучения Apache Airflow.
Для удобства все переменные окружения захардкожены в docker скрипты. На проде так делать не надо :)
## 📋 Предварительные требования
- Docker и Docker Compose
- Базовые знания Python и SQL
- Веб-браузер для доступа к интерфейсу Airflow
## 🚀 Быстрый старт
### 1. Запуск стенда
Из корня репозитория:
```bash
# Перейдите в директорию проекта
cd airflow-docker
# Запустите все сервисы
docker-compose up -d
docker compose up -d --build
```
### 2. Доступ к интерфейсам
Команды подходят для Linux и PowerShell в Windows. При первом запуске Docker собирает образ, поднимает две БД, выполняет `airflow-init`, затем запускает webserver и scheduler. Инициализация создает учетную запись и подключение `postgres_training`.
- **Airflow UI**: http://localhost:8080
- Логин: `admin`
- Пароль: `admin`
Проверьте состояние:
- **PostgreSQL для тренировок**: `localhost:5432`
- База данных: `training`
- Пользователь: `student`
- Пароль: `student`
- **PostgreSQL для метаданных Airflow**: `localhost:5434`
- База данных: `airflow`
- Пользователь: `airflow`
- Пароль: `airflow`
## 🏗️ Архитектура стенда
```mermaid
graph LR
%% Локальная файловая система
subgraph local["Локальная файловая система"]
dags["Каталог<br/>Educational DAGs"]
fs["Local Filesystem"]
dags --> fs
end
%% Docker-контейнеры
subgraph docker["Контейнеры Docker"]
subgraph airflow["Airflow"]
ws["Airflow Webserver"]
sch["Airflow Scheduler"]
meta["PostgreSQL Metadata"]
sch --> meta
ws --> meta
end
subgraph training["Учебная БД"]
train["PostgreSQL Training"]
end
end
%% Клиенты
subgraph clients["Клиенты"]
user["Пользователь<br/>(браузер)"]
sql["SQL клиент"]
end
%% Монтирование DAG'ов в контейнеры Airflow
dags -. "том с DAG-файлами" .- ws
dags -. "том с DAG-файлами" .- sch
%% Airflow-задачи работают с учебной БД
sch -- "задачи DAG" --> train
%% Внешние подключения
user -- "порт 8080" --> ws
sql -- "порт 5432" --> train
```bash
docker compose ps -a
docker compose logs airflow-init
```
## 📁 Структура проекта
Завершение `airflow-init` с кодом 0 нормально: это разовая подготовка. Остальные четыре сервиса должны работать. Откройте http://localhost:8080 и войдите как `admin` / `admin`.
```
| Сервис | Адрес с компьютера | База | Пользователь / пароль |
|---|---|---|---|
| Airflow UI | `http://localhost:8080` | - | `admin` / `admin` |
| Учебная PostgreSQL | `localhost:5432` | `training` | `student` / `student` |
| Метаданные Airflow | `localhost:5434` | `airflow` | `airflow` / `airflow` |
В задачах используется адрес `postgres-training:5432`: это имя сервиса внутри Docker. Базу метаданных для упражнений не используйте. Учебные пароли заданы в Compose; для публичного сервера такая конфигурация не подходит.
## Прохождение практики
Откройте [задания](../educational-tasks.md). Их восемь блоков проходят последовательно; перед каждым указаны нужные главы [учебника](../README.md), исходные файлы и настройки.
Основные задания внутри блока сохраняют изменения предыдущих шагов. Дополнительные находятся в конце блока и помечены "По желанию"; их можно пропустить. Для завершения практики достаточно основных заданий и проверки их результатов.
| Блок | DAG |
|---|---|
| [Первый DAG](../educational-tasks.md#first-dag) | `hello_world_dag` |
| [SQL и подключения](../educational-tasks.md#sql) | `sql_basic_dag` |
| [Работа с файлами](../educational-tasks.md#files) | `file_operations_dag` |
| [Загрузка CSV и DQ](../educational-tasks.md#load-dq) | `csv_to_postgres`, `csv_to_postgres_dq` |
| [Разбор ETL](../educational-tasks.md#etl) | `data_processing_dag` |
| [Ветвление](../educational-tasks.md#branching) | `branching_dag` |
| [Ошибки и повторы](../educational-tasks.md#errors) | `error_handling_dag` |
| [Пулы, XCom и TaskGroup](../educational-tasks.md#resources) | `resource_management_dag` |
Описание поведения исходных примеров находится в [справочнике DAG](dag-specifications.md). Почта нужна только для отдельного задания по желанию; SMTP в стенде не настроен.
## Файлы и данные
```text
airflow-docker/
├── docker-compose.yml # Конфигурация Docker
├── .env # Файл не используется: переменные заданы в docker-compose.yml
├── dags/ # DAG файлы для обучения
│ ├── hello_world_dag.py # Базовый пример
│ ├── sql_basic_dag.py # Работа с SQL
│ ├── file_operations_dag.py # Обработка файлов
│ ├── csv_to_postgres.py # Загрузка CSV в Postgres (ETL)
│ ├── csv_to_postgres_dq.py # Проверки качества данных (DQ)
│ ├── data_processing_dag.py # Сложный ETL пайплайн
│ ├── branching_dag.py # Условная логика
│ └── error_handling_dag.py # Обработка ошибок
├── data/ # Данные и артефакты прогонов
│ ├── input/ # Входные данные
│ └── output/ # Сгенерированные CSV, отчеты и результаты обработки
├── logs/ # Логи Airflow
├── README.md # Эта инструкция
└── educational-tasks.md # Практические задания для студентов
├── docker-compose.yml # Сервисы, порты и учебные подключения
├── Dockerfile # Airflow 2.9.2
├── requirements.txt # Дополнительные Python-пакеты
├── dags/ # Девять учебных DAG
├── data/input/ # Входные CSV и данные упражнений
├── data/output/ # Результаты задач
└── init-postgres.sql # Начальная подготовка учебной БД
```
## 🎓 Учебные материалы
`dags/` и `data/` смонтированы в контейнеры как `/opt/airflow/dags/` и `/opt/airflow/data/`. Правки DAG подхватываются планировщиком; перед новым запуском дождитесь обновления вкладки Code. Резервные копии Python-файлов храните вне `dags/`, чтобы не создать два определения одного `dag_id`.
### Основы Airflow
Таблицы PostgreSQL сохраняются в именованных томах `pg_data` и `pgmeta`. Обычная остановка их не удаляет. Логи Airflow хранятся внутри контейнеров; важные логи опыта сохраните до удаления контейнеров.
**Цели:**
- Понимание структуры DAG
- Создание простых задач
- Настройка зависимостей между задачами
**Примеры DAG:**
- `hello_world_dag.py` - базовые операторы Python
- `sql_basic_dag.py` - работа с базами данных
### Интеграция с данными
**Цели:**
- Подключение к PostgreSQL
- Выполнение SQL запросов
- Обработка файлов CSV
**Примеры DAG:**
- `file_operations_dag.py` - работа с файлами
- `csv_to_postgres.py` - загрузка данных из CSV в PostgreSQL
- `csv_to_postgres_dq.py` - автоматизированные проверки качества (Data Quality)
- `data_processing_dag.py` - ETL процессы
### Продвинутые возможности
**Цели:**
- Условное выполнение задач
- Обработка ошибок
- Параметризация workflows
**Примеры DAG:**
- `branching_dag.py` - условная логика
- `error_handling_dag.py` - обработка ошибок
## 🎯 Практические задания
Для закрепления знаний по каждому DAG мы подготовили практические задания разного уровня сложности:
### 📋 Учебные задания
В файле **[educational-tasks.md](educational-tasks.md)** вы найдете детальные задания для каждого DAG:
- 🌱 **Начальный уровень**: Базовые задачи по созданию и настройке DAG'ов
- 📚 **Средний уровень**: Работа с данными, файлами и SQL операциями
- 🚀 **Продвинутый уровень**: ETL процессы, ветвление, обработка ошибок
### 🎓 Рекомендуемый путь обучения
1. **Начните с `hello_world_dag.py`** - освоите основы Airflow
2. **Перейдите к `sql_basic_dag.py`** - изучите работу с базами данных
3. **Попрактикуйтесь на `file_operations_dag.py`** - работа с файлами
4. **Освойте ETL и DQ на `csv_to_postgres.py` и `csv_to_postgres_dq.py`** - загрузка и валидация данных
5. **Разберите сложный ETL на `data_processing_dag.py`** - обработка данных
6. **Изучите продвинутые темы** - ветвление и обработка ошибок
Каждое задание содержит:
- Цель и сложность выполнения
- Конкретные требования к модификации кода
- Ожидаемый результат
- Время на выполнение
## 🔧 Технические детали
### Переменные окружения
В этом проекте мы не используем `.env`: все значения заданы напрямую в `docker-compose.yml`.
Сервисы Airflow получают:
- `POSTGRES_TRAINING_HOST=postgres-training`
- `POSTGRES_TRAINING_PORT=5432`
- `POSTGRES_TRAINING_DB=training`
- `POSTGRES_TRAINING_USER=student`
- `POSTGRES_TRAINING_PASSWORD=student`
- `AIRFLOW_CONN_POSTGRES_TRAINING=postgresql://student:student@postgres-training:5432/training`
`docker-compose run --rm airflow-init` запускает `airflow connections add postgres_training`, поэтому соединение доступно сразу после инициализации. Проверить наличие можно через:
Для SQL-запросов из заданий:
```bash
docker-compose exec airflow-webserver airflow connections get postgres_training
docker compose exec postgres-training psql -U student -d training
```
`csv_to_postgres.py` по умолчанию складывает сгенерированные CSV в `/opt/airflow/data/output`, то есть в локальный каталог `airflow-docker/data/output/`.
Выход из psql - `\q`. Готовое подключение Airflow можно проверить командой:
### Порты
- `8080` - Airflow Webserver
- `5432` - PostgreSQL для тренировок
- `5434` - PostgreSQL для метаданных Airflow
## 🛠️ Управление стендом
### Запуск сервисов
```bash
docker-compose up -d
docker compose exec airflow-webserver airflow connections get postgres_training
```
### Остановка сервисов
## Управление и поиск ошибок
Все команды ниже выполняются из `airflow-docker`.
```bash
docker-compose down
docker compose exec airflow-webserver airflow version
docker compose exec airflow-webserver airflow dags list
docker compose exec airflow-webserver airflow dags list-import-errors
docker compose logs airflow-scheduler
docker compose logs airflow-webserver
```
### Просмотр логов
Если DAG не появился, проверьте путь файла и ошибки импорта. Если задача упала, сначала откройте ее лог в UI. При ошибке подключения проверьте `docker compose ps` и Conn Id задачи. Задачи из блока 8 ждут, пока вы создадите указанный в задании пул.
Остановка с сохранением данных:
```bash
# Логи Airflow
docker-compose logs airflow-webserver
docker-compose logs airflow-scheduler
# Логи PostgreSQL
docker-compose logs postgres-training
docker-compose logs postgres-metadata
docker compose down
```
### Перезапуск конкретного сервиса
```bash
docker-compose restart airflow-webserver
```
Повторный запуск - `docker compose up -d`. Для полного сброса обеих БД используйте `docker compose down -v`: эта команда удалит учебные таблицы, историю запусков и настройки Airflow в томах. Файлы в `data/` останутся.
## 🐛 Решение проблем
## Для авторов курса
### DAG не появляется в интерфейсе
- Проверьте, что файл находится в папке `dags/`
- Убедитесь в правильности синтаксиса Python
- Проверьте логи планировщика: `docker-compose logs airflow-scheduler`
### Ошибки подключения к базе данных
- Убедитесь, что PostgreSQL запущен: `docker-compose ps`
- Проверьте логи PostgreSQL: `docker-compose logs postgres-training`
### Задачи завершаются с ошибкой
- Проверьте логи задачи в интерфейсе Airflow
- Убедитесь в наличии необходимых Python пакетов
## 📚 Дополнительные ресурсы
- [Официальная документация Airflow](https://airflow.apache.org/docs/)
- [Учебные материалы в родительской папке](../)
- [Практические задания для студентов](educational-tasks.md)
---
## 🌟 Начало работы с заданиями
После запуска стенда Airflow, откройте файл [educational-tasks.md](educational-tasks.md) и выберите задание, соответствующее вашему уровню подготовки. Каждое задание содержит подробные инструкции и ожидаемые результаты.
**Быстрый старт с заданиями:**
```bash
# Запустите стенд
docker-compose up -d
# Откройте задания в браузере или редакторе
# И следуйте инструкциям из educational-tasks.md
```
Удачи в изучении Apache Airflow!
---
**Примечание**: Этот стенд предназначен исключительно для учебных целей. Для production использования требуется дополнительная настройка безопасности.
[План обновления практики](../docs/specs/2026-09-28-practice-improvement-plan.md) сохраняет согласованные решения, разбор исходных заданий и результаты проверок.
+23 -321
View File
@@ -1,327 +1,29 @@
# Educational DAG Specifications for Airflow Learning
# Что делают исходные DAG
## Learning Progression Structure
Это справочник примеров для Airflow 2.9.2. Порядок прохождения, изменения кода и условия завершения находятся в [практических заданиях](../educational-tasks.md).
### Level 1: Basic Concepts (Week 1)
| DAG и файл в `dags/` | Подготовка | Поведение и результат |
|---|---|---|
| `hello_world_dag` (`hello_world_dag.py`) | Не требуется | Три Python-задачи печатают приветствие, дату и завершение. Расписание ежедневное, `catchup=False`. |
| `sql_basic_dag` (`sql_basic_dag.py`) | Connection `postgres_training`, создаваемый при запуске стенда | Создает `public.students_sample`, очищает перед вставкой, добавляет трех студентов и выполняет SELECT. После каждого полного запуска в таблице три строки. |
| `file_operations_dag` (`file_operations_dag.py`) | Каталоги `data/input`, `data/output` из репозитория | Генерирует 100 строк, проверяет данные, сохраняет `output/processed_data.csv` и `output/summary.txt`. Категория High начинается с 70000. |
| `csv_to_postgres` (`csv_to_postgres.py`) | Connection `postgres_training` | Создает `public.orders`, генерирует CSV на 1000 строк, передает путь через XCom и загружает файл. Уже существующие `order_id` пропускаются; новый полный запуск генерирует новые заказы. |
| `csv_to_postgres_dq` (`csv_to_postgres_dq.py`) | Успешная загрузка `csv_to_postgres`; запускать вручную после нее | Проверяет таблицу, точную схему из четырех колонок, наличие строк и отсутствие дубликатов. Сводка перечисляет состояния проверок. По умолчанию после ошибки последующие задачи не выполняются (`all_success`). |
| `data_processing_dag` (`data_processing_dag.py`) | Не требуется, свои входные CSV создаются заново | Извлекает 5 клиентов и 6 заказов в `output/extracted_*.csv`, читает их при преобразовании. `simulate_load` печатает число строк и DDL без записи в БД. Отчет: 6 заказов, выручка 2105. |
| `branching_dag` (`branching_dag.py`) | Variable `branch_format`: `csv` или `json`; по умолчанию `csv` | Выбранная ветка имитирует обработку, другая получает `skipped`. Слияние читает XCom текущего запуска с `none_failed_min_one_success`. Неизвестный формат приводит к ошибке. |
| `error_handling_dag` (`error_handling_dag.py`) | Variable `error_mode`, по умолчанию `retry_once` | Две рабочие задачи, по одному повтору с задержкой 10 секунд. Оба обработчика зависят от обеих задач. Окончательный отказ сохраняет `failed` у DAG Run, даже если обработчик ошибки успешен. |
| `resource_management_dag` (`resource_management_dag.py`) | Создать `training_pool` с 2 слотами | Две задачи по 20 секунд имитируют чтение и занимают 2 и 1 слот, поэтому работают последовательно. Через XCom передают 20 и 30; следующая задача возвращает словарь, последняя печатает его. |
#### 1.1 hello_world_dag.py
**Learning Objectives:**
- Understand basic DAG structure
- Learn about PythonOperator
- Understand task dependencies
Все DAG, кроме `hello_world_dag`, запускаются вручную. Связи между загрузчиком CSV и DQ-DAG нет. Файлы из `input/orders.csv` в ETL-примере и таблица `public.orders` в загрузчике относятся к разным блокам.
**DAG Structure:**
```python
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime
## Режимы примера с ошибками
def print_hello():
print("Hello World from Airflow!")
| `error_mode` | Рабочие задачи | Обработчики | DAG Run |
|---|---|---|---|
| `success` | Обе успешны с первой попытки | Успех: `success`, ошибка: `skipped` | `success` |
| `retry_once` | `retry_task` падает один раз, затем успешна | Успех: `success`, ошибка: `skipped` | `success` |
| `fail_first` | `unreliable_task` окончательно падает | Успех: `upstream_failed`, ошибка: `success` | `failed` |
| `fail_second` | `retry_task` окончательно падает | Успех: `upstream_failed`, ошибка: `success` | `failed` |
| `fail_both` | Обе окончательно падают | Успех: `upstream_failed`, ошибка: `success` | `failed` |
def print_date():
print(f"Current date: {datetime.now()}")
def print_goodbye():
print("Goodbye from Airflow!")
# DAG definition with simple tasks
```
**Tasks:**
- `start_task`: Print welcome message
- `date_task`: Print current date/time
- `end_task`: Print goodbye message
**Dependencies:**
start_task → date_task → end_task
#### 1.2 sql_basic_dag.py
**Learning Objectives:**
- Connect to PostgreSQL database
- Execute SQL queries
- Use PostgresOperator
**Connection Hint:** `docker-compose run --rm airflow-init` automatically provisions the `postgres_training` connection via `airflow connections add`, so no manual setup is required. You can verify it with `docker-compose exec airflow-webserver airflow connections get postgres_training`.
**Tasks:**
- `create_table`: Create simple table (users, products)
- `insert_data`: Insert sample records
- `query_data`: Select and display data
- `drop_table`: Clean up (optional)
**SQL Operations:**
```sql
-- Create table
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
age INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
-- Insert data
INSERT INTO students (name, age) VALUES
('Alice', 22),
('Bob', 24),
('Charlie', 21);
```
### Level 2: Intermediate Concepts (Week 2)
#### 2.1 file_operations_dag.py
**Learning Objectives:**
- File system operations
- CSV data processing
- Data transformation
**Tasks:**
- `generate_sample_data`: Create CSV file with random data
- `read_csv_file`: Read and validate data
- `transform_data`: Simple data transformations
- `write_output`: Save processed data
**Sample Data Structure:**
```csv
id,name,department,salary
1,Alice,Engineering,50000
2,Bob,Marketing,45000
3,Charlie,Sales,48000
```
#### 2.2 csv_to_postgres.py
**Learning Objectives:**
- Load CSV data into PostgreSQL database
- Implement idempotent loading via temporary table
- Use XCom for passing file paths between tasks
- Work with PostgreSQL connections in Airflow
**Scenario:**
Generate sample orders data as CSV, preview it, and load it into PostgreSQL.
**Tasks:**
- `create_orders_table`: Create public.orders table in PostgreSQL
- `generate_csv`: Generate sample orders CSV file
- `preview_csv`: Display first few rows of CSV
- `load_csv_to_postgres`: Load CSV data into PostgreSQL using temporary table
**Database Connection:** Uses `postgres_training` connection (auto-provisioned by init script).
**Generated Files:** CSV files are written to `/opt/airflow/data/output/`.
**Sample Data Structure:**
```csv
order_id,order_ts,customer_id,amount
1,2023-10-01 10:30:00,101,1250.50
2,2023-10-01 11:45:00,102,890.00
3,2023-10-02 09:15:00,103,2100.75
```
**Data Quality Checks:** Run `csv_to_postgres_dq.py` after loading to validate the target table.
#### 2.3 csv_to_postgres_dq.py
**Learning Objectives:**
- Implement data quality validation in Airflow
- Use Python functions for data checks
- Handle data quality failures
- Separate validation from main ETL pipeline
**Scenario:**
Run automated data quality checks on the public.orders table after CSV loading.
**Tasks:**
- `check_table_exists`: Verify public.orders table exists
- `check_schema`: Validate table schema matches expected structure
- `check_row_count`: Ensure table has data
- `check_duplicates`: Verify no duplicate order_id values
**Quality Checks:**
- Table existence in public schema
- Column names and data types (order_id, order_ts, customer_id, amount)
- Minimum row count (> 0)
- Unique order_id values (no duplicates)
**Helper Functions:** All DQ check functions are defined directly in `csv_to_postgres_dq.py`.
#### 2.4 data_processing_dag.py
**Learning Objectives:**
- ETL pipeline concepts
- Multiple data sources
- Error handling basics
**Tasks:**
- `extract_customers`: Read customer data
- `extract_orders`: Read order data
- `transform_data`: Join and process data
- `load_to_database`: Save results
- `generate_report`: Create summary
### Level 3: Advanced Concepts (Week 3)
#### 3.1 branching_dag.py
**Learning Objectives:**
- Conditional task execution
- BranchPythonOperator
- Decision making in workflows
**Scenario:**
Process data based on file type or data quality
**Tasks:**
- `check_file_type`: Determine processing path
- `process_csv_branch`: For CSV files
- `process_json_branch`: For JSON files
- `merge_results`: Combine outputs
#### 3.2 error_handling_dag.py
**Learning Objectives:**
- Task retries
- Error notifications
- Failure handling
**Tasks:**
- `unreliable_task`: Simulate failures
- `retry_task`: Demonstrate retry mechanism
- `success_handler`: On success callback
- `failure_handler`: On failure callback
### Level 4: Orchestration & Collaboration (Week 4)
#### 4.1 advanced_features_dag.py
**Learning Objectives:**
- Control concurrency with pools and `pool_slots`
- Exchange data between tasks using XCom
- Group related tasks using `TaskGroup`
- Configure email-based alerting on failures
**Scenario:**
Enhanced daily analytics pipeline that reads data from the training database, performs transformations, and writes summaries, while limiting heavy backup tasks via a dedicated pool and sending notifications about pipeline status.
**Tasks:**
- `extract_group`: Use `TaskGroup` to wrap extract tasks (e.g., customers and orders)
- `transform_group`: Aggregate metrics and prepare summary tables
- `load_group`: Simulate loading results back into the training database or files
- `backup_task`: Heavy backup task running in a dedicated pool (e.g., `backup_pool`) with custom `pool_slots`
- `calculate_metrics`: Python task that returns aggregated metrics (pushed to XCom)
- `log_metrics`: Task that reads metrics via `xcom_pull` and logs them or uses them in a template
- `send_notification`: Final notification task (email or log) triggered with `ALL_DONE` semantics
## Sample Data Files
### customers.csv
```csv
customer_id,name,email,join_date
1,Alice Johnson,alice@example.com,2023-01-15
2,Bob Smith,bob@example.com,2023-02-20
3,Charlie Brown,charlie@example.com,2023-03-10
```
### orders.csv
```csv
order_id,customer_id,product,amount,order_date
101,1,Laptop,1200,2023-10-01
102,2,Monitor,300,2023-10-02
103,1,Keyboard,80,2023-10-03
104,3,Mouse,25,2023-10-04
```
### products.csv
```csv
product_id,name,category,price
1,Laptop,Electronics,1200
2,Monitor,Electronics,300
3,Keyboard,Electronics,80
4,Mouse,Electronics,25
```
## Database Schema for Training
### Students Table
```sql
CREATE TABLE students (
student_id SERIAL PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
email VARCHAR(100),
enrollment_date DATE,
grade INTEGER
);
```
### Courses Table
```sql
CREATE TABLE courses (
course_id SERIAL PRIMARY KEY,
course_name VARCHAR(100),
instructor VARCHAR(100),
credits INTEGER
);
```
### Enrollments Table
```sql
CREATE TABLE enrollments (
enrollment_id SERIAL PRIMARY KEY,
student_id INTEGER REFERENCES students(student_id),
course_id INTEGER REFERENCES courses(course_id),
enrollment_date DATE,
grade CHAR(1)
);
```
## Learning Outcomes by Week
### Week 1: Foundation
- ✅ Understand DAG structure and components
- ✅ Create basic Python tasks
- ✅ Set up task dependencies
- ✅ Run first successful workflow
### Week 2: Integration
- ✅ Connect to databases
- ✅ Execute SQL operations
- ✅ Process file data
- ✅ Build simple ETL pipelines
### Week 3: Advanced Features
- ✅ Implement conditional logic
- ✅ Handle errors and retries
- ✅ Use parameters and templates
- ✅ Monitor and debug workflows
### Week 4: Orchestration & Operations
- ✅ Use pools to control resource usage
- ✅ Share data between tasks via XCom
- ✅ Group tasks using `TaskGroup`
- ✅ Configure alerting and notifications for failures
## Common Pitfalls and Solutions
### Problem: DAG not appearing in UI
**Solution:** Check DAG file location and syntax
### Problem: Database connection errors
**Solution:** Verify connection strings and database availability
### Problem: Task failures
**Solution:** Check logs, implement proper error handling
### Problem: Scheduling issues
**Solution:** Understand cron expressions and execution dates
## Assessment Criteria
### Basic Competency
- Can create simple DAG with 3+ tasks
- Understands task dependencies
- Can run and monitor workflows
### Intermediate Competency
- Can integrate with databases
- Can process file data
- Implements basic error handling
### Advanced Competency
- Uses conditional branching
- Implements proper error handling
- Creates reusable components
- Optimizes workflow performance
Меняйте Variable после завершения предыдущего запуска. `retries=1` означает две попытки всего. `failure_handler` использует `one_failed`: достаточно одного окончательного отказа, ждать завершения другой рабочей задачи ему не обязательно.
+58 -89
View File
@@ -1,103 +1,72 @@
"""Выбор ветки через Variable branch_format: csv или json.
Обработка файлов имитируется. В основной практике изучаются граф и статусы.
"""
DAG для демонстрации условного выполнения задач в Airflow
Уровень: Продвинутый
"""
from datetime import datetime, timedelta
import logging
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator, BranchPythonOperator
from airflow.operators.dummy import DummyOperator
import random
from airflow.models import Variable
from airflow.operators.empty import EmptyOperator
from airflow.operators.python import BranchPythonOperator, PythonOperator
# Определение DAG
default_args = {
'owner': 'student',
'depends_on_past': False,
'start_date': datetime(2023, 1, 1),
'email_on_failure': False,
'email_on_retry': False,
'retries': 1,
'retry_delay': timedelta(minutes=5)
}
dag = DAG(
'branching_dag',
default_args=default_args,
description='DAG для изучения условного выполнения задач в Airflow',
schedule_interval=None,
catchup=False,
tags=['educational', 'branching', 'advanced']
)
def choose_format():
"""Возвращает task_id выбранной ветки."""
file_format = Variable.get("branch_format", default_var="csv")
branches = {"csv": "process_csv_branch", "json": "process_json_branch"}
if file_format not in branches:
raise ValueError(
f"branch_format должен быть csv или json, получено: {file_format}"
)
logging.info("Выбран формат: %s", file_format)
return branches[file_format]
def check_data_quality():
"""Проверка качества данных - случайным образом определяет, какие данные использовать"""
# В реальном сценарии здесь будет проверка качества данных
# Для учебных целей просто случайное решение
quality_score = random.random() # случайное число от 0 до 1
if quality_score > 0.5:
print(f"Качество данных хорошее (оценка: {quality_score:.2f}), используем CSV")
return 'process_csv_branch'
else:
print(f"Качество данных требует внимания (оценка: {quality_score:.2f}), используем JSON")
return 'process_json_branch'
def process_csv_data():
"""Обработка CSV данных"""
print("Обработка CSV файла...")
# Здесь будет логика обработки CSV файла
return "CSV данные обработаны"
"""Имитирует обработку CSV."""
logging.info("Учебная CSV-ветка выполнена; файлы не читались")
return "csv"
def process_json_data():
"""Обработка JSON данных"""
print("Обработка JSON файла...")
# Здесь будет логика обработки JSON файла
return "JSON данные обработаны"
"""Имитирует обработку JSON."""
logging.info("Учебная JSON-ветка выполнена; файлы не читались")
return "json"
def merge_results():
"""Объединение результатов из разных веток"""
print("Объединение результатов из разных веток...")
return "Результаты объединены"
# Определение задач
start_task = DummyOperator(
task_id='start_task',
dag=dag
)
def merge_results(ti):
"""Читает результат выполненной ветки текущего запуска."""
results = ti.xcom_pull(task_ids=["process_csv_branch", "process_json_branch"])
selected = [value for value in results if value is not None]
logging.info("Результат выбранной ветки: %s", selected)
return selected
check_quality_task = BranchPythonOperator(
task_id='check_data_quality',
python_callable=check_data_quality,
dag=dag
)
process_csv_task = PythonOperator(
task_id='process_csv_branch',
python_callable=process_csv_data,
dag=dag
)
with DAG(
"branching_dag",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
default_args={"owner": "student", "retries": 0},
tags=["educational", "branching"],
) as dag:
start_task = EmptyOperator(task_id="start_task")
choose_task = BranchPythonOperator(
task_id="choose_format", python_callable=choose_format,
)
process_csv_task = PythonOperator(
task_id="process_csv_branch", python_callable=process_csv_data,
)
process_json_task = PythonOperator(
task_id="process_json_branch", python_callable=process_json_data,
)
merge_task = PythonOperator(
task_id="merge_results",
python_callable=merge_results,
trigger_rule="none_failed_min_one_success",
)
end_task = EmptyOperator(task_id="end_task")
process_json_task = PythonOperator(
task_id='process_json_branch',
python_callable=process_json_data,
dag=dag
)
merge_task = PythonOperator(
task_id='merge_results',
python_callable=merge_results,
trigger_rule='none_failed_or_skipped', # Выполняется, когда одна из веток завершена
dag=dag
)
end_task = DummyOperator(
task_id='end_task',
dag=dag
)
# Установка зависимостей
# BranchPythonOperator автоматически пропускает (skips) задачи в невыбранной ветке
# Например, если check_data_quality возвращает 'process_csv_branch',
# то задача process_json_branch будет пропущена (статус skipped)
start_task >> check_quality_task
check_quality_task >> [process_csv_task, process_json_task]
[process_csv_task, process_json_task] >> merge_task >> end_task
start_task >> choose_task >> [process_csv_task, process_json_task]
[process_csv_task, process_json_task] >> merge_task >> end_task
+2
View File
@@ -121,6 +121,8 @@ def _load_csv(csv_path: str) -> None:
cur.execute(
"CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;"
)
# Порядок полей CSV должен совпадать со списком COPY.
# HEADER пропускает заголовок, а не сопоставляет имена колонок.
cur.copy_expert(
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
f,
+16 -10
View File
@@ -2,7 +2,7 @@
DAG для проверки качества данных (Data Quality) после загрузки в PostgreSQL
Уровень: Средний
Этот DAG запускается после csv_to_postgres и проверяет таблицу public.orders:
Запустите этот DAG вручную после csv_to_postgres. Он проверяет public.orders:
1. Существование таблицы
2. Соответствие схемы (колонки и типы данных)
3. Наличие данных (таблица не пуста)
@@ -14,12 +14,11 @@ DAG для проверки качества данных (Data Quality) пос
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
POSTGRES_CONN_ID = "postgres_training"
@@ -106,13 +105,20 @@ def _check_no_duplicates():
conn.close()
def _log_dq_summary():
"""Логирует итоговую сводку по качеству данных."""
logging.info("Все проверки качества данных пройдены успешно!")
logging.info("Качество данных в таблице orders соответствует требованиям.")
def _log_dq_summary(dag_run, ti):
"""Проверяет итог, в том числе при учебном переходе на all_done."""
states = {
task.task_id: task.state
for task in dag_run.get_task_instances()
if task.task_id != ti.task_id
}
logging.info("Результаты проверок: %s", states)
if any(state != "success" for state in states.values()):
raise ValueError("Не все проверки качества данных прошли успешно")
logging.info("Все проверки качества данных пройдены")
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
default_args = {"owner": "student", "retries": 0}
with DAG(
dag_id="csv_to_postgres_dq",
+7 -8
View File
@@ -6,7 +6,7 @@ DAG для демонстрации ETL процессов в Airflow
1. Создание тестовых данных (клиенты + заказы) в CSV
2. Параллельное извлечение (Extract) клиентов и заказов
3. Трансформация (Transform) — объединение таблиц, вычисляемые поля
4. Загрузка (Load) — подготовка SQL для вставки в БД
4. Имитация загрузки: число подготовленных строк и пример DDL в логах
5. Генерация текстового отчёта со статистикой
Результат: файлы в /opt/airflow/data/output/ (extracted_*, transformed_data.csv, report.txt).
@@ -14,7 +14,6 @@ DAG для демонстрации ETL процессов в Airflow
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
# Определение DAG
default_args = {
@@ -85,8 +84,8 @@ def extract_orders():
def transform_data():
"""Преобразование данных - объединение клиентов и заказов"""
import pandas as pd
customers_df = pd.read_csv('/opt/airflow/data/input/customers.csv')
orders_df = pd.read_csv('/opt/airflow/data/input/orders.csv')
customers_df = pd.read_csv('/opt/airflow/data/output/extracted_customers.csv')
orders_df = pd.read_csv('/opt/airflow/data/output/extracted_orders.csv')
# Объединяем данные
merged_df = pd.merge(orders_df, customers_df, on='customer_id', how='left')
@@ -101,14 +100,14 @@ def transform_data():
return f"Преобразованы {len(merged_df)} записей"
def load_to_database():
def simulate_load():
"""Загрузка данных в базу данных (симуляция)"""
import pandas as pd
df = pd.read_csv('/opt/airflow/data/output/transformed_data.csv')
# В реальном сценарии здесь был бы код для загрузки в базу данных
# Для учебных целей просто логируем
print(f"Загружено в базу данных: {len(df)} записей")
print(f"Имитация загрузки: подготовлено {len(df)} записей. Записи в БД нет.")
# Создаем SQL для создания таблицы (в реальном сценарии)
create_table_sql = """
@@ -185,8 +184,8 @@ transform_task = PythonOperator(
)
load_task = PythonOperator(
task_id='load_to_database',
python_callable=load_to_database,
task_id='simulate_load',
python_callable=simulate_load,
dag=dag
)
+81 -100
View File
@@ -1,108 +1,89 @@
"""Повторы и обработчики результата. Режим задается Variable error_mode.
retry_once: вторая задача падает при первой попытке, затем выполняется.
success: обе задачи выполняются сразу.
fail_first / fail_second / fail_both: выбранные задачи падают при каждой попытке.
Меняйте режим после завершения предыдущего запуска.
"""
DAG для демонстрации обработки ошибок в Airflow
Уровень: Продвинутый
"""
import logging
from datetime import datetime, timedelta
from airflow import DAG
from airflow.models import Variable
from airflow.operators.empty import EmptyOperator
from airflow.operators.python import PythonOperator
from airflow.operators.dummy import DummyOperator
from airflow.models.baseoperator import chain
import random
# Определение DAG
default_args = {
'owner': 'student',
'depends_on_past': False,
'start_date': datetime(2023, 1, 1),
'email_on_failure': False, # Отключаем email уведомления для простоты
'email_on_retry': False,
'retries': 3, # Количество попыток при ошибке
'retry_delay': timedelta(seconds=10) # Задержка между попытками
}
dag = DAG(
'error_handling_dag',
default_args=default_args,
description='DAG для изучения обработки ошибок в Airflow',
schedule_interval=None,
def get_error_mode():
"""Читает режим во время выполнения задачи."""
mode = Variable.get("error_mode", default_var="retry_once")
if mode not in {"success", "retry_once", "fail_first", "fail_second", "fail_both"}:
raise ValueError(f"Неизвестный error_mode: {mode}")
return mode
def run_first_task():
"""Имитирует первую рабочую задачу."""
if get_error_mode() in {"fail_first", "fail_both"}:
raise ValueError("Учебная ошибка первой задачи")
logging.info("Первая задача выполнена")
def run_retry_task(ti):
"""Имитирует временную или постоянную ошибку второй задачи."""
mode = get_error_mode()
logging.info("Режим: %s; попытка: %s", mode, ti.try_number)
if mode in {"fail_second", "fail_both"}:
raise ValueError("Постоянная учебная ошибка второй задачи")
if mode == "retry_once" and ti.try_number == 1:
raise ValueError("Временная учебная ошибка: следующая попытка выполнится")
logging.info("Вторая задача выполнена")
def handle_success():
"""Сообщает об успехе обеих рабочих задач."""
logging.info("Обе рабочие задачи выполнены")
def handle_failure():
"""Сообщает об окончательном отказе хотя бы одной задачи."""
logging.error("Рабочая задача исчерпала повторы. Откройте ее лог")
with DAG(
"error_handling_dag",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
tags=['educational', 'error_handling', 'advanced']
)
def unreliable_task():
"""Задача, которая может завершиться с ошибкой"""
# В реальном сценарии это может быть задача, зависящая от внешних факторов
# Для учебных целей случайным образом генерируем ошибку
if random.random() < 0.3: # 30% вероятность ошибки
print("Ошибка: задача не выполнена успешно!")
raise Exception("Случайная ошибка в задаче")
print("Задача выполнена успешно!")
return "Задача выполнена"
def success_handler():
"""Обработчик успешного выполнения"""
print("Поздравляем! Все задачи выполнены успешно!")
return "Успешно завершено"
def failure_handler():
"""Обработчик ошибок"""
print("Одна или несколько задач завершились с ошибкой!")
print("Проверьте логи для получения дополнительной информации")
return "Ошибка обработана"
def retry_task():
"""Задача с механизмом повторных попыток"""
# Имитируем задачу, которая может завершиться с ошибкой, но со временем исправляется
import time
time.sleep(2) # Имитация работы
# С вероятностью 50% задача завершится с ошибкой
if random.random() < 0.5:
print("Ошибка в retry_task!")
raise Exception("Ошибка в задаче с повторными попытками")
print("retry_task выполнена успешно!")
return "retry_task завершена"
# Определение задач
start_task = DummyOperator(
task_id='start_task',
dag=dag
)
unreliable_task = PythonOperator(
task_id='unreliable_task',
python_callable=unreliable_task,
dag=dag
)
retry_task = PythonOperator(
task_id='retry_task',
python_callable=retry_task,
dag=dag
)
success_handler_task = PythonOperator(
task_id='success_handler',
python_callable=success_handler,
trigger_rule='all_success', # Выполняется только если все предыдущие задачи успешны
dag=dag
)
failure_handler_task = PythonOperator(
task_id='failure_handler',
python_callable=failure_handler,
trigger_rule='one_failed', # Выполняется если хотя бы одна предыдущая задача завершилась с ошибкой
dag=dag
)
end_task = DummyOperator(
task_id='end_task',
dag=dag
)
# Установка зависимостей
# Используем chain, чтобы наглядно показать ученикам построение ветвящихся зависимостей без ручного перечисления операторов.
chain(start_task, [unreliable_task, retry_task], [success_handler_task, failure_handler_task], end_task)
default_args={
"owner": "student",
"retries": 1,
"retry_delay": timedelta(seconds=10),
},
tags=["educational", "errors"],
) as dag:
start_task = EmptyOperator(task_id="start_task")
unreliable_task = PythonOperator(
task_id="unreliable_task", python_callable=run_first_task,
)
retry_task = PythonOperator(
task_id="retry_task", python_callable=run_retry_task,
)
success_handler_task = PythonOperator(
task_id="success_handler",
python_callable=handle_success,
trigger_rule="all_success",
retries=0,
)
failure_handler_task = PythonOperator(
task_id="failure_handler",
python_callable=handle_failure,
trigger_rule="one_failed",
retries=0,
)
start_task >> [unreliable_task, retry_task]
[unreliable_task, retry_task] >> success_handler_task
[unreliable_task, retry_task] >> failure_handler_task
# Оба обработчика завершающие. При отказе success_handler получает
# upstream_failed, поэтому успешное уведомление не скрывает ошибку DAG Run.
@@ -0,0 +1,61 @@
"""Пулы и XCom на двух задачах с наблюдаемой длительностью.
До запуска создайте пул training_pool с двумя слотами.
Чтение источников имитируется; подключений и входных файлов не требуется.
"""
import logging
import time
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
def read_source(source, rows):
"""Имитирует чтение источника в течение 20 секунд."""
logging.info("Начало чтения %s", source)
time.sleep(20)
logging.info("Чтение %s завершено: %s строк (имитация)", source, rows)
return rows
def calculate_metrics(ti):
"""Получает небольшие результаты двух задач через XCom."""
customers = ti.xcom_pull(task_ids="read_customers")
orders = ti.xcom_pull(task_ids="read_orders")
return {"customers": customers, "orders": orders}
def log_metrics(ti):
"""Печатает словарь, возвращенный предыдущей задачей."""
logging.info("Метрики: %s", ti.xcom_pull(task_ids="calculate_metrics"))
with DAG(
"resource_management_dag",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
default_args={"owner": "student", "retries": 0},
tags=["educational", "pools", "xcom"],
) as dag:
customers_task = PythonOperator(
task_id="read_customers",
python_callable=read_source,
op_kwargs={"source": "customers", "rows": 20},
pool="training_pool",
pool_slots=2,
)
orders_task = PythonOperator(
task_id="read_orders",
python_callable=read_source,
op_kwargs={"source": "orders", "rows": 30},
pool="training_pool",
pool_slots=1,
)
metrics_task = PythonOperator(
task_id="calculate_metrics", python_callable=calculate_metrics,
)
log_task = PythonOperator(task_id="log_metrics", python_callable=log_metrics)
[customers_task, orders_task] >> metrics_task >> log_task
+3 -3
View File
@@ -4,7 +4,6 @@ DAG для демонстрации работы с SQL в Airflow
"""
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
# Определение DAG
@@ -76,11 +75,12 @@ query_data_task = PostgresOperator(
)
truncate_table_task = PostgresOperator(
task_id='drop_table',
task_id='truncate_table',
postgres_conn_id='postgres_training',
sql=truncate_table_sql,
dag=dag
)
# Установка зависимостей
create_table_task >> insert_data_task >> query_data_task >> truncate_table_task
# Очистка перед вставкой: после запуска в таблице остаются три строки.
create_table_task >> truncate_table_task >> insert_data_task >> query_data_task
-478
View File
@@ -1,478 +0,0 @@
# Учебные задания по Apache Airflow
Этот документ содержит практические задания для закрепления знаний по Apache Airflow. Каждое задание соответствует одному из учебных DAG'ов и направлено на лучшее понимание конкретных концепций.
## Общие инструкции
Перед выполнением заданий:
1. Убедитесь, что стенд Airflow запущен: `docker-compose up -d`
2. Проверьте доступность интерфейса: http://localhost:8080
3. Ознакомьтесь с соответствующим DAG'ом в интерфейсе Airflow
4. Откройте исходный код DAG'а и прочитайте описание в начале файла
---
## Задания для hello_world_dag.py
**Цель:** Освоить базовые концепции Airflow — создание задач, зависимости, операторы.
### Задание 1: Модификация существующих задач
**Сложность:** Начальная
**Задача:**
- Откройте файл `hello_world_dag.py`
- Измените текст в функциях `print_hello()`, `print_date()`, `print_goodbye()` на русский язык
- Добавьте новую задачу, которая выводит текущую погоду (можно использовать фиктивные данные)
**Подсказка:**
- Новая задача создаётся по аналогии с `date_task` — нужна Python-функция и `PythonOperator` для неё
- Не забудьте встроить задачу в цепочку зависимостей (`>>`)
- После сохранения файла DAG подхватится автоматически — запустите его вручную через UI и проверьте логи
**Цель задания:** Понять структуру DAG, научиться добавлять и модифицировать задачи.
### Задание 2: Изменение расписания
**Сложность:** Начальная
**Задача:**
- Измените `schedule_interval` с ежедневного на еженедельное выполнение
- Добавьте параметр `max_active_runs` для ограничения одновременных запусков
- Проверьте изменения в интерфейсе Airflow
**Подсказка:**
- Расписание задаётся в параметрах `DAG(...)` — ищите `schedule_interval`
- Можно использовать `timedelta(weeks=1)` или cron-выражение (например, `'0 0 * * 1'`)
- `max_active_runs` — тоже параметр `DAG()`
**Цель задания:** Освоить настройку расписания и параметров выполнения DAG.
### Задание 3: Добавление BashOperator
**Сложность:** Средняя
**Задача:**
- Добавьте задачу с `BashOperator`, которая создает текстовый файл в папке `/opt/airflow/data/output/`
- Настройте зависимости так, чтобы эта задача выполнялась между `date_task` и `end_task`
- Убедитесь, что файл создается при каждом запуске DAG
**Подсказка:**
- Импорт `BashOperator` уже есть в файле — посмотрите шапку
- `BashOperator` принимает параметр `bash_command` со строкой shell-команды
- Для записи в файл подойдёт обычный `echo "текст" > /path/to/file.txt`
- Проверить результат можно: `docker-compose exec airflow-webserver cat /opt/airflow/data/output/<ваш_файл>`
**Цель задания:** Научиться работать с разными типами операторов.
---
## Задания для sql_basic_dag.py
**Цель:** Освоить работу с базами данных через Airflow.
### Задание 1: Расширение структуры таблицы
**Сложность:** Средняя
**Задача:**
- Добавьте в таблицу `students_sample` новые поля: `email`, `phone`, `registration_date`
- Модифицируйте SQL-запросы для работы с новой структурой
- Добавьте задачу для обновления существующих записей
**Подсказка:**
- Новые колонки добавляются в переменную `create_table_sql` — подберите подходящие типы (VARCHAR, DATE и т.д.)
- Не забудьте обновить `insert_data_sql` — количество значений в INSERT должно совпадать с количеством колонок
- Для задачи UPDATE создайте новую SQL-переменную и новый `PostgresOperator` по аналогии с существующими
- **Подводный камень:** `TRUNCATE TABLE` не изменяет структуру. Чтобы новые колонки появились, замените его на `DROP TABLE IF EXISTS`
**Цель задания:** Научиться работать с миграциями схемы базы данных.
### Задание 2: Создание отчетов
**Сложность:** Средняя
**Задача:**
- Добавьте задачу, которая создает сводный отчет по данным студентов
- Отчет должен содержать: количество студентов, средний возраст, распределение по возрасту
- Сохраните отчет в файл в папке `/opt/airflow/data/output/`
**Подсказка:**
- Здесь понадобится `PythonOperator`, а не `PostgresOperator` — потому что нужно получить результат SQL и записать в файл
- Для подключения к БД из Python-функции используйте `PostgresHook` (импорт: `from airflow.providers.postgres.hooks.postgres import PostgresHook`)
- У хука есть метод `get_conn()`, который возвращает обычное psycopg2-соединение — дальше работайте через `cursor.execute()` и `fetchone()`
- Задачу поставьте перед `drop_table` в цепочке зависимостей
**Цель задания:** Освоить создание аналитических отчетов в DAG'ах.
### Задание 3: Работа с соединениями
**Сложность:** Средняя
**Задача:**
- Создайте новое соединение к базе данных через Airflow UI
- Модифицируйте DAG для использования нового соединения
- Добавьте обработку ошибок подключения к БД
**Подсказка:**
- Соединения создаются в UI: Admin → Connections → "+"
- Параметры для нашей учебной БД: Host = `postgres-training`, Schema = `training`, Login = `student`, Password = `student`, Port = `5432`
- В DAG-файле соединение указывается через `postgres_conn_id` — замените значение на ваш новый Conn Id
- Для проверки ошибок попробуйте указать несуществующее соединение и посмотрите, что покажут логи
**Цель задания:** Научиться управлять соединениями с внешними системами.
---
## Задания для file_operations_dag.py
**Цель:** Освоить работу с файлами и данными в Airflow.
### Задание 1: Модификация генерации данных
**Сложность:** Средняя
**Задача:**
- Добавьте новые поля в генерируемые данные: `department`, `experience_years`, `education_level`
- Модифицируйте валидацию для проверки новых полей
- Добавьте фильтрацию данных по определенным критериям (например, опыт > 3 года)
**Подсказка:**
- Генерация данных происходит в функции `generate_sample_data()` — новые поля добавляются в словарь `data`
- Для `department` подойдёт `random.choice()` со списком отделов, для `experience_years` — привяжите к возрасту (не может быть больше `age - 18`)
- Валидация — в функции `read_and_validate_data()`: добавьте `assert` по аналогии с существующими
- Фильтрацию (pandas `df[df['experience_years'] > 3]`) можно добавить в `transform_data()` и сохранить отдельным файлом
**Цель задания:** Научиться работать с различными типами данных и валидацией.
### Задание 2: Создание дополнительных отчетов
**Сложность:** Средняя
**Задача:**
- Создайте отчет в формате JSON с детальной статистикой по отделам
- Добавьте визуализацию данных с помощью библиотеки matplotlib (сохранение графика в файл)
- Создайте сводку по зарплатам в разных возрастных категориях
**Подсказка:**
- Создайте новую функцию и новый `PythonOperator`, добавьте в цепочку после `summary_task`
- Для JSON-отчёта: загрузите processed_data.csv через pandas, сгруппируйте (`groupby`) и сохраните через `json.dump()`
- matplotlib может быть не установлен в контейнере — это нормально, сделайте эту часть необязательной (обработайте `ImportError`)
- Не забудьте `import pandas as pd` внутри функции (не в шапке файла!)
**Цель задания:** Освоить создание комплексных отчетов и визуализацию.
### Задание 3: Оптимизация обработки
**Сложность:** Продвинутая
**Задача:**
- Разделите обработку данных на параллельные задачи для разных отделов
- Добавьте контроль качества данных (проверка на дубликаты, аномалии)
- Реализуйте механизм повторной обработки при ошибках
**Подсказка:**
- Параллельные задачи задаются списком: `read_task >> [task_a, task_b, task_c] >> summary_task`
- Каждая задача фильтрует DataFrame по своему отделу и сохраняет результат в отдельный файл
- Проверку дубликатов можно сделать через `df['id'].is_unique`
- Параметры `retries` и `retry_delay` можно задать как у отдельных задач, так и в `default_args`
**Цель задания:** Научиться оптимизировать и делать обработку данных отказоустойчивой.
---
## Задания для csv_to_postgres.py
**Цель:** Освоить паттерны загрузки данных (ETL) из файлов в базу данных и применение XCom.
### Задание 1: Расширение структуры данных
**Сложность:** Начальная
**Задача:**
- Добавьте в генератор CSV новую колонку `status` (например, со случайными значениями 'NEW', 'PROCESSING', 'COMPLETED').
- Обновите функцию `_create_table`, чтобы учесть новую колонку.
- Запустите DAG и проверьте, что данные успешно загрузились с новой колонкой.
- Убедитесь, что сгенерированный файл появился в каталоге `/opt/airflow/data/output/`.
**Подсказка:**
- Новая колонка добавляется в трёх местах: генерация (`_generate_csv`), DDL (`_create_table`), загрузка (`_load_csv` — список колонок в COPY)
- В `_generate_csv()` для случайных значений подойдёт `random.choices(['NEW', 'PROCESSING', 'COMPLETED'], k=rows)`
- **Подводный камень:** если таблица уже существует со старой схемой, новая колонка не появится. Удалите таблицу вручную или добавьте `DROP TABLE` перед `CREATE TABLE`
**Цель задания:** Понять процесс изменения схемы данных на всех этапах пайплайна.
### Задание 2: Использование PostgresOperator
**Сложность:** Средняя
**Задача:**
- Перепишите задачу `create_orders_table`. Сейчас она использует `PythonOperator` и `PostgresHook` внутри Python-функции.
- Замените её на использование стандартного `PostgresOperator`, используя соединение `postgres_training`.
- Убедитесь, что пайплайн продолжает работать корректно.
**Подсказка:**
- Посмотрите, как устроен `sql_basic_dag.py` — там `PostgresOperator` уже используется, можно взять за образец
- `PostgresOperator` принимает `postgres_conn_id` и `sql` — SQL можно передать прямо строкой
- После замены проверьте, не осталось ли ссылок на удалённую функцию. `_get_conn()` всё ещё нужна в `_load_csv()`
**Цель задания:** Научиться использовать специализированные операторы для работы с БД вместо кастомного Python-кода.
---
## Задания для csv_to_postgres_dq.py
**Цель:** Освоить подходы к обеспечению качества данных (Data Quality) в Airflow.
### Задание 1: Новая проверка качества
**Сложность:** Средняя
**Задача:**
- Добавьте новую функцию проверки прямо в `csv_to_postgres_dq.py`, которая будет убеждаться, что все значения в колонке `amount` строго больше нуля.
- Добавьте вызов этой функции как новую задачу в DAG `csv_to_postgres_dq`.
- Встройте новую задачу в общую цепочку выполнения (например, перед `data_quality_summary`).
**Подсказка:**
- Возьмите за образец любую из существующих функций проверки (например, `_check_has_rows`) — паттерн одинаковый: подключиться, выполнить SQL, проверить результат, бросить `ValueError` если не ок
- SQL для проверки: `SELECT COUNT(*) FROM public.orders WHERE amount <= 0`
- Не забудьте создать `PythonOperator` и добавить задачу в цепочку
**Цель задания:** Научиться расширять набор проверок качества данных.
### Задание 2: Управление статусом при ошибках (Trigger Rules)
**Сложность:** Продвинутая
**Задача:**
- Смоделируйте ошибку (например, временно измените данные так, чтобы проверки не прошли).
- По умолчанию, если падает одна проверка, следующие не выполняются (поведение `all_success`).
- Измените параметры задач так (с помощью `trigger_rule`), чтобы выполнялись *все* проверки, даже если некоторые из них упали.
- Сделайте так, чтобы задача `data_quality_summary` могла анализировать статусы предыдущих задач и отражать общий итог.
**Подсказка:**
- Для моделирования ошибки: измените `EXPECTED_ORDERS_SCHEMA` так, чтобы схема не совпала. Запустите — увидите, что все задачи после упавшей будут skipped
- Ключевой параметр — `trigger_rule` у `PythonOperator`. Значение `"all_done"` означает: «запустись в любом случае, когда все upstream завершились (успешно или нет)»
- Для анализа статусов в `dq_summary`: функция может принимать `**context` и через `context['ti']` получить информацию о статусах предыдущих задач
- После экспериментов не забудьте вернуть `EXPECTED_ORDERS_SCHEMA` к правильным значениям
**Цель задания:** Освоить продвинутую маршрутизацию статусов задач с помощью `trigger_rule`.
---
## Задания для data_processing_dag.py
**Цель:** Освоить ETL процессы и работу с бизнес-логикой.
### Задание 1: Расширение ETL пайплайна
**Сложность:** Средняя
**Задача:**
- Добавьте новый источник данных — файл с информацией о продуктах
- Создайте задачу для объединения данных о заказах с информацией о продуктах
- Добавьте расчет общей выручки по продуктам
**Подсказка:**
- В `create_sample_data()` добавьте генерацию ещё одного CSV (products.csv) с колонками `product`, `category`, `weight_kg` — значения product должны совпадать с теми, что уже есть в orders
- Создайте функцию `extract_products()` по аналогии с `extract_customers()`
- Для объединения в `transform_data()` используйте `pd.merge()` по ключу `product`
- Новую extract-задачу поставьте параллельно с существующими: `create_data_task >> [extract_customers_task, extract_orders_task, extract_products_task]`
**Цель задания:** Научиться работать с множественными источниками данных.
### Задание 2: Расширение текстового отчёта
**Сложность:** Средняя
**Задача:**
- Расширьте функцию `generate_report()` так, чтобы отчёт содержал больше полезной статистики
- Добавьте в отчёт: топ-3 самых дорогих заказа, распределение заказов по месяцам, среднюю сумму заказа по каждому клиенту
- Сохраните расширенный отчёт в `/opt/airflow/data/output/detailed_report.txt`
**Подсказка:**
- Всё делается в существующей функции `generate_report()` — дополните её
- Полезные методы pandas: `df.nlargest()`, `df.groupby(...).mean()`, `df.groupby(...).sum()`
- Можно записать результат в тот же файл или создать отдельный
**Цель задания:** Освоить создание аналитических отчётов с помощью pandas.
### Задание 3: Мониторинг качества данных
**Сложность:** Продвинутая
**Задача:**
- Добавьте проверки качества данных на каждом этапе ETL
- Создайте механизм оповещения о проблемах с данными
- Реализуйте архивирование обработанных данных
**Подсказка:**
- Создайте отдельную функцию валидации (проверка: строки > 0, нет NULL в ключевых полях, суммы положительные) и поставьте её между extract и transform
- Для оповещения используйте `on_failure_callback` в `default_args` — это функция, которая вызывается при падении любой задачи. Она получает `context` с информацией об ошибке
- Для архивирования: скопируйте результат в файл с датой в имени (например, `shutil.copy()` + `datetime.now().strftime(...)`)
**Цель задания:** Научиться обеспечивать качество данных в ETL процессах.
---
## Задания для branching_dag.py
**Цель:** Освоить условную логику и ветвление в Airflow.
### Задание 1: Модификация условий ветвления
**Сложность:** Средняя
**Задача:**
- Измените условие в функции `check_data_quality()` на основе реальных критериев (например, размер файла)
- Добавьте третью ветку обработки для данных "требующих ручной проверки"
- Настройте разные триггерные правила для слияния веток
**Подсказка:**
- Сейчас функция выбирает ветку случайно — замените `random.random()` на проверку чего-то реального (например, `os.path.getsize()` для размера файла)
- Функция `BranchPythonOperator` возвращает `task_id` ветки для выполнения. Для третьей ветки — верните третий `task_id`
- У `merge_task` проверьте `trigger_rule` — при ветвлении непройденные ветки получают статус `skipped`
**Цель задания:** Научиться создавать сложные условия ветвления.
### Задание 2: Реализация реального сценария
**Сложность:** Продвинутая
**Задача:**
- Создайте реальные задачи обработки для CSV и JSON форматов
- Добавьте валидацию данных в каждой ветке
- Реализуйте механизм сравнения результатов из разных веток
**Подсказка:**
- Вместо `print("Обработка CSV...")` загрузите реальный файл (например, `sample_data.csv`) и посчитайте статистику
- Результат каждой ветки сохраните в отдельный файл — в `merge_results()` загрузите оба (если существуют) и сравните
- Учтите, что при ветвлении выполняется только одна ветка — функция слияния должна обрабатывать случай, когда один из файлов отсутствует
**Цель задания:** Применить ветвление в реальном сценарии обработки данных.
### Задание 3: Динамическое ветвление
**Сложность:** Продвинутая
**Задача:**
- Реализуйте ветвление на основе внешних параметров (например, переданных через Variables)
- Добавьте обработку случая, когда ни одна ветка не подходит
- Создайте механизм логирования выбранного пути выполнения
**Подсказка:**
- Variables создаются в UI: Admin → Variables. Для чтения в коде: `Variable.get('key', default_var='значение')`
- Для случая «ни одна ветка не подходит» добавьте `DummyOperator` как ветку-заглушку
- `BranchPythonOperator` должен всегда возвращать существующий `task_id` — иначе DAG упадёт с ошибкой
**Цель задания:** Освоить динамическое принятие решений в DAG'ах.
---
## Задания для error_handling_dag.py
**Цель:** Освоить обработку ошибок и создание отказоустойчивых пайплайнов.
### Задание 1: Настройка стратегий повторения
**Сложность:** Средняя
**Задача:**
- Измените параметры `retries` и `retry_delay` для разных задач
- Добавьте экспоненциальную задержку между повторными попытками
- Реализуйте кастомный обработчик ошибок для конкретных исключений
**Подсказка:**
- Параметры повторения можно задать на уровне задачи (перекроют `default_args`): `retries`, `retry_delay`, `retry_exponential_backoff`, `max_retry_delay`
- Для кастомного обработчика: параметр `on_retry_callback` принимает функцию с аргументом `context`, из которого можно получить номер попытки через `context['ti'].try_number`
- Запустите DAG несколько раз и посмотрите в логах, как меняется задержка между попытками
**Цель задания:** Научиться настраивать стратегии обработки ошибок.
### Задание 2: Создание комплексной обработки ошибок
**Сложность:** Продвинутая
**Задача:**
- Добавьте задачи для разных типов ошибок (сетевая ошибка, ошибка данных, системная ошибка)
- Создайте механизм эскалации ошибок (после N неудачных попыток)
- Реализуйте отправку уведомлений о критических ошибках
**Подсказка:**
- Создайте функции по аналогии с `unreliable_task()`, но бросающие разные типы исключений: `ConnectionError`, `ValueError`, `OSError`
- Для эскалации используйте `on_failure_callback` — внутри проверьте `context['ti'].try_number` и при достижении порога выведите сообщение уровня CRITICAL
- Пример структуры callback:
```python
def escalation_callback(context):
if context['ti'].try_number >= 3:
# сюда — логику эскалации
```
**Цель задания:** Освоить создание комплексной системы обработки ошибок.
### Задание 3: Добавление логирования через модуль logging
**Сложность:** Средняя
**Задача:**
- Замените все `print()` в функциях DAG'а на вызовы стандартного модуля `logging`
- Добавьте в каждую функцию логирование начала и окончания выполнения
- Посмотрите, как логи отображаются в интерфейсе Airflow (вкладка Log у каждой задачи)
**Подсказка:**
- Стандартный паттерн: `import logging` в начале файла, затем `log = logging.getLogger(__name__)` в функции
- Уровни: `log.info()` для штатных событий, `log.warning()` для предупреждений, `log.error()` для ошибок
- В UI Airflow откройте выполненную задачу → вкладка **Log** — сообщения `logging` отображаются с метками уровня и временем, в отличие от `print()`
**Цель задания:** Научиться использовать стандартное логирование Python в задачах Airflow и читать логи через UI.
---
## Дополнительные задания: пулы, XCom, TaskGroup и алертинг
**Цель:** Освоить продвинутые возможности оркестрации — управление ресурсами, обмен данными между задачами, группировку и оповещения.
### Задание 1: Пулы и управление ресурсами
**Сложность:** Средняя
**Задача:**
- В интерфейсе Airflow создайте пул `backup_pool` с 2 слотами
- Создайте новый DAG `advanced_features_dag.py` **или** расширьте `data_processing_dag.py` задачами резервного копирования (например, `backup_to_csv`, `backup_to_db`)
- Назначьте этим задачам параметр `pool="backup_pool"` и настройте `pool_slots` так, чтобы одна из задач занимала 2 слота, а другая — 1
- Наблюдайте в UI, что одновременно запускается не более 2 задач из этого пула
**Подсказка:**
- Пул создаётся в UI: Admin → Pools → "+"
- У `PythonOperator` есть параметры `pool` (имя пула) и `pool_slots` (сколько слотов занимает задача)
- Если задача занимает 2 слота из 2 доступных — вторая задача будет ждать, даже если они не связаны зависимостями
**Цель задания:** Научиться управлять параллелизмом задач через пулы и `pool_slots`.
### Задание 2: Обмен данными через XCom
**Сложность:** Средняя
**Задача:**
- В том же DAG добавьте задачу `calculate_metrics` (PythonOperator), которая возвращает словарь с агрегированными показателями, например: `{"total_orders": ..., "avg_amount": ...}`
- Добавьте задачу `log_metrics`, которая с помощью `xcom_pull` читает результат `calculate_metrics` и выводит значения в лог
- Для одной из задач продемонстрируйте использование XCom в Jinja-шаблоне (например, в `bash_command` или SQL-запросе)
**Подсказка:**
- Любое значение, которое функция возвращает через `return`, автоматически сохраняется в XCom
- Для чтения XCom в другой задаче: функция принимает `**context`, затем `context['ti'].xcom_pull(task_ids='имя_задачи')`
- В Jinja-шаблонах (параметры `bash_command`, `sql` и др.) XCom доступен через `{{ ti.xcom_pull(task_ids='...') }}`
- Посмотреть сохранённые XCom-значения можно в UI: откройте задачу → вкладка **XCom**
**Цель задания:** Освоить передачу результатов между задачами через XCom и их использование в шаблонах.
### Задание 3: TaskGroup и алертинг
**Сложность:** Продвинутая
**Задача:**
- Объедините логически связанные задачи (например, `extract` / `transform` / `load`) в `TaskGroup`'ы
- Добавьте завершающую задачу `send_notification` на основе примера из раздела про алертинг (PythonOperator с `send_email_smtp` или другим механизмом уведомлений)
- Настройте для задачи уведомления `trigger_rule=TriggerRule.ALL_DONE`, чтобы уведомление отправлялось даже при частичных ошибках
- При желании вынесите функцию отправки письма в отдельный модуль `utils.py` и импортируйте её в DAG
**Подсказка:**
- TaskGroup — контекстный менеджер: `with TaskGroup("имя") as group:` — внутри определяете задачи как обычно
- Зависимости работают на уровне групп: `group_a >> group_b`
- `TriggerRule.ALL_DONE` (из `airflow.utils.trigger_rule`) означает: «запустись, когда все upstream завершились — неважно, с успехом или ошибкой»
- В Graph View группы отображаются как складные блоки — удобно для больших DAG'ов
**Цель задания:** Научиться группировать задачи с помощью TaskGroup и строить схему оповещений о статусе пайплайна.
---
## Рекомендации по выполнению
1. **Начинайте с простых заданий** и постепенно переходите к сложным
2. **Тестируйте каждое изменение** через интерфейс Airflow
3. **Изучайте логи выполнения** для понимания поведения задач (вкладка Log у каждой задачи в UI)
4. **Экспериментируйте** с разными настройками и параметрами
5. **Не бойтесь ломать** — DAG'и можно откатить через `git checkout`, а таблицы пересоздать
## Оценка прогресса
- **Начальный уровень:** Выполнены задания для hello_world_dag и sql_basic_dag
- **Средний уровень:** Выполнены задания для file_operations_dag, csv_to_postgres.py, csv_to_postgres_dq.py и data_processing_dag
- **Продвинутый уровень:** Выполнены все задания, включая branching_dag, error_handling_dag и дополнительные задания по пулам, XCom, TaskGroup и алертингу
Удачи в изучении Apache Airflow!
@@ -0,0 +1,153 @@
# План обновления практики по Airflow
Дата: 2026-09-28. Подход согласован с автором курса. Реализация завершена. Результаты и границы проверки сохранены в [протоколе](../validation/2026-09-28-practice.md).
Этот документ сохраняет решения, порядок доработки и результаты проверки. Текущие задания находятся в [educational-tasks.md](../../educational-tasks.md), запуск стенда описан в [README](../../airflow-docker/README.md).
## Цель и границы
Стенд знакомит студентов с Apache Airflow и входит в [программу Data Engineering](https://de.dementev.space/). Перед ним студенты проходят Python, pandas, SQL и Docker. Самостоятельное построение цельных процессов обработки данных относится к последующим курсовым работам.
Результат этого блока: студент умеет запустить готовый DAG, найти его граф и логи, внести небольшое изменение по образцу и объяснить наблюдаемое поведение. Практика остается набором небольших экспериментов с инструментом.
Обновление сохраняет ознакомительный формат и версию Airflow 2.9.x. Основной маршрут выполняется на существующем стенде; внешние интеграции относятся к заданиям по желанию. Учебная имитация отдельных операций допустима, если описание и логи честно называют ее имитацией.
## Порядок работы
Перед продолжением сверить чек-лист с текущими файлами и историей Git. Прочитать согласованные решения ниже, затем выполнить первый незавершенный этап. Локальные решения в этих границах принимать и записывать в рабочей таблице; противоречия с согласованной целью выносить на обсуждение.
- [x] Зафиксировать подход и находки в этом документе; добавить ссылки из README стенда и AGENTS.md.
- [x] **1. Разобрать все исходные задания.** Создать здесь рабочую таблицу с колонками: исходное задание (DAG/раздел и номер), изучаемая возможность, исходное состояние и зависимости, решение с обоснованием, место в будущем маршруте, наблюдаемый результат. Решения: оставить основным, разделить, перенести в дополнительные или переформулировать. Исходный набор - 25 заданий в `educational-tasks.md` на коммите `13e3102`; после переработки число упражнений может измениться. **Готово:** учтены 25 из 25 исходных заданий, у каждой части разделенного задания определена обязательность, а все зависимости основного маршрута обеспечены без выполнения дополнительных упражнений. Сопоставление с главами учебника объясняет выбор тем; новые темы добавляются только при выявленной необходимости для цели блока.
- [x] **2. Проверить и исправить несогласованности.** Сверить каждую строку раздела "Найденные несогласованности и препятствия" с актуальным кодом. Для каждой записать итог: исправлено, не подтвердилось с объяснением или оставлено как явно описанный учебный сценарий. **Готово:** все находки разобраны; подтвержденные ошибки устранены, измененное поведение проверено на версии стенда. Невыполненная проверка остается открытой.
- [x] **3. Переработать показательный блок обработки ошибок.** Подготовить основной управляемый эксперимент с повтором, наблюдаемые результаты и отдельное усложнение по желанию. **Готово:** исходное состояние и возврат к нему описаны; воспроизведены успех после повтора и окончательная ошибка; логи, состояния задач и итог DAG Run совпадают с описанием. Этот блок служит образцом объема и оформления остальных упражнений.
- [x] **4. Переработать остальные блоки.** Применить выбранное распределение и образец к остальным заданиям. **Готово:** каждая строка рабочей таблицы ссылается на итоговое упражнение или содержит причину объединения; у каждого упражнения есть обязательность и проверяемый результат; подготовка и восстановление общих данных описаны там, где нужны.
- [x] **5. Согласовать навигацию.** Обновить маршрут и оценку прогресса в `educational-tasks.md`, корневом README и README стенда. Сверить `dag-specifications.md` с итоговым содержанием. **Готово:** ссылки ведут к актуальным упражнениям; документы согласованно описывают порядок и условия завершения; устаревшие прототипы и необоснованные оценки времени удалены или исправлены.
- [x] **6. Пройти маршрут двумя способами.** Выполнить только основную практику, затем дополнительные упражнения с переходами обратно к основной. **Готово:** обе последовательности пройдены в изолированном окружении; указанные результаты подтверждены в данных и UI; сохранены условия запусков, результаты и ограничения по правилам раздела "Как проверять результат доработки".
По мере выполнения обновлять рабочую таблицу и чек-лист. У закрытого этапа указывать ссылку на коммит или запись о проверке. Если часть проверок недоступна, записать причину и продолжать независимую работу; соответствующий этап остается незавершенным.
## Текущее состояние реализации
Исправления DAG сохранены в `cf1b770`, задания и навигация в `d8dfe83`, уточнения после прохождения в `1277bff`. В `f6b450c` задания перенесены в корень репозитория.
Все 25 исходных заданий сопоставлены с 34 упражнениями: 16 основных и 18 по желанию. Основной маршрут пройден без дополнительных упражнений. Затем проверены дополнительные расширения и возвраты к базовой схеме, режимам и настройкам. Сохранены [условия и результаты проверки](../validation/2026-09-28-practice.md), [состояния 70 контрольных и диагностических запусков](../validation/2026-09-28-practice-runs.jsonl) и снимки UI.
Открытых этапов реализации нет. При следующей правке сверять затронутые задания с кодом и протоколом. Проверка не измеряет трудность на студентах; SMTP проверен локальным приемником, без внешнего почтового провайдера.
После независимого педагогического ревью исправлены примеры в главах 2, 7 и 9 и уточнены ссылки на теорию перед практикой. Значимость находок, принятые решения и исполнение примеров записаны в [перепроверке ревью](../validation/2026-09-28-theory-review.md). Предложение расширить основную практику опытом с Clear оставлено до проверки на студентах; состав упражнений сохранен.
## Согласованные решения
1. **Последовательность внутри блока.** Студент запускает исходный пример, меняет одну существенную настройку или часть графа и наблюдает результат. Изменения основных заданий внутри блока сохраняются.
2. **Ясное начало каждого блока.** Указаны исходный DAG, необходимые файлы, таблицы и настройки, а также изменения, которые остаются от предыдущей работы. Блок определяется учебным смыслом: загрузка CSV и последующая DQ-проверка могут объединять два DAG.
3. **Основная практика и задания по желанию.** Обязательность отделена от сложности. Дополнительные задания расположены в конце своего блока; их результаты не нужны для продолжения основной практики. Пропуск дополнительного задания не препятствует завершению блока.
4. **Разделение смешанных заданий.** Если одно упражнение объединяет знакомство с возможностью Airflow и значительное усложнение, оно разделяется. Например, TaskGroup и настройка настоящей отправки писем не должны быть одним обязательным заданием.
5. **Наблюдаемый результат.** У каждого задания есть раздел "Что должно получиться": конкретное поведение в UI, сообщение в логах или результат в файле/таблице. Преднамеренная ошибка может быть правильным результатом упражнения.
6. **Управляемые учебные сценарии.** Основные примеры ветвления и повторных попыток позволяют гарантированно увидеть нужный случай. Случайность допустима в данных, если она не мешает наблюдать изучаемое поведение и проверять результат.
7. **Понятное завершение.** Основная практика дает достаточное знакомство для перехода дальше по программе. Выполнение всех дополнительных упражнений не является скрытым условием прохождения курса.
Дополнительное задание, меняющее общую таблицу или настройку, должно описывать возврат к состоянию, необходимому следующему блоку. Перенос задания в конец раздела сам по себе эту проблему не решает. Восстановление касается только объектов упражнения, без общего сброса стенда или чужих изменений.
## Как оформлять задания
Задания находятся в корне репозитория рядом с главами учебника: `educational-tasks.md`. В `airflow-docker/` остаются инструкция запуска, стенд и примеры DAG. Основные и дополнительные упражнения сохраняются в одном документе.
В начале блока: связь с главами учебника, необходимые исходные условия и короткий запуск готового примера. У задания:
- пометка "Основная практика" или "По желанию"; сложность указывается отдельно;
- конкретное изменение, позволяющее познакомиться с одной возможностью Airflow;
- подсказка по необходимости;
- "Что должно получиться" с указанием места проверки;
- для экспериментов с ошибками - способ вызвать ошибку и вернуть нормальное поведение;
- при необходимости один короткий вопрос о причине наблюдаемого результата.
Для ветвления и обработки ошибок проверяются статусы отдельных задач и всего DAG Run. Формулировка "всё зеленое" не подходит для сценария с ожидаемыми пропусками или ошибками. Проверка результата не должна требовать ожидания недельного расписания или неограниченного числа случайных запусков.
## Распределение содержания
Распределение уточнено после разбора 25 исходных заданий. Номера итоговых упражнений указаны в рабочей таблице ниже; правила прохождения находятся в разделе "Согласованные решения".
| Блок | Основная практика | Возможное усложнение по желанию |
|---|---|---|
| Первый DAG | Запуск, граф, логи, добавление задачи по образцу, зависимости, знакомство с расписанием | Дополнительные эксперименты с ограничениями запусков |
| SQL и Connections | Выполнение SQL, смена подключения, наблюдение результата и ошибки подключения | Дополнительные отчеты и расширение модели данных |
| Файлы и преобразования | Проследить путь файла между задачами, изменить простое преобразование или проверку | Новые поля, отчеты, обработка по отделам |
| CSV → PostgreSQL → DQ | Проследить загрузку, найти путь к файлу в XCom, добавить проверку, увидеть корректные и ошибочные данные | Расширение схемы с согласованным изменением загрузчика и DQ, общая сводка проверок |
| Пример ETL | Прочитать граф с параллельными задачами, найти промежуточные и итоговые результаты | Новый источник, расширенная аналитика, архивирование |
| Ветвление и Variables | Управляемо выбрать ветку, увидеть `skipped`, понять слияние веток | Третья ветка и полноценная обработка разных форматов |
| Ошибки и повторы | Вызвать ошибку, увидеть повтор и успешное завершение, найти причину в логах | Экспоненциальная задержка, разные исключения, эскалация |
| Пулы, XCom и TaskGroup | Ожидание слотов, новая метрика XCom, группировка существующих задач | XCom в Bash-шаблоне; настоящая отправка писем на примере с ошибками |
Чтение и изменение XCom не должно зависеть от выполнения дополнительного задания по резервному копированию. Для знакомства с пулами достаточно готовых задач с наблюдаемой длительностью; написание механизма резервного копирования не является целью такого эксперимента.
## Разбор 25 исходных заданий
Номера слева относятся к версии `13e3102`, номера справа - к новой редакции `educational-tasks.md`. Состав основной практики покрывает темы глав 4-9; главы 1-3 дают контекст перед запуском. Обработка по интервалам и самостоятельный проект остаются следующей ступенью программы.
| Исходное задание | Возможность | Вход и зависимости | Решение и причина | Новое место | Наблюдаемый результат |
|---|---|---|---|---|---|
| hello_world / 1 | PythonOperator, зависимости | Исходный DAG | Основное: небольшое изменение графа | [1.1](../../educational-tasks.md#first-dag) | Новая задача и ее сообщение видны в UI |
| hello_world / 2 | Расписание, ограничения запусков | После 1.1 | Разделить: расписание основное, конкуренция по желанию | [1.3, 1.A](../../educational-tasks.md#first-dag) | Видно расписание; второй запуск ожидает при лимите 1 |
| hello_world / 3 | BashOperator | После 1.1 | Основное: другой оператор и файл | [1.2](../../educational-tasks.md#first-dag) | Файл содержит ожидаемый текст |
| sql_basic / 1 | SQL в задаче, схема таблицы | Исходный SQL-DAG и training | Разделить: UPDATE основное, схема по желанию | [2.2, 2.A](../../educational-tasks.md#sql) | Возраст изменен; дополнительная колонка заполнена |
| sql_basic / 2 | PostgresHook и отчет | После 2.2, таблица с тремя строками | По желанию: дополнительная обработка | [2.B](../../educational-tasks.md#sql) | Файл: 3 студента, средний возраст 23.33 после UPDATE |
| sql_basic / 3 | Connections и диагностика | Учебная БД, собственный Conn Id | Основное; вместо неопределенного обработчика - найти и исправить ошибку подключения | [2.1](../../educational-tasks.md#sql) | Ошибка видна в логах, после исправления запуск успешен |
| file_operations / 1 | Генерация, валидация | Исходный файловый DAG | Разделить: простая трансформация основная, новые поля по желанию | [3.1, 3.A](../../educational-tasks.md#files) | 100 обработанных строк; поля и фильтр проверены отдельно |
| file_operations / 2 | Отчет по отделам | Требует 3.A | По желанию; график исключен, поскольку требует отдельной зависимости и не знакомит с новой возможностью Airflow | [3.B](../../educational-tasks.md#files) | Сумма численности отделов равна числу строк входа |
| file_operations / 3 | Параллельность, DQ, retries | Требует 3.A | По желанию; параллельность также видна в основном ETL-примере | [3.C](../../educational-tasks.md#files) | Раздельные файлы покрывают все отделы, сборка читает их |
| csv_to_postgres / 1 | Проведение поля через загрузку | После основной загрузки и DQ | По желанию: согласовать все четыре SQL/CSV-участка и проверку схемы | [4.A](../../educational-tasks.md#load-dq) | status заполнен; DQ проходит; исходная схема восстановлена |
| csv_to_postgres / 2 | Специализированный оператор | Базовая схема orders | По желанию: сравнение двух реализаций уже знакомого действия | [4.B](../../educational-tasks.md#load-dq) | После замены оператора загрузка и DQ успешны |
| csv_to_postgres_dq / 1 | Новая проверка, ошибка данных | Основная загрузка orders | Основное: положительный и отрицательный сценарии | [4.2](../../educational-tasks.md#load-dq) | Отрицательная сумма вызывает failed; восстановление возвращает success |
| csv_to_postgres_dq / 2 | Trigger Rules и итог DAG Run | После 4.2 | По желанию: запуск всех проверок и общая сводка | [4.C](../../educational-tasks.md#load-dq) | Все проверки завершаются, итог сохраняет ошибку |
| data_processing / 1 | Несколько источников | Исходный ETL, свои CSV | По желанию; основной разбор графа добавлен перед расширениями | [5.1, 5.A](../../educational-tasks.md#etl) | После JOIN остаются 6 заказов и выручка 2105 |
| data_processing / 2 | Отчет pandas | После основного запуска, products не нужен | По желанию | [5.B](../../educational-tasks.md#etl) | Топ заказов и средние сверяются с шестью исходными строками |
| data_processing / 3 | Валидация, callback, архив | После 5.1; поля продуктов необязательны | По желанию с конкретной ошибкой и восстановлением | [5.C](../../educational-tasks.md#etl) | При ошибке архив не создается; после исправления совпадает с результатом |
| branching / 1 | Выбор ветки и слияние | Исходный DAG, Variable branch_format | Разделить: две управляемые ветки основные, третья по желанию | [6.1, 6.A](../../educational-tasks.md#branching) | Выбранная ветка success, другая skipped, слияние success |
| branching / 2 | Работа выбранной ветки с данными | Два явно заданных входных файла | По желанию; использовать XCom текущего запуска вместо старых файлов другой ветки | [6.B](../../educational-tasks.md#branching) | Оба формата дают одинаковые агрегаты в отдельных запусках |
| branching / 3 | Variables, неизвестный параметр | После 6.1 | Variable включена в основное; fallback объединен с третьей веткой | [6.1, 6.A](../../educational-tasks.md#branching) | Неизвестное значение дает понятную ошибку либо review-ветку после расширения |
| error_handling / 1 | Повторы и задержка | Исходный DAG, Variable error_mode | Разделить: один управляемый повтор основной, backoff по желанию | [7.1, 7.2, 7.A](../../educational-tasks.md#errors) | Две попытки; с нулем повторов - failed |
| error_handling / 2 | Типы ошибок и оповещение | После 7.2 | По желанию; локальный callback без внешней почты | [7.B](../../educational-tasks.md#errors) | После исчерпания повторов есть сообщение с task_id и причиной |
| error_handling / 3 | logging | После 7.1 | Основное небольшое дополнение вместо механической замены всех print | [7.3](../../educational-tasks.md#errors) | Собственные сообщения видны в логах |
| Дополнительные / 1 | Пулы и слоты | Готовый resource_management_dag, пул из 2 слотов | Основной короткий эксперимент; резервное копирование заменено имитацией чтения | [8.1](../../educational-tasks.md#resources) | Задачи с весами 2 и 1 выполняются последовательно |
| Дополнительные / 2 | XCom и Jinja | После 8.1 | Основная метрика; шаблон Bash по желанию | [8.2, 8.A](../../educational-tasks.md#resources) | total_rows = 50, шаблон выводит то же значение |
| Дополнительные / 3 | TaskGroup и алертинг | После 8.2; для почты нужен SMTP | Разделить: группировка основная, внешняя доставка по желанию | [8.3, 8.B](../../educational-tasks.md#resources) | Группа раскрывается; XCom работает с полными task_id |
Основной маршрут сохраняет изменения внутри блоков. Общие данные есть только в паре загрузка/DQ; изменение схемы вынесено после основных опытов, с обратным ALTER и возвратом списков колонок. Задания 3.B и 3.C явно зависят от дополнительного 3.A, но дальнейшая основная практика от этой ветки не зависит. Variables возвращаются к исходным значениям перед переходом дальше.
## Найденные несогласованности и препятствия
Первичные находки подтвердились при чтении кода и выполнении. Ниже их итоговое состояние; подробные результаты приведены в [протоколе](../validation/2026-09-28-practice.md).
| Место | Исходная проблема | Итог |
|---|---|---|
| SQL | DROP после вставки не готовил существующую таблицу к новой колонке; результат очищался до проверки. | Исправлено: TRUNCATE перед вставкой, task_id `truncate_table`, три строки остаются в БД. В 2.A ALTER выполняется до запуска. Проверены UPDATE, email и отчет. |
| Загрузчик CSV | Подсказка про status пропускала INSERT ... SELECT. | Исправлено: перечислены четыре участка загрузчика и порядок полей CSV/COPY. Проверены значения статусов в БД, а не только наличие колонки. |
| Схема DQ | Дополнительная колонка конфликтовала с ожидаемыми четырьмя. | Исправлено: расширение идет после основного DQ; описаны обновление ожидания и возврат схемы. Оба состояния проверены. |
| Trigger Rules DQ | После отказа обещан skipped. | Исправлено на upstream_failed; подтверждены отказ проверки суммы и отказ схемы. |
| Обработчики ошибок | chain связывал списки попарно. | Исправлено: оба обработчика имеют обе рабочие задачи в непосредственных предшественниках. Проверены успех, повтор, отказ первой, второй и обеих задач, общий итог DAG Run. |
| Сводка DQ и алертинг | Единственная успешная завершающая задача с all_done могла скрыть ошибку; письмо безусловно сообщало об успехе. | Исправлено: сводка сверяет состояния и падает при неуспешной проверке. Уведомления назначены callbacks задачи и DAG. Проверены all_done, письма об отказе/успехе и сохранение результата при ошибке SMTP. |
| ETL | Transform обходил результаты Extract, имитация называлась загрузкой в БД. | Исправлен путь данных. Имитация сохранена и явно обозначена именем simulate_load и логом. Фильтрация Extract изменила отчет с 6/2105 на 4/2000. |
| Пулы | Формулировка "не более двух задач" не показывала ограничение по весам. | Добавлен готовый пример: в пуле из двух слотов веса 2+1 выполняются последовательно, 1+1 пересекаются по времени. Проверены оба варианта. |
| Ветвление, повторы и зависимости | Случайность мешала наблюдать нужный случай; использовались неопределенные входы и результаты старых запусков. | Исправлено: Variables управляют режимами; входы CSV/JSON заданы; слияние читает XCom текущего запуска; зависимости дополнительных упражнений указаны явно. |
| Логи callbacks | Первоначальная инструкция отправляла все сообщения в docker compose logs scheduler. | Уточнено после выполнения: callback оператора виден в последней попытке задачи, callback DAG в файле scheduler. Оба места проверены. |
Технические источники: [chain](https://airflow.apache.org/docs/apache-airflow/2.9.2/_api/airflow/models/baseoperator/index.html#airflow.models.baseoperator.chain), [статусы задач](https://airflow.apache.org/docs/apache-airflow/2.9.2/core-concepts/tasks.html#task-instances), [итог DAG Run](https://airflow.apache.org/docs/apache-airflow/2.9.2/core-concepts/dag-run.html#dag-run-status), [пулы](https://airflow.apache.org/docs/apache-airflow/2.9.2/administration-and-deployment/pools.html), [COPY в PostgreSQL 16](https://www.postgresql.org/docs/16/sql-copy.html). Условия поиска логов уточнены по фактическому стенду 2.9.2.
## Как проверять результат доработки
Результаты проверок сохранять в этом документе рядом с закрываемым этапом: проверенная версия/коммит, сценарий и исходное состояние, ожидаемый и фактический результат, способ проверки. Для длинных логов или снимков экрана достаточно ссылки на доступный артефакт. Версию Airflow сверять с фактическим окружением, а не только с тегом образа в Dockerfile.
Проверки разделяются по уровню доказательств:
- **Документы и исходники:** все 25 исходных заданий учтены, ссылки работают, имена файлов/задач/таблиц совпадают, обязательность и зависимости согласованы.
- **Стенд:** подтверждены версия Airflow и загрузка DAG без ошибок импорта; выполнены затронутые сценарии и проверены файлы/таблицы. Проверка синтаксиса Python не заменяет выполнение задач.
- **UI:** студент действительно может найти указанные состояния, логи, XCom, граф и настройки; показанные результаты совпадают с текстом.
- **Прохождение:** отдельно проверены основной маршрут и переходы после дополнительных заданий. Не выполненные проверки так и отмечаются; предполагаемое время не выдается за измеренное на студентах.
Эксперименты с изменением данных выполняются в изолированном учебном окружении. Проверка не должна затрагивать другие работающие стенды или несвязанные изменения пользователя.
## Почему выбран этот формат
Одни метки сложности не разделяют смешанные упражнения и не объясняют условия продолжения. Полностью независимые задания увеличивают повторную подготовку. Отдельный сборник усложнений разрывает связь с исходным примером. Демонстрации без изменения кода дают меньше практики, а самостоятельный большой пайплайн дублирует цель последующих курсовых.
Выбранный формат сохраняет практику изменения кода и позволяет переходить к следующему блоку после знакомства с нужными возможностями Airflow.
@@ -0,0 +1,71 @@
{"type": "environment", "date": "2026-09-28", "airflow": "2.9.2", "python": "3.12.4", "executor": "LocalExecutor", "required_scenarios": 69, "recorded_runs": 70, "final_source_sha256": {"branching_dag.py": "873f00e26ede268582245aac0ec3b82f858a3654fb7a4e1e08eb6e68ec5d031a", "csv_to_postgres.py": "43452b5f639365be07174e417fa981116b478e90acaeffb184e530d9ae67cb3b", "csv_to_postgres_dq.py": "05e43bc4bdab64813d7a853fee9285c11cb0e82a077c33a641219ca47b4604af", "data_processing_dag.py": "df39a91f89ff2e572d0a101b52171b9af97fb4abc7a66fc90c136425acb64b89", "error_handling_dag.py": "914e00b5845afa82c08aa540501a9994825b9b48a375bcc65ec59ae87b2cc23f", "file_operations_dag.py": "c4b5969a296af94c61cd63a9511a5572f9b858cbd75be913e8dffd015441b48f", "hello_world_dag.py": "09036daee495267a9dc23fd9c04fbd99c50b76194be1f3004822cee87f7797e7", "resource_management_dag.py": "03774dbfe5cadc6d6f036814f4fb2f8db0991c511b7ea3e877f577dca644b13e", "sql_basic_dag.py": "d028ff3d281ad322e8443447f43000497db67053cfeb757dc23b21b78f8b536f"}}
{"label": "base_branching_dag", "dag": "branching_dag", "run_id": "base_branching_dag__1790598303491558368", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:25:04.466969+00:00", "end": "2026-09-28T12:25:04.466989+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:06.300063+00:00", "end": "2026-09-28T12:25:07.740076+00:00"}, "process_csv_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:09.192486+00:00", "end": "2026-09-28T12:25:09.736934+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:25:11.926795+00:00", "end": "2026-09-28T12:25:11.926806+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:25:07.614511+00:00", "end": "2026-09-28T12:25:07.614511+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:10.695251+00:00", "end": "2026-09-28T12:25:11.211111+00:00"}}}
{"label": "base_csv_to_postgres", "dag": "csv_to_postgres", "run_id": "base_csv_to_postgres__1790598316856917563", "state": "success", "expected_state": "success", "tasks": {"create_orders_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:17.703222+00:00", "end": "2026-09-28T12:25:18.385307+00:00"}, "generate_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:19.297092+00:00", "end": "2026-09-28T12:25:20.954830+00:00"}, "preview_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:21.691404+00:00", "end": "2026-09-28T12:25:23.672198+00:00"}, "load_csv_to_postgres": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:24.349364+00:00", "end": "2026-09-28T12:25:25.132534+00:00"}}}
{"label": "base_data_processing_dag", "dag": "data_processing_dag", "run_id": "base_data_processing_dag__1790598297048939564", "state": "success", "expected_state": "success", "tasks": {"create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:58.018194+00:00", "end": "2026-09-28T12:24:59.516887+00:00"}, "extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:00.524479+00:00", "end": "2026-09-28T12:25:04.438975+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:00.319521+00:00", "end": "2026-09-28T12:25:04.581621+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:06.641381+00:00", "end": "2026-09-28T12:25:08.648080+00:00"}, "simulate_load": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:10.564728+00:00", "end": "2026-09-28T12:25:11.970738+00:00"}, "generate_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:13.716924+00:00", "end": "2026-09-28T12:25:15.365893+00:00"}}}
{"label": "base_dq", "dag": "csv_to_postgres_dq", "run_id": "base_dq__1790598363500865461", "state": "success", "expected_state": "success", "tasks": {"data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:09.667195+00:00", "end": "2026-09-28T12:26:10.149648+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:05.724275+00:00", "end": "2026-09-28T12:26:06.206894+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:06.888967+00:00", "end": "2026-09-28T12:26:07.337686+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:08.530504+00:00", "end": "2026-09-28T12:26:09.010861+00:00"}, "check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:04.557178+00:00", "end": "2026-09-28T12:26:05.087473+00:00"}}}
{"label": "base_error_handling_dag", "dag": "error_handling_dag", "run_id": "base_error_handling_dag__1790598307068800147", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:25:08.688153+00:00", "end": "2026-09-28T12:25:08.688162+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:09.327897+00:00", "end": "2026-09-28T12:25:09.842635+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:21.859653+00:00", "end": "2026-09-28T12:25:22.425907+00:00"}, "retry_task": {"state": "success", "attempts": 2, "start": "2026-09-28T12:25:20.608453+00:00", "end": "2026-09-28T12:25:21.211328+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:25:21.289459+00:00", "end": "2026-09-28T12:25:21.289459+00:00"}}}
{"label": "base_file_operations_dag", "dag": "file_operations_dag", "run_id": "base_file_operations_dag__1790598285829374474", "state": "success", "expected_state": "success", "tasks": {"generate_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:47.788802+00:00", "end": "2026-09-28T12:24:52.901884+00:00"}, "read_and_validate_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:54.105194+00:00", "end": "2026-09-28T12:24:56.336898+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:57.980925+00:00", "end": "2026-09-28T12:24:59.453129+00:00"}, "write_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:00.578118+00:00", "end": "2026-09-28T12:25:05.022890+00:00"}}}
{"label": "base_hello_world_dag", "dag": "hello_world_dag", "run_id": "base_hello_world_dag__1790598285331279953", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:47.450226+00:00", "end": "2026-09-28T12:24:48.038258+00:00"}, "date_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:50.252850+00:00", "end": "2026-09-28T12:24:50.964908+00:00"}, "end_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:54.481818+00:00", "end": "2026-09-28T12:24:55.586281+00:00"}}}
{"label": "base_resource_management_dag", "dag": "resource_management_dag", "run_id": "base_resource_management_dag__1790598315287681546", "state": "success", "expected_state": "success", "tasks": {"read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:16.416179+00:00", "end": "2026-09-28T12:25:36.854962+00:00"}, "read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:38.182441+00:00", "end": "2026-09-28T12:25:58.742686+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:25:59.666739+00:00", "end": "2026-09-28T12:26:00.315747+00:00"}, "log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:26:00.826752+00:00", "end": "2026-09-28T12:26:01.327392+00:00"}}}
{"label": "base_sql_basic_dag", "dag": "sql_basic_dag", "run_id": "base_sql_basic_dag__1790598285733066584", "state": "success", "expected_state": "success", "tasks": {"create_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:47.558668+00:00", "end": "2026-09-28T12:24:48.171774+00:00"}, "truncate_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:50.270342+00:00", "end": "2026-09-28T12:24:50.925384+00:00"}, "insert_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:54.165693+00:00", "end": "2026-09-28T12:24:55.422059+00:00"}, "query_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:24:58.018381+00:00", "end": "2026-09-28T12:24:58.580976+00:00"}}}
{"label": "main_1_1", "dag": "hello_world_dag", "run_id": "main_1_1__1790598669894851732", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:31:11.829375+00:00", "end": "2026-09-28T12:31:12.443251+00:00"}, "weather_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:31:12.949206+00:00", "end": "2026-09-28T12:31:13.415511+00:00"}, "date_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:31:14.385576+00:00", "end": "2026-09-28T12:31:14.885931+00:00"}, "end_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:31:16.391743+00:00", "end": "2026-09-28T12:31:16.910520+00:00"}}}
{"label": "main_1_2_3", "dag": "hello_world_dag", "run_id": "main_1_2_3__1790598718302127877", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:31:59.745450+00:00", "end": "2026-09-28T12:32:00.316366+00:00"}, "write_note": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:05.072071+00:00", "end": "2026-09-28T12:32:06.021474+00:00"}, "weather_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:01.159334+00:00", "end": "2026-09-28T12:32:01.723974+00:00"}, "date_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:02.837180+00:00", "end": "2026-09-28T12:32:03.474962+00:00"}, "end_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:07.286250+00:00", "end": "2026-09-28T12:32:07.807426+00:00"}}}
{"label": "main_2_1_bad_connection", "dag": "sql_basic_dag", "run_id": "main_2_1_bad_connection__1790598823284321738", "state": "failed", "expected_state": "failed", "tasks": {"truncate_table": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:33:44.966461+00:00", "end": "2026-09-28T12:33:44.966461+00:00"}, "insert_data": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:33:45.092205+00:00", "end": "2026-09-28T12:33:45.092205+00:00"}, "query_data": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:33:46.184509+00:00", "end": "2026-09-28T12:33:46.184509+00:00"}, "create_table": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:33:44.394182+00:00", "end": "2026-09-28T12:33:44.864384+00:00"}}}
{"label": "main_2_1_connection", "dag": "sql_basic_dag", "run_id": "main_2_1_connection__1790598770237278526", "state": "success", "expected_state": "success", "tasks": {"truncate_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:53.727892+00:00", "end": "2026-09-28T12:32:54.590903+00:00"}, "query_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:57.779131+00:00", "end": "2026-09-28T12:32:58.430291+00:00"}, "insert_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:55.515062+00:00", "end": "2026-09-28T12:32:56.385369+00:00"}, "create_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:32:51.379822+00:00", "end": "2026-09-28T12:32:52.074251+00:00"}}}
{"label": "main_2_2_repeat", "dag": "sql_basic_dag", "run_id": "main_2_2_repeat__1790598880752727153", "state": "success", "expected_state": "success", "tasks": {"create_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:42.273136+00:00", "end": "2026-09-28T12:34:42.874104+00:00"}, "truncate_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:43.459390+00:00", "end": "2026-09-28T12:34:44.136238+00:00"}, "insert_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:45.510497+00:00", "end": "2026-09-28T12:34:46.049469+00:00"}, "query_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:49.918420+00:00", "end": "2026-09-28T12:34:50.754474+00:00"}, "update_student": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:47.643123+00:00", "end": "2026-09-28T12:34:48.453621+00:00"}}}
{"label": "main_2_2_update", "dag": "sql_basic_dag", "run_id": "main_2_2_update__1790598869163909877", "state": "success", "expected_state": "success", "tasks": {"create_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:30.191396+00:00", "end": "2026-09-28T12:34:30.709128+00:00"}, "truncate_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:31.374968+00:00", "end": "2026-09-28T12:34:31.950460+00:00"}, "insert_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:33.266504+00:00", "end": "2026-09-28T12:34:33.826087+00:00"}, "update_student": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:34.392167+00:00", "end": "2026-09-28T12:34:34.997350+00:00"}, "query_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:34:35.712919+00:00", "end": "2026-09-28T12:34:36.267889+00:00"}}}
{"label": "main_3_1", "dag": "file_operations_dag", "run_id": "main_3_1__1790598934667802974", "state": "success", "expected_state": "success", "tasks": {"transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:41.591826+00:00", "end": "2026-09-28T12:35:43.072378+00:00"}, "write_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:43.657051+00:00", "end": "2026-09-28T12:35:45.282296+00:00"}, "read_and_validate_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:38.756892+00:00", "end": "2026-09-28T12:35:40.407604+00:00"}, "generate_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:35.850235+00:00", "end": "2026-09-28T12:35:37.611113+00:00"}}}
{"label": "main_4_1", "dag": "csv_to_postgres", "run_id": "main_4_1__1790598948261221421", "state": "success", "expected_state": "success", "tasks": {"preview_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:54.292219+00:00", "end": "2026-09-28T12:35:56.183442+00:00"}, "load_csv_to_postgres": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:57.028083+00:00", "end": "2026-09-28T12:35:57.557853+00:00"}, "create_orders_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:49.970156+00:00", "end": "2026-09-28T12:35:50.510862+00:00"}, "generate_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:35:51.118676+00:00", "end": "2026-09-28T12:35:52.724944+00:00"}}}
{"label": "main_4_2_negative", "dag": "csv_to_postgres_dq", "run_id": "main_4_2_negative__1790599011734924894", "state": "failed", "expected_state": "failed", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:52.890470+00:00", "end": "2026-09-28T12:36:53.470813+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:54.443034+00:00", "end": "2026-09-28T12:36:54.969375+00:00"}, "check_positive_amount": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:36:59.434123+00:00", "end": "2026-09-28T12:37:00.000016+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:56.309978+00:00", "end": "2026-09-28T12:36:56.841855+00:00"}, "data_quality_summary": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:37:00.114864+00:00", "end": "2026-09-28T12:37:00.114864+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:57.649020+00:00", "end": "2026-09-28T12:36:58.151617+00:00"}}}
{"label": "main_4_2_positive", "dag": "csv_to_postgres_dq", "run_id": "main_4_2_positive__1790599000150640710", "state": "success", "expected_state": "success", "tasks": {"check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:46.773434+00:00", "end": "2026-09-28T12:36:47.237395+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:47.780290+00:00", "end": "2026-09-28T12:36:48.296235+00:00"}, "check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:41.328500+00:00", "end": "2026-09-28T12:36:41.826903+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:42.632046+00:00", "end": "2026-09-28T12:36:43.225919+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:44.252071+00:00", "end": "2026-09-28T12:36:44.799840+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:36:45.510631+00:00", "end": "2026-09-28T12:36:46.128483+00:00"}}}
{"label": "main_4_2_restore", "dag": "csv_to_postgres_dq", "run_id": "main_4_2_restore__1790599023379510146", "state": "success", "expected_state": "success", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:04.600783+00:00", "end": "2026-09-28T12:37:05.244958+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:06.720511+00:00", "end": "2026-09-28T12:37:07.273312+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:09.578662+00:00", "end": "2026-09-28T12:37:10.260325+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:07.872450+00:00", "end": "2026-09-28T12:37:08.452528+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:12.613421+00:00", "end": "2026-09-28T12:37:13.154513+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:11.411627+00:00", "end": "2026-09-28T12:37:11.924165+00:00"}}}
{"label": "main_5_1_filter", "dag": "data_processing_dag", "run_id": "main_5_1_filter__1790599074663133799", "state": "success", "expected_state": "success", "tasks": {"create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:55.824116+00:00", "end": "2026-09-28T12:37:57.680612+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:59.047390+00:00", "end": "2026-09-28T12:38:00.783528+00:00"}, "extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:37:59.039283+00:00", "end": "2026-09-28T12:38:00.812189+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:01.501036+00:00", "end": "2026-09-28T12:38:03.122143+00:00"}, "simulate_load": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:04.173110+00:00", "end": "2026-09-28T12:38:05.600297+00:00"}, "generate_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:06.618522+00:00", "end": "2026-09-28T12:38:08.794823+00:00"}}}
{"label": "main_5_1_restore", "dag": "data_processing_dag", "run_id": "main_5_1_restore__1790599131072124593", "state": "success", "expected_state": "success", "tasks": {"create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:52.059583+00:00", "end": "2026-09-28T12:38:53.524667+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:55.185088+00:00", "end": "2026-09-28T12:38:56.827156+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:58.100743+00:00", "end": "2026-09-28T12:38:59.670338+00:00"}, "simulate_load": {"state": "success", "attempts": 1, "start": "2026-09-28T12:39:01.276000+00:00", "end": "2026-09-28T12:39:02.673380+00:00"}, "extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:38:55.342213+00:00", "end": "2026-09-28T12:38:56.915006+00:00"}, "generate_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:39:03.515006+00:00", "end": "2026-09-28T12:39:04.916556+00:00"}}}
{"label": "main_6_1_csv", "dag": "branching_dag", "run_id": "main_6_1_csv__1790599221866037738", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:23.121466+00:00", "end": "2026-09-28T12:40:23.121476+00:00"}, "process_csv_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:25.928269+00:00", "end": "2026-09-28T12:40:26.732622+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:40:25.140559+00:00", "end": "2026-09-28T12:40:25.140559+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:24.652209+00:00", "end": "2026-09-28T12:40:25.245620+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:27.779111+00:00", "end": "2026-09-28T12:40:28.278924+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:28.547843+00:00", "end": "2026-09-28T12:40:28.547856+00:00"}}}
{"label": "main_6_1_csv_restore", "dag": "branching_dag", "run_id": "main_6_1_csv_restore__1790599246736022049", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:47.688841+00:00", "end": "2026-09-28T12:40:47.688856+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:40:49.804302+00:00", "end": "2026-09-28T12:40:49.804302+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:49.301493+00:00", "end": "2026-09-28T12:40:49.954481+00:00"}, "process_csv_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:50.449791+00:00", "end": "2026-09-28T12:40:51.052888+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:51.831887+00:00", "end": "2026-09-28T12:40:52.503932+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:52.882378+00:00", "end": "2026-09-28T12:40:52.882395+00:00"}}}
{"label": "main_6_1_json", "dag": "branching_dag", "run_id": "main_6_1_json__1790599230837355984", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:31.315989+00:00", "end": "2026-09-28T12:40:31.315999+00:00"}, "process_csv_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:40:33.528600+00:00", "end": "2026-09-28T12:40:33.528600+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:33.004290+00:00", "end": "2026-09-28T12:40:33.646275+00:00"}, "process_json_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:34.836572+00:00", "end": "2026-09-28T12:40:35.404802+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:36.209056+00:00", "end": "2026-09-28T12:40:36.773333+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:37.541270+00:00", "end": "2026-09-28T12:40:37.541279+00:00"}}}
{"label": "main_6_1_xml", "dag": "branching_dag", "run_id": "main_6_1_xml__1790599239960170363", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:40.887013+00:00", "end": "2026-09-28T12:40:40.887026+00:00"}, "choose_format": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:40:42.076249+00:00", "end": "2026-09-28T12:40:42.682151+00:00"}, "process_csv_branch": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:40:42.871931+00:00", "end": "2026-09-28T12:40:42.871931+00:00"}, "process_json_branch": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:40:42.884065+00:00", "end": "2026-09-28T12:40:42.884065+00:00"}, "merge_results": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:40:43.830677+00:00", "end": "2026-09-28T12:40:43.830677+00:00"}, "end_task": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:40:44.428621+00:00", "end": "2026-09-28T12:40:44.428621+00:00"}}}
{"label": "main_7_1", "dag": "error_handling_dag", "run_id": "main_7_1__1790599256282469073", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:40:56.875385+00:00", "end": "2026-09-28T12:40:56.875397+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:40:58.566416+00:00", "end": "2026-09-28T12:40:59.270822+00:00"}, "retry_task": {"state": "success", "attempts": 2, "start": "2026-09-28T12:41:09.795211+00:00", "end": "2026-09-28T12:41:10.316195+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:41:10.449363+00:00", "end": "2026-09-28T12:41:10.449363+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:41:11.113518+00:00", "end": "2026-09-28T12:41:11.598224+00:00"}}}
{"label": "main_7_2_fail_both", "dag": "error_handling_dag", "run_id": "main_7_2_fail_both__1790599401713196879", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:43:22.530430+00:00", "end": "2026-09-28T12:43:22.530442+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:43:36.132875+00:00", "end": "2026-09-28T12:43:36.780473+00:00"}, "unreliable_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:43:36.118123+00:00", "end": "2026-09-28T12:43:36.741330+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:43:36.841371+00:00", "end": "2026-09-28T12:43:36.841371+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:37.573043+00:00", "end": "2026-09-28T12:43:38.121760+00:00"}}}
{"label": "main_7_2_fail_first", "dag": "error_handling_dag", "run_id": "main_7_2_fail_first__1790599364116452900", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:42:44.750400+00:00", "end": "2026-09-28T12:42:44.750408+00:00"}, "retry_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:42:45.926971+00:00", "end": "2026-09-28T12:42:46.477915+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:42:59.251667+00:00", "end": "2026-09-28T12:42:59.716135+00:00"}, "unreliable_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:42:57.061887+00:00", "end": "2026-09-28T12:42:57.672300+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:42:57.864139+00:00", "end": "2026-09-28T12:42:57.864139+00:00"}}}
{"label": "main_7_2_fail_second", "dag": "error_handling_dag", "run_id": "main_7_2_fail_second__1790599383044317774", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:43:03.978190+00:00", "end": "2026-09-28T12:43:03.978208+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:43:17.475284+00:00", "end": "2026-09-28T12:43:17.984522+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:05.869070+00:00", "end": "2026-09-28T12:43:06.372928+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:43:18.102304+00:00", "end": "2026-09-28T12:43:18.102304+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:19.615230+00:00", "end": "2026-09-28T12:43:20.101638+00:00"}}}
{"label": "main_7_2_no_retry", "dag": "error_handling_dag", "run_id": "main_7_2_no_retry__1790599315199596055", "state": "failed", "expected_state": "failed", "tasks": {"failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:42:00.036017+00:00", "end": "2026-09-28T12:42:00.705269+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:41:56.214072+00:00", "end": "2026-09-28T12:41:56.214088+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:41:57.872256+00:00", "end": "2026-09-28T12:41:58.819669+00:00"}, "retry_task": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:41:57.911390+00:00", "end": "2026-09-28T12:41:58.936653+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:41:59.040015+00:00", "end": "2026-09-28T12:41:59.040015+00:00"}}}
{"label": "main_7_2_success", "dag": "error_handling_dag", "run_id": "main_7_2_success__1790599420340411753", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:43:40.702654+00:00", "end": "2026-09-28T12:43:40.702666+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:42.414466+00:00", "end": "2026-09-28T12:43:43.206132+00:00"}, "retry_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:42.347891+00:00", "end": "2026-09-28T12:43:43.281951+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:43:43.383261+00:00", "end": "2026-09-28T12:43:43.383261+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:43:44.696936+00:00", "end": "2026-09-28T12:43:45.165836+00:00"}}}
{"label": "main_7_3", "dag": "error_handling_dag", "run_id": "main_7_3__1790599466582227262", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:44:27.093897+00:00", "end": "2026-09-28T12:44:27.093911+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:44:28.737493+00:00", "end": "2026-09-28T12:44:29.422467+00:00"}, "retry_task": {"state": "success", "attempts": 2, "start": "2026-09-28T12:44:40.106129+00:00", "end": "2026-09-28T12:44:40.590496+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:44:40.639777+00:00", "end": "2026-09-28T12:44:40.639777+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:44:41.109197+00:00", "end": "2026-09-28T12:44:41.576977+00:00"}}}
{"label": "main_8_1_parallel", "dag": "resource_management_dag", "run_id": "main_8_1_parallel__1790599574558882106", "state": "success", "expected_state": "success", "tasks": {"read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:46:16.538315+00:00", "end": "2026-09-28T12:46:37.107764+00:00"}, "read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:46:16.596349+00:00", "end": "2026-09-28T12:46:37.192829+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:46:37.808171+00:00", "end": "2026-09-28T12:46:38.458356+00:00"}, "log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:46:39.331072+00:00", "end": "2026-09-28T12:46:39.899368+00:00"}}}
{"label": "main_8_1_serial", "dag": "resource_management_dag", "run_id": "main_8_1_serial__1790599482620382943", "state": "success", "expected_state": "success", "tasks": {"log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:45:29.173691+00:00", "end": "2026-09-28T12:45:30.128755+00:00"}, "read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:44:43.856681+00:00", "end": "2026-09-28T12:45:04.694013+00:00"}, "read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:45:05.830821+00:00", "end": "2026-09-28T12:45:26.382507+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:45:27.408530+00:00", "end": "2026-09-28T12:45:28.420441+00:00"}}}
{"label": "main_8_2", "dag": "resource_management_dag", "run_id": "main_8_2__1790599640804934985", "state": "success", "expected_state": "success", "tasks": {"read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:47:21.889563+00:00", "end": "2026-09-28T12:47:42.647916+00:00"}, "read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:47:43.632588+00:00", "end": "2026-09-28T12:48:04.391177+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:48:05.904759+00:00", "end": "2026-09-28T12:48:06.622084+00:00"}, "log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:48:08.049355+00:00", "end": "2026-09-28T12:48:08.564654+00:00"}}}
{"label": "main_8_3", "dag": "resource_management_dag", "run_id": "main_8_3__1790599731782559761", "state": "success", "expected_state": "success", "tasks": {"extract.read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:48:53.427367+00:00", "end": "2026-09-28T12:49:13.903909+00:00"}, "extract.read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:49:15.274901+00:00", "end": "2026-09-28T12:49:35.809526+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:49:36.965983+00:00", "end": "2026-09-28T12:49:37.918550+00:00"}, "log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T12:49:39.405235+00:00", "end": "2026-09-28T12:49:39.974974+00:00"}}}
{"label": "optional_1_A_first", "dag": "hello_world_dag", "run_id": "optional_1_A_first__1790599828501658295", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:29.705066+00:00", "end": "2026-09-28T12:51:00.256126+00:00"}, "date_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:02.651448+00:00", "end": "2026-09-28T12:51:03.175435+00:00"}, "weather_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:01.427549+00:00", "end": "2026-09-28T12:51:02.022120+00:00"}, "write_note": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:03.805590+00:00", "end": "2026-09-28T12:51:04.377917+00:00"}, "end_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:04.973858+00:00", "end": "2026-09-28T12:51:05.452179+00:00"}}}
{"label": "optional_1_A_second", "dag": "hello_world_dag", "run_id": "optional_1_A_second__1790599831490826780", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:07.523497+00:00", "end": "2026-09-28T12:51:38.200910+00:00"}, "weather_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:40.092576+00:00", "end": "2026-09-28T12:51:40.720014+00:00"}, "date_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:42.001534+00:00", "end": "2026-09-28T12:51:42.531175+00:00"}, "end_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:45.023411+00:00", "end": "2026-09-28T12:51:45.554981+00:00"}, "write_note": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:43.392660+00:00", "end": "2026-09-28T12:51:44.179028+00:00"}}}
{"label": "optional_2_A_B", "dag": "sql_basic_dag", "run_id": "optional_2_A_B__1790599987276042498", "state": "success", "expected_state": "success", "tasks": {"create_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:09.211357+00:00", "end": "2026-09-28T12:53:09.951512+00:00"}, "truncate_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:11.047425+00:00", "end": "2026-09-28T12:53:11.740876+00:00"}, "insert_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:12.934788+00:00", "end": "2026-09-28T12:53:13.607534+00:00"}, "update_student": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:15.944973+00:00", "end": "2026-09-28T12:53:16.638095+00:00"}, "query_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:17.435005+00:00", "end": "2026-09-28T12:53:18.141358+00:00"}, "report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:19.546507+00:00", "end": "2026-09-28T12:53:20.286011+00:00"}}}
{"label": "optional_3_A_B_C", "dag": "file_operations_dag", "run_id": "optional_3_A_B_C__1790600042332111688", "state": "success", "expected_state": "success", "tasks": {"transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:09.980335+00:00", "end": "2026-09-28T12:54:12.248114+00:00"}, "write_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:13.879091+00:00", "end": "2026-09-28T12:54:17.284622+00:00"}, "read_and_validate_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:07.264422+00:00", "end": "2026-09-28T12:54:08.788050+00:00"}, "generate_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:03.667581+00:00", "end": "2026-09-28T12:54:06.067366+00:00"}, "department_engineering": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:14.349510+00:00", "end": "2026-09-28T12:54:17.713422+00:00"}, "department_support": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:14.885206+00:00", "end": "2026-09-28T12:54:17.977159+00:00"}, "department_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:20.187672+00:00", "end": "2026-09-28T12:54:21.852688+00:00"}, "department_sales": {"state": "success", "attempts": 2, "start": "2026-09-28T12:54:20.381886+00:00", "end": "2026-09-28T12:54:22.016177+00:00"}, "gather": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:22.800577+00:00", "end": "2026-09-28T12:54:24.457104+00:00"}}}
{"label": "optional_4_A_dq", "dag": "csv_to_postgres_dq", "run_id": "optional_4_A_dq__1790600084423979625", "state": "success", "expected_state": "success", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:45.792417+00:00", "end": "2026-09-28T12:54:46.301310+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:47.351405+00:00", "end": "2026-09-28T12:54:47.874188+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:48.529849+00:00", "end": "2026-09-28T12:54:48.966554+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:49.787486+00:00", "end": "2026-09-28T12:54:50.271862+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:51.064045+00:00", "end": "2026-09-28T12:54:51.705791+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:52.304665+00:00", "end": "2026-09-28T12:54:52.846778+00:00"}}}
{"label": "optional_4_A_restore", "dag": "csv_to_postgres_dq", "run_id": "optional_4_A_restore__1790600176128130911", "state": "success", "expected_state": "success", "tasks": {"check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:18.907596+00:00", "end": "2026-09-28T12:56:19.356101+00:00"}, "check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:17.659452+00:00", "end": "2026-09-28T12:56:18.088407+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:22.339147+00:00", "end": "2026-09-28T12:56:22.756030+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:19.993271+00:00", "end": "2026-09-28T12:56:20.456629+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:21.187257+00:00", "end": "2026-09-28T12:56:21.591291+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:23.525671+00:00", "end": "2026-09-28T12:56:23.996173+00:00"}}}
{"label": "optional_4_A_status", "dag": "csv_to_postgres", "run_id": "optional_4_A_status__1790600072998689452", "state": "success", "expected_state": "success", "tasks": {"create_orders_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:34.420941+00:00", "end": "2026-09-28T12:54:34.965465+00:00"}, "generate_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:35.846349+00:00", "end": "2026-09-28T12:54:37.632934+00:00"}, "load_csv_to_postgres": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:40.758903+00:00", "end": "2026-09-28T12:54:41.313249+00:00"}, "preview_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:38.368799+00:00", "end": "2026-09-28T12:54:39.934048+00:00"}}}
{"label": "optional_4_A_status_bad_order", "dag": "csv_to_postgres", "run_id": "optional_4_A_status__1790599866797382800", "state": "failed", "expected_state": "failed", "tasks": {"create_orders_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:07.993200+00:00", "end": "2026-09-28T12:51:08.577937+00:00"}, "load_csv_to_postgres": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:51:47.478501+00:00", "end": "2026-09-28T12:51:48.034511+00:00"}, "generate_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:09.311281+00:00", "end": "2026-09-28T12:51:10.967148+00:00"}, "preview_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:12.691761+00:00", "end": "2026-09-28T12:51:14.377415+00:00"}}}
{"label": "optional_4_B_dq", "dag": "csv_to_postgres_dq", "run_id": "optional_4_B_dq__1790600237996456220", "state": "success", "expected_state": "success", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:19.103267+00:00", "end": "2026-09-28T12:57:19.560193+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:20.324388+00:00", "end": "2026-09-28T12:57:20.846060+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:22.250197+00:00", "end": "2026-09-28T12:57:22.727576+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:23.415948+00:00", "end": "2026-09-28T12:57:23.951371+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:25.207786+00:00", "end": "2026-09-28T12:57:25.689645+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:27.492209+00:00", "end": "2026-09-28T12:57:28.083003+00:00"}}}
{"label": "optional_4_B_operator", "dag": "csv_to_postgres", "run_id": "optional_4_B_operator__1790600227029157362", "state": "success", "expected_state": "success", "tasks": {"generate_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:10.252580+00:00", "end": "2026-09-28T12:57:11.814673+00:00"}, "create_orders_table": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:08.082255+00:00", "end": "2026-09-28T12:57:08.667507+00:00"}, "preview_csv": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:13.073106+00:00", "end": "2026-09-28T12:57:14.567664+00:00"}, "load_csv_to_postgres": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:15.203959+00:00", "end": "2026-09-28T12:57:15.691633+00:00"}}}
{"label": "optional_4_C_all_done", "dag": "csv_to_postgres_dq", "run_id": "optional_4_C_all_done__1790600337228090134", "state": "failed", "expected_state": "failed", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:58:58.725586+00:00", "end": "2026-09-28T12:58:59.201374+00:00"}, "check_orders_schema": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:59:01.015357+00:00", "end": "2026-09-28T12:59:01.541150+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:02.461634+00:00", "end": "2026-09-28T12:59:02.991782+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:03.664066+00:00", "end": "2026-09-28T12:59:04.142534+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:04.892560+00:00", "end": "2026-09-28T12:59:05.430995+00:00"}, "data_quality_summary": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:59:07.120230+00:00", "end": "2026-09-28T12:59:07.593930+00:00"}}}
{"label": "optional_4_C_default", "dag": "csv_to_postgres_dq", "run_id": "optional_4_C_default__1790600291366135652", "state": "failed", "expected_state": "failed", "tasks": {"check_order_duplicates": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:58:15.164590+00:00", "end": "2026-09-28T12:58:15.164590+00:00"}, "check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:58:13.042381+00:00", "end": "2026-09-28T12:58:13.526508+00:00"}, "check_positive_amount": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:58:16.262284+00:00", "end": "2026-09-28T12:58:16.262284+00:00"}, "data_quality_summary": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:58:16.585583+00:00", "end": "2026-09-28T12:58:16.585583+00:00"}, "check_orders_schema": {"state": "failed", "attempts": 1, "start": "2026-09-28T12:58:14.330420+00:00", "end": "2026-09-28T12:58:14.851567+00:00"}, "check_orders_has_rows": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:58:14.960806+00:00", "end": "2026-09-28T12:58:14.960806+00:00"}}}
{"label": "optional_4_C_restore", "dag": "csv_to_postgres_dq", "run_id": "optional_4_C_restore__1790600390711838382", "state": "success", "expected_state": "success", "tasks": {"check_orders_table_exists": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:51.882799+00:00", "end": "2026-09-28T12:59:52.351329+00:00"}, "check_orders_schema": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:53.403783+00:00", "end": "2026-09-28T12:59:53.992901+00:00"}, "check_orders_has_rows": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:54.968165+00:00", "end": "2026-09-28T12:59:55.731129+00:00"}, "check_order_duplicates": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:58.083402+00:00", "end": "2026-09-28T12:59:59.365498+00:00"}, "check_positive_amount": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:01.904918+00:00", "end": "2026-09-28T13:00:03.145987+00:00"}, "data_quality_summary": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:03.953750+00:00", "end": "2026-09-28T13:00:04.515677+00:00"}}}
{"label": "optional_5_A_B_C_success", "dag": "data_processing_dag", "run_id": "optional_5_A_B_C_success__1790599826280464970", "state": "success", "expected_state": "success", "tasks": {"create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:28.065679+00:00", "end": "2026-09-28T12:50:29.967444+00:00"}, "validate": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:38.601847+00:00", "end": "2026-09-28T12:50:40.510529+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:35.381953+00:00", "end": "2026-09-28T12:50:37.142909+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:30.926518+00:00", "end": "2026-09-28T12:50:32.669276+00:00"}, "extract_products": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:31.062084+00:00", "end": "2026-09-28T12:50:32.916827+00:00"}, "extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:30.983002+00:00", "end": "2026-09-28T12:50:33.137312+00:00"}, "simulate_load": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:41.199488+00:00", "end": "2026-09-28T12:50:42.824662+00:00"}, "generate_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:43.422545+00:00", "end": "2026-09-28T12:50:45.474906+00:00"}, "archive": {"state": "success", "attempts": 1, "start": "2026-09-28T12:50:46.874833+00:00", "end": "2026-09-28T12:50:47.500464+00:00"}}}
{"label": "optional_5_C_failure", "dag": "data_processing_dag", "run_id": "optional_5_C_failure__1790599890880680316", "state": "failed", "expected_state": "failed", "tasks": {"create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:31.769851+00:00", "end": "2026-09-28T12:51:33.331037+00:00"}, "extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:34.907501+00:00", "end": "2026-09-28T12:51:36.969211+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:34.860135+00:00", "end": "2026-09-28T12:51:37.189529+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:38.760107+00:00", "end": "2026-09-28T12:51:40.621671+00:00"}, "simulate_load": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:51:49.189667+00:00", "end": "2026-09-28T12:51:49.189667+00:00"}, "generate_report": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:51:49.662176+00:00", "end": "2026-09-28T12:51:49.662176+00:00"}, "archive": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:51:50.496425+00:00", "end": "2026-09-28T12:51:50.496425+00:00"}, "extract_products": {"state": "success", "attempts": 1, "start": "2026-09-28T12:51:35.050525+00:00", "end": "2026-09-28T12:51:37.258136+00:00"}, "validate": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:51:47.454480+00:00", "end": "2026-09-28T12:51:49.033534+00:00"}}}
{"label": "optional_5_C_restore", "dag": "data_processing_dag", "run_id": "optional_5_C_restore__1790599954560978695", "state": "success", "expected_state": "success", "tasks": {"extract_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:38.792872+00:00", "end": "2026-09-28T12:52:41.056815+00:00"}, "extract_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:38.712862+00:00", "end": "2026-09-28T12:52:40.983042+00:00"}, "transform_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:44.098377+00:00", "end": "2026-09-28T12:52:46.183169+00:00"}, "generate_report": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:54.485567+00:00", "end": "2026-09-28T12:52:56.742958+00:00"}, "archive": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:57.823277+00:00", "end": "2026-09-28T12:52:58.453906+00:00"}, "create_sample_data": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:35.864954+00:00", "end": "2026-09-28T12:52:37.451156+00:00"}, "extract_products": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:38.920025+00:00", "end": "2026-09-28T12:52:41.149145+00:00"}, "validate": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:47.821696+00:00", "end": "2026-09-28T12:52:49.319829+00:00"}, "simulate_load": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:50.127366+00:00", "end": "2026-09-28T12:52:52.548589+00:00"}}}
{"label": "optional_6_A_csv", "dag": "branching_dag", "run_id": "optional_6_A_csv__1790599969413452767", "state": "success", "expected_state": "success", "tasks": {"process_csv_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:54.817429+00:00", "end": "2026-09-28T12:52:55.475004+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:49.756524+00:00", "end": "2026-09-28T12:52:49.756537+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:53.426676+00:00", "end": "2026-09-28T12:52:53.426676+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:51.852609+00:00", "end": "2026-09-28T12:52:53.700004+00:00"}, "review_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:53.426676+00:00", "end": "2026-09-28T12:52:53.426676+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:56.749010+00:00", "end": "2026-09-28T12:52:57.301418+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:57.406094+00:00", "end": "2026-09-28T12:52:57.406105+00:00"}}}
{"label": "optional_6_A_json", "dag": "branching_dag", "run_id": "optional_6_A_json__1790599981318891965", "state": "success", "expected_state": "success", "tasks": {"process_csv_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:53:04.678219+00:00", "end": "2026-09-28T12:53:04.678219+00:00"}, "review_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:53:04.678219+00:00", "end": "2026-09-28T12:53:04.678219+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:53:02.190235+00:00", "end": "2026-09-28T12:53:02.190247+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:03.854653+00:00", "end": "2026-09-28T12:53:04.883167+00:00"}, "process_json_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:05.617301+00:00", "end": "2026-09-28T12:53:06.163931+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:06.846567+00:00", "end": "2026-09-28T12:53:07.431430+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:53:07.527378+00:00", "end": "2026-09-28T12:53:07.527385+00:00"}}}
{"label": "optional_6_A_review", "dag": "branching_dag", "run_id": "optional_6_A_review__1790599950878181492", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:31.880500+00:00", "end": "2026-09-28T12:52:31.880513+00:00"}, "process_csv_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:34.520952+00:00", "end": "2026-09-28T12:52:34.520952+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:34.520952+00:00", "end": "2026-09-28T12:52:34.520952+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:33.686169+00:00", "end": "2026-09-28T12:52:34.634325+00:00"}, "review_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:35.883342+00:00", "end": "2026-09-28T12:52:36.335243+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:37.145269+00:00", "end": "2026-09-28T12:52:37.638569+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:38.080346+00:00", "end": "2026-09-28T12:52:38.080354+00:00"}}}
{"label": "optional_6_A_xml", "dag": "branching_dag", "run_id": "optional_6_A_xml__1790599960064387143", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:40.576461+00:00", "end": "2026-09-28T12:52:40.576475+00:00"}, "process_csv_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:42.254102+00:00", "end": "2026-09-28T12:52:42.254102+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:52:42.254102+00:00", "end": "2026-09-28T12:52:42.254102+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:41.714877+00:00", "end": "2026-09-28T12:52:42.372355+00:00"}, "review_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:44.072978+00:00", "end": "2026-09-28T12:52:44.611876+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:52:47.349562+00:00", "end": "2026-09-28T12:52:47.349570+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:52:45.532475+00:00", "end": "2026-09-28T12:52:46.387047+00:00"}}}
{"label": "optional_6_B_csv", "dag": "branching_dag", "run_id": "optional_6_B_csv__1790600030982481228", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:53:51.382625+00:00", "end": "2026-09-28T12:53:51.382631+00:00"}, "process_json_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:53:53.983930+00:00", "end": "2026-09-28T12:53:53.983930+00:00"}, "review_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:53:53.983930+00:00", "end": "2026-09-28T12:53:53.983930+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:52.593521+00:00", "end": "2026-09-28T12:53:54.169523+00:00"}, "process_csv_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:55.338055+00:00", "end": "2026-09-28T12:53:57.034412+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:53:58.733441+00:00", "end": "2026-09-28T12:53:58.733456+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:58.016770+00:00", "end": "2026-09-28T12:53:58.536435+00:00"}}}
{"label": "optional_6_B_json", "dag": "branching_dag", "run_id": "optional_6_B_json__1790600043123594630", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:54:04.312564+00:00", "end": "2026-09-28T12:54:04.312573+00:00"}, "process_csv_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:54:07.267935+00:00", "end": "2026-09-28T12:54:07.267935+00:00"}, "review_branch": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:54:07.267935+00:00", "end": "2026-09-28T12:54:07.267935+00:00"}, "choose_format": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:06.715738+00:00", "end": "2026-09-28T12:54:07.404010+00:00"}, "process_json_branch": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:08.623885+00:00", "end": "2026-09-28T12:54:10.327426+00:00"}, "merge_results": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:11.590619+00:00", "end": "2026-09-28T12:54:12.518040+00:00"}, "end_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:54:13.240380+00:00", "end": "2026-09-28T12:54:13.240393+00:00"}}}
{"label": "optional_7_A", "dag": "error_handling_dag", "run_id": "optional_7_A__1790600019449470468", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:53:40.237917+00:00", "end": "2026-09-28T12:53:40.237924+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:53:41.595590+00:00", "end": "2026-09-28T12:53:42.183338+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:54:50.391456+00:00", "end": "2026-09-28T12:54:50.391456+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:54:51.055581+00:00", "end": "2026-09-28T12:54:51.678943+00:00"}, "retry_task": {"state": "failed", "attempts": 4, "start": "2026-09-28T12:54:49.753125+00:00", "end": "2026-09-28T12:54:50.277862+00:00"}}}
{"label": "optional_7_B_ConnectionError", "dag": "error_handling_dag", "run_id": "optional_7_B_ConnectionError__1790600133593914390", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:55:34.970055+00:00", "end": "2026-09-28T12:55:34.970064+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:55:36.562237+00:00", "end": "2026-09-28T12:55:37.011257+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:55:47.492118+00:00", "end": "2026-09-28T12:55:47.940804+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:55:48.064846+00:00", "end": "2026-09-28T12:55:48.064846+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:55:48.712490+00:00", "end": "2026-09-28T12:55:49.197555+00:00"}}}
{"label": "optional_7_B_OSError", "dag": "error_handling_dag", "run_id": "optional_7_B_OSError__1790600252561937372", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:57:33.632472+00:00", "end": "2026-09-28T12:57:33.632487+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:34.290109+00:00", "end": "2026-09-28T12:57:34.768072+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:57:45.543523+00:00", "end": "2026-09-28T12:57:46.128267+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:57:46.246909+00:00", "end": "2026-09-28T12:57:46.246909+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:57:47.071181+00:00", "end": "2026-09-28T12:57:47.715989+00:00"}}}
{"label": "optional_7_B_ValueError", "dag": "error_handling_dag", "run_id": "optional_7_B_ValueError__1790600191959984913", "state": "failed", "expected_state": "failed", "tasks": {"success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:56:45.784424+00:00", "end": "2026-09-28T12:56:45.784424+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:46.689244+00:00", "end": "2026-09-28T12:56:47.474193+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:56:32.295591+00:00", "end": "2026-09-28T12:56:32.295598+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:56:33.808507+00:00", "end": "2026-09-28T12:56:34.327861+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:56:44.934551+00:00", "end": "2026-09-28T12:56:45.531229+00:00"}}}
{"label": "optional_7_B_restore", "dag": "error_handling_dag", "run_id": "optional_7_B_restore__1790600308604083181", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:58:28.905883+00:00", "end": "2026-09-28T12:58:28.905893+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:58:30.479933+00:00", "end": "2026-09-28T12:58:30.955552+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:58:44.841191+00:00", "end": "2026-09-28T12:58:45.414819+00:00"}, "retry_task": {"state": "success", "attempts": 2, "start": "2026-09-28T12:58:42.378837+00:00", "end": "2026-09-28T12:58:43.211009+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:58:43.325326+00:00", "end": "2026-09-28T12:58:43.325326+00:00"}}}
{"label": "optional_8_A", "dag": "resource_management_dag", "run_id": "optional_8_A__1790600366934777325", "state": "success", "expected_state": "success", "tasks": {"extract.read_customers": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:28.045365+00:00", "end": "2026-09-28T12:59:48.565983+00:00"}, "extract.read_orders": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:49.448034+00:00", "end": "2026-09-28T13:00:10.063529+00:00"}, "calculate_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:11.157140+00:00", "end": "2026-09-28T13:00:11.708381+00:00"}, "print_total": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:12.427443+00:00", "end": "2026-09-28T13:00:13.032322+00:00"}, "log_metrics": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:12.452891+00:00", "end": "2026-09-28T13:00:12.951344+00:00"}}}
{"label": "optional_8_B_delivery_error_failed", "dag": "error_handling_dag", "run_id": "optional_8_B_delivery_error_failed__1790600442808275037", "state": "failed", "expected_state": "failed", "tasks": {"retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T13:00:55.863913+00:00", "end": "2026-09-28T13:00:56.344315+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T13:00:56.517274+00:00", "end": "2026-09-28T13:00:56.517274+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T13:00:43.241323+00:00", "end": "2026-09-28T13:00:43.241338+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:44.542395+00:00", "end": "2026-09-28T13:00:45.057656+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T13:00:58.115826+00:00", "end": "2026-09-28T13:00:58.719339+00:00"}}}
{"label": "optional_8_B_delivery_error_success", "dag": "error_handling_dag", "run_id": "optional_8_B_delivery_error_success__1790600461566675460", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T13:01:02.553513+00:00", "end": "2026-09-28T13:01:02.553524+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T13:01:04.593955+00:00", "end": "2026-09-28T13:01:05.213490+00:00"}, "retry_task": {"state": "success", "attempts": 1, "start": "2026-09-28T13:01:03.364187+00:00", "end": "2026-09-28T13:01:03.838482+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T13:01:03.355462+00:00", "end": "2026-09-28T13:01:03.840328+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T13:01:03.939908+00:00", "end": "2026-09-28T13:01:03.939908+00:00"}}}
{"label": "optional_8_B_failure", "dag": "error_handling_dag", "run_id": "optional_8_B_failure__1790600374167341045", "state": "failed", "expected_state": "failed", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:59:34.633405+00:00", "end": "2026-09-28T12:59:34.633414+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:36.262871+00:00", "end": "2026-09-28T12:59:36.837096+00:00"}, "retry_task": {"state": "failed", "attempts": 2, "start": "2026-09-28T12:59:47.584630+00:00", "end": "2026-09-28T12:59:48.117663+00:00"}, "success_handler": {"state": "upstream_failed", "attempts": 0, "start": "2026-09-28T12:59:48.256654+00:00", "end": "2026-09-28T12:59:48.256654+00:00"}, "failure_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:49.504044+00:00", "end": "2026-09-28T12:59:50.105201+00:00"}}}
{"label": "optional_8_B_restore", "dag": "error_handling_dag", "run_id": "optional_8_B_restore__1790600507926543810", "state": "success", "expected_state": "success", "tasks": {"start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T13:01:49.154391+00:00", "end": "2026-09-28T13:01:49.154407+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T13:01:50.686480+00:00", "end": "2026-09-28T13:01:51.192154+00:00"}, "retry_task": {"state": "success", "attempts": 2, "start": "2026-09-28T13:02:01.992429+00:00", "end": "2026-09-28T13:02:02.590182+00:00"}, "success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T13:02:03.292271+00:00", "end": "2026-09-28T13:02:03.992153+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T13:02:02.684938+00:00", "end": "2026-09-28T13:02:02.684938+00:00"}}}
{"label": "optional_8_B_success", "dag": "error_handling_dag", "run_id": "optional_8_B_success__1790600393174553780", "state": "success", "expected_state": "success", "tasks": {"success_handler": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:58.041525+00:00", "end": "2026-09-28T12:59:59.107268+00:00"}, "start_task": {"state": "success", "attempts": 0, "start": "2026-09-28T12:59:54.153604+00:00", "end": "2026-09-28T12:59:54.153613+00:00"}, "unreliable_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:54.985172+00:00", "end": "2026-09-28T12:59:55.734497+00:00"}, "retry_task": {"state": "success", "attempts": 1, "start": "2026-09-28T12:59:55.307245+00:00", "end": "2026-09-28T12:59:55.926514+00:00"}, "failure_handler": {"state": "skipped", "attempts": 0, "start": "2026-09-28T12:59:56.121310+00:00", "end": "2026-09-28T12:59:56.121310+00:00"}}}
+69
View File
@@ -0,0 +1,69 @@
# Проверка обновленной практики
Дата: 2026-09-28. Код примеров: коммит `cf1b770`; задания и навигация: `d8dfe83`. Проверка выполнена авторским прогоном по [заданиям](../../educational-tasks.md). Это проверка воспроизводимости инструкций; время и трудность прохождения на студентах не измерялись.
## Окружение
Образ собран из Dockerfile стенда. Фактические версии внутри контейнера: Apache Airflow 2.9.2, Python 3.12.4, pandas 2.1.4, mimesis 15.1.0, PostgreSQL provider 5.11.1. Executor: LocalExecutor, PostgreSQL 16.
Использован отдельный Compose-проект `airflow-manual-practice-check`: собственные новые тома БД и копии `dags/`, `data/`. Порты перенесены на 18089, 15439, 15440. Для автоматизации включен Basic Auth API, `parallelism=4`, интервалы обнаружения файлов сокращены до 5 секунд. Перед запуском измененного DAG проверялся новый разбор файла и выдерживался интервал обновления сериализованного графа.
Изменения студента выполнялись только в копиях. Внутри блока они сохранялись. Вначале прошли исходные примеры, затем основной маршрут без дополнительных заданий. Дополнительные расширения проверены после него. Независимые блоки выполнялись одновременно; загрузчик и DQ шли последовательно.
Повторный `docker compose run --rm airflow-init` на существующих томах также завершился с кодом 0: учетная запись сохранена, подключение пересоздано.
## Исходные примеры
Все девять DAG загрузились без Import Errors и завершились успешно. Для `resource_management_dag` заранее создан пул из двух слотов. У `error_handling_dag` режим по умолчанию дал ошибку первой попытки и успех второй. У ветвления выбранная ветка успешна, другая пропущена.
SQL оставил трех студентов, загрузчик создал 1000 заказов, DQ прошел. Файловый пример записал 100 строк. ETL создал шесть заказов с общей суммой 2105, шаг `simulate_load` сообщил об имитации.
## Основной маршрут
| Упражнения | Действия и фактический результат |
|---|---|
| 1.1-1.3 | После добавления погоды четыре задачи, после BashOperator пять; `note.txt` содержит `Airflow works`. Расписание изменилось на `0 0 * * 1`, ручной запуск прошел. |
| 2.1-2.2 | Создано отдельное подключение. Неверный Conn Id при нуле повторов: `create_table=failed`, следующие задачи `upstream_failed`. После восстановления и UPDATE: Alice 25, Bob 24, Charlie 21. Повторный полный запуск оставил три строки. |
| 3.1 | Порог High изменен на 80000. Проверены 100 строк и соответствие категории зарплате в CSV того же запуска. |
| 4.1-4.2 | Второй последовательный запуск загрузчика увеличил число заказов до 2000. Добавлена проверка суммы: корректные данные проходят; один отрицательный заказ дает `check_positive_amount=failed`, сводка `upstream_failed`, DAG Run `failed`. Повторный UPDATE восстановил успешный DQ. |
| 5.1 | Фильтр в Extract дал 4 заказа и сумму 2000 в результате Transform. После удаления фильтра снова 6 и 2105. |
| 6.1 | Режимы `csv`, `json`, `xml`, затем `csv`: выбор нужной ветки, отказ при неизвестном формате и успешное восстановление. XCom слияния относится к текущему запуску. |
| 7.1-7.3 | `retry_once`: две попытки и успех; переопределение `retries=0`: окончательный отказ. `fail_first`, `fail_second`, `fail_both`: обработчик ошибки успешен, обработчик успеха `upstream_failed`, DAG Run `failed`. Режим `success` и возврат к `retry_once` успешны. Добавлена запись в лог обработчика успеха. |
| 8.1 | Интервалы выполнения задач с весами 2 и 1 не пересекаются. После замены веса 2 на 1 интервалы пересекаются. |
| 8.2-8.3 | Добавлена метрика `total_rows=50`. После TaskGroup обновлены оба полных task_id, все четыре задачи успешны; XCom сохранил 20, 30 и 50. |
Основной маршрут завершен без дополнительных упражнений.
## Дополнительные упражнения
| Упражнения | Действия и фактический результат |
|---|---|
| 1.A | Два ручных запуска с задержкой 30 секунд и `max_active_runs=1`: задачи второго начались после завершения первого. Затем задержка удалена, DAG выключен. |
| 2.A-2.B | Колонка email добавлена через ALTER до запуска. Три адреса заполнены, отчет содержит 3 студента и средний возраст 23.33; UPDATE из основной практики сохранен. |
| 3.A-3.C | Новые поля проверены, файл опытных сотрудников отделен от полного. Численность отделов в JSON дает 100; сборка трех файлов содержит те же 100 уникальных id. Отдел sales выполнился со второй попытки без дублей. |
| 4.A | При неверном порядке CSV/COPY получено `invalid input syntax for type numeric: "NEW"`. После согласования порядка новые статусы попали в БД и прошел DQ. После удаления поля из кода и таблицы DQ снова успешен; положительная сумма продолжает проверяться. |
| 4.B-4.C | Создание таблицы заменено PostgresOperator, загрузка и DQ успешны. Неверный ожидаемый тип amount при all_success дал upstream_failed у следующих задач. С all_done они выполнились, но сводка и DAG Run остались failed. Возврат numeric и исходных trigger rules восстановил успешный DQ. |
| 5.A-5.C | Справочник продуктов не изменил 6 заказов и сумму 2105. Топ-3: 1200, 400, 300; средний заказ Alice 640. Неверный клиент вызвал отказ проверки и заблокировал архив. После восстановления появился один архив, побайтово равный результату. |
| 6.A-6.B | Третья ветка выбрана для review и неизвестного формата; csv/json продолжают работать. Чтение двух фиксированных файлов дало по 2 записи с суммой 30 в отдельных запусках. |
| 7.A | Три повтора дали четыре неуспешные попытки, после которых DAG Run остался failed. |
| 7.B | ConnectionError, ValueError и OSError вызвали callback после второй попытки; проверены task_id и текст причины. В обеих попытках восстановленного retry_once callback отказа отсутствует. |
| 8.A | После группировки добавлен BashOperator с чтением XCom; в Details видна команда echo 50, в Logs число 50 и успешный выход. |
| 8.B | Локальный SMTP принял одно письмо об отказе retry_task и одно об успехе DAG. При недоступном SMTP неуспешный запуск остался failed, успешный остался success. После отключения почтовых callbacks и возврата retry_once запуск успешен. |
[Протокол запусков JSONL](2026-09-28-practice-runs.jsonl) содержит 69 контрольных запусков и один диагностический запуск с неверным порядком CSV/COPY. Для каждого сохранены ожидаемый и фактический итог, состояния и число попыток задач, время начала и окончания. В первой строке указаны версии и SHA-256 итоговых DAG. Все ожидаемые состояния сошлись.
При выполнении уточнены инструкции: одинаковый порядок CSV/COPY; короткая задержка повтора в 3.C и 5.C; расположение Rendered Templates внутри Details; раскрытие TaskGroup нажатием на название со стрелкой. Сообщение callback оператора подтверждено в логе последней попытки. Команда `docker compose logs airflow-scheduler` его не показала. Отправка из callback DAG и ошибка его доставки найдены в `/opt/airflow/logs/scheduler/latest/error_handling_dag.py.log`.
## Интерфейс и документы
Chromium: вход под учебной учетной записью, список девяти DAG, запуск кнопкой Trigger DAG, граф с четырьмя прямыми ребрами от рабочих задач к обработчикам. В Logs доступны две попытки; во второй видно `Starting attempt 2 of 2` и сообщение об успехе. В XCom загрузчика найден путь к CSV; тот же CSV виден в Details, раздел Rendered Templates, у задачи загрузки. Часть пути там маскируется как `***`; инструкция уточнена, имя файла сверено с XCom.
Снимки проверенных состояний: [окончательный отказ](../../_attachments/practice-failed-run.png), [раскрытая TaskGroup](../../_attachments/practice-task-group.png), [путь в Rendered Templates](../../_attachments/practice-rendered-template.png). Группа раскрыта нажатием на название со стрелкой, внутри видны обе задачи.
Markdown отрисован отдельно: восемь строк маршрута, 34 заголовка упражнений, рабочий переход к блоку ошибок. Проверены локальные ссылки и якоря в измененных документах, синтаксис девяти DAG и `git diff --check`.
После сохранения протокола изолированные контейнеры, браузер и два тестовых тома удалены. Остальные работающие сервисы не изменялись.
## Границы проверки
Почтовый пример проверен на локальном SMTP-приемнике без отправки сообщений третьим лицам. Внешний провайдер, авторизация и TLS не проверялись: их параметры зависят от выбранного студентом сервера. Поведение Windows не проверялось на отдельной Windows-машине; опубликованные команды не используют синтаксис, специфичный для Bash.
@@ -0,0 +1,50 @@
# Перепроверка педагогического ревью
Дата: 2026-09-28. Исходная версия: `f6b450c`. После независимого ревью замечания проверены по текстам, Compose и исполнением примеров в Apache Airflow 2.9.2.
## Оценка значимости и решения
Основные задания используют готовые DAG и правильные параметры учебной БД. Подтвержденные ошибки относятся к примерам теоретических глав: они мешают студенту, который переносит код из главы в стенд. Оценка их как блокировки всей основной практики была бы завышенной.
| Находка | Что подтвердилось | Решение |
|---|---|---|
| Параметры Connections из другого стенда | В главе 7 предлагались `postgres / airflow / airflow`, хотя Compose и практика используют `postgres-training / training / student` | Исправлены поля формы. Добавлен полный DAG проверки подключения и наблюдаемый результат в БД |
| Задачи без привязки к DAG | Примеры `greeting_dag` и `template_tracking_example` падают при объявлении зависимостей; `template_example` создает DAG без задач | Добавлены привязки и объяснение. Примеры шаблонов дополнены импортами и настройками, чтобы их можно было запускать целиком |
| Неподготовленный итоговый пример главы 9 | Нет исходного CSV и подготовки схем БД, расходятся имена Variable. После подстановки Variable пример падает при создании TaskGroup | Вместо отдельного пайплайна глава направляет к существующему `resource_management_dag.py` и блоку 8. Пример самой TaskGroup сделан самостоятельным; схема согласована с кодом |
| Слишком широкое чтение перед упражнениями | Для XCom перед блоком 4 была указана вся глава 9 | В таблице маршрута даны ссылки на нужные разделы. Callback отмечен как подготовка к дополнительному 7.B |
| Дополнительный опыт с Clear | Нехватка понимания повторов у студентов не подтверждена. Основные задания уже показывают новые запуски и retry; в 4.1 есть объяснение различия повторной загрузки и нового CSV | Обязательная практика сохранена: 16 основных и 18 дополнительных заданий. Опыт с Clear имеет смысл добавлять после наблюдения за студентами, если они путают эти случаи |
## Воспроизведение исходных ошибок
Код извлечен из Python-блоков глав на `f6b450c` и исполнен в одноразовом контейнере образа `airflow-manual-practice-check:2.9.2`, ранее собранного из Dockerfile стенда. Версия внутри контейнера: `2.9.2`.
В неполные примеры главы 7 переданы недостающие импорты и `default_args`, чтобы проверить именно привязку задач. Для итогового примера главы 9 `Variable.get` заменен на тестовое значение строки подключения: это позволило дойти до создания TaskGroup без подготовки посторонних данных.
| Пример | Результат до правок |
|---|---|
| `greeting_dag` | `AirflowException: Tried to create relationships between tasks that don't have DAGs yet` |
| `template_example` | Разбор без исключения, список задач пуст |
| `template_tracking_example` | То же исключение при создании зависимостей |
| `customer_pipeline_enhanced` | `AirflowException: TaskGroup can only be used inside a dag` |
## Проверка исправленных примеров
Полные Python-блоки текущих глав исполнены без дополнений к коду. Затем вызван `DAG.test()` для логической даты `2024-01-02 UTC`; SQL-пример дополнительно запущен для `2024-01-03 UTC`.
Окружение: тот же образ Airflow 2.9.2, отдельная SQLite для метаданных и отдельная PostgreSQL 16 с базой `training`. Подключение `my_postgres_conn` передано через окружение с полями из инструкции. Контейнеры работали во внутренней сети без опубликованных портов. Каталог репозитория смонтирован только для чтения. Временные контейнеры, сеть и данные удалены после проверки.
| Пример | Проверенный результат |
|---|---|
| `greeting_dag` | Две успешные задачи; функция приветствия исполнилась |
| `template_example` | Одна успешная задача; результат Bash - `2024-01-02` |
| `template_tracking_example` | Две успешные задачи; результаты Bash - `2024-01-02` и `Processing for 2024-01-02` |
| `connection_example` | Оба запуска успешны; `to_regclass` возвращает `connection_demo_users`, таблица содержит 0 строк |
| `task_group_example` | Все семь задач успешны; проверены полные идентификаторы и шесть зависимостей, показанных на схеме |
Все шесть DAG Run завершились успешно. Ссылки на файлы и разделы проверены после преобразования Markdown в HTML. Содержание упражнений после таблицы маршрута совпадает с исходной версией.
## Границы проверки
`DAG.test()` подтверждает разбор графа, выполнение задач, подстановку шаблонов и работу SQL. Этот прогон не проверяет работу планировщика по расписанию, форму Connections и отображение графов в UI. Основные DAG стенда не менялись; прежний [протокол практики](2026-09-28-practice.md) остается отдельной проверкой их поведения.
Трудность и время прохождения на студентах не измерялись. Педагогическое предположение о пользе нового опыта с Clear остается гипотезой, а не открытой ошибкой реализации.
+440
View File
@@ -0,0 +1,440 @@
# Практика по Apache Airflow
Здесь мы знакомимся с Airflow на готовых примерах: запускаем DAG, меняем задачи и смотрим, что произошло. Свой цельный процесс обработки данных вы соберете в курсовых работах дальше по программе.
## Как проходить практику
В каждом блоке сначала выполните основную практику. Задания "По желанию" находятся в конце блока: их можно пропустить и перейти дальше. Сложность указана отдельно. Если дополнительному заданию нужны результаты другого упражнения, это написано в его начале.
Основные задания одного блока выполняйте последовательно, сохраняя изменения. Перед новым блоком прочитайте условия начала. Загрузка CSV и проверки DQ используют одну таблицу, поэтому идут вместе.
| Блок | Примеры | Что наблюдаем | Перед практикой |
|---|---|---|---|
| [1. Первый DAG](#first-dag) | `hello_world_dag.py` | Задачи, зависимости, операторы, расписание | Главы 4 и 5; для 1.3 - [расписания][schedules] |
| [2. SQL и подключения](#sql) | `sql_basic_dag.py` | Connection, SQL, ошибка подключения | [Подключения][connections] |
| [3. Работа с файлами](#files) | `file_operations_dag.py` | Результаты отдельных задач | Главы 5 и 6 |
| [4. Загрузка и DQ](#load-dq) | `csv_to_postgres.py`, `csv_to_postgres_dq.py` | XCom, данные в БД, ошибка проверки | Глава 6, [шаблоны][templates] и [XCom][xcom] |
| [5. Разбор ETL](#etl) | `data_processing_dag.py` | Параллельные шаги и путь данных | Глава 5 |
| [6. Ветвление](#branching) | `branching_dag.py` | Variables, выбранная ветка, `skipped` | Глава 6 и [Variables][variables] |
| [7. Ошибки и повторы](#errors) | `error_handling_dag.py` | Попытки, обработчики, итог запуска | Глава 6; для 7.B - [callback][alerts] |
| [8. Пулы и группировка](#resources) | `resource_management_dag.py` | Слоты, XCom, TaskGroup | [Пулы][pools], [XCom][xcom] и [TaskGroup][taskgroups] |
[schedules]: 08%20-%20Управление%20временем.md#настройка-автоматических-запусков
[connections]: 07%20-%20Шаблоны,%20переменные%20и%20подключения%20в%20Airflow.md#централизованные-подключения-connections
[templates]: 07%20-%20Шаблоны,%20переменные%20и%20подключения%20в%20Airflow.md#динамические-шаблоны-airflow-на-основе-jinja
[variables]: 07%20-%20Шаблоны,%20переменные%20и%20подключения%20в%20Airflow.md#безопасные-переменные-variables
[xcom]: 09%20-%20Пулы,%20XCom,%20TaskGroup%20и%20алертинг%20в%20Airflow.md#обмен-данными-между-задачами-xcom-и-контекст-выполнения
[pools]: 09%20-%20Пулы,%20XCom,%20TaskGroup%20и%20алертинг%20в%20Airflow.md#управление-ресурсами-с-помощью-пулов-задач
[taskgroups]: 09%20-%20Пулы,%20XCom,%20TaskGroup%20и%20алертинг%20в%20Airflow.md#группировка-задач-с-помощью-taskgroup
[alerts]: 09%20-%20Пулы,%20XCom,%20TaskGroup%20и%20алертинг%20в%20Airflow.md#система-оповещений-алертинг
Главы доступны в [оглавлении учебника](README.md). Инструкция запуска и параметры учебной БД находятся в [README стенда](airflow-docker/README.md), файлы примеров - в [airflow-docker/dags/](airflow-docker/dags/).
Откройте http://localhost:8080, войдите как `admin` / `admin`. Для выбранного DAG включите переключатель и нажмите Trigger DAG. Дождитесь завершения запуска, затем откройте его граф и логи задач. После правки Python-файла дождитесь обновления вкладки Code; новый запуск должен использовать новый код. Если DAG исчез или перестал обновляться, проверьте Import Errors и логи scheduler.
Каталог `/opt/airflow/data/` в контейнере соответствует `airflow-docker/data/` на компьютере. Команды ниже выполняются из `airflow-docker`. Подойдет терминал Linux или PowerShell в Windows.
Для SQL можно открыть консоль учебной БД:
```bash
docker compose exec postgres-training psql -U student -d training
```
В ней выполняются SQL-запросы из заданий; выход - `\q`. Сохраняйте исходные DAG перед опытами вне каталога `dags`: две Python-копии с одинаковым `dag_id` мешают Airflow выбрать нужную.
<a id="first-dag"></a>
## 1. Первый DAG
Начните с исходного `hello_world_dag.py`. В нем три задачи: `start_task`, `date_task`, `end_task`. Включение DAG с расписанием может создать автоматический запуск; для опытов выбирайте свой ручной запуск.
### 1.1. Добавить задачу
Основная практика. Сложность: начальная.
Запустите исходный DAG и найдите сообщения всех трех задач. Переведите приветствие на русский, затем добавьте функцию с фиктивной погодой и `PythonOperator` с `task_id="weather_task"`. Включите ее между `start_task` и `date_task`, изменив существующую цепочку зависимостей.
**Что должно получиться:** в новом запуске четыре задачи со статусом `success`, а в логе `weather_task` есть выбранный вами текст. В графе видно, какая задача должна закончиться перед ней.
### 1.2. Создать файл через BashOperator
Основная практика. Сложность: начальная.
Добавьте между `date_task` и `end_task` задачу `write_note`. Импорт `BashOperator` уже есть. В `bash_command` запишите команду `echo 'Airflow works' > /opt/airflow/data/output/note.txt`.
После запуска проверьте файл:
```bash
docker compose exec airflow-webserver cat /opt/airflow/data/output/note.txt
```
**Что должно получиться:** пять успешных задач и файл с текстом `Airflow works`. При повторном запуске этот файл перезаписывается.
### 1.3. Посмотреть расписание
Основная практика. Сложность: начальная.
Замените `schedule_interval` на `'0 0 * * 1'`. Найдите новое расписание и следующий автоматический запуск в UI. Сопоставьте дату запуска с часовым поясом DAG. `catchup=False` оставьте как есть.
**Что должно получиться:** UI показывает запуск по понедельникам в 00:00 в часовом поясе DAG. Ручной запуск по кнопке по-прежнему выполняется сразу, когда доступны ресурсы. Для проверки расписания ждать понедельника не нужно. После опыта выключите переключатель DAG.
### 1.A. Ограничить одновременные запуски
По желанию. Сложность: средняя.
В `DAG(...)` задайте `max_active_runs=1`. В одну Python-функцию временно добавьте `time.sleep(30)` с импортом `time`. Включите DAG и запустите его вручную дважды с разными Run Id.
**Что должно получиться:** пока первый запуск выполняет задержку, второй не начинает свои задачи. После первого он продолжает работу. Удалите задержку и выключите DAG; ограничение можно оставить.
<a id="sql"></a>
## 2. SQL и подключения
Используйте исходный `sql_basic_dag.py`. Он создает `public.students_sample`, очищает ее перед вставкой и добавляет трех студентов. После выполнения данные остаются для проверки. Повторный полный запуск снова оставит три строки. Остальные таблицы учебной БД этот DAG не меняет.
### 2.1. Подключиться к учебной БД
Основная практика. Сложность: начальная.
Запустите DAG с готовым соединением `postgres_training`. В Admin > Connections создайте `student_postgres`: Connection Type = Postgres, Host = `postgres-training`, Database/Schema = `training`, Login = `student`, Password = `student`, Port = `5432`. Замените `postgres_conn_id` во всех SQL-задачах на свой Conn Id.
Проверьте таблицу:
```sql
SELECT name, age FROM public.students_sample ORDER BY name;
```
**Что должно получиться:** Alice - 22, Bob - 24, Charlie - 21. Новый запуск с вашим Connection также успешен.
Чтобы увидеть ошибку подключения, временно укажите несуществующий Conn Id и поставьте `retries=0` в `default_args`. Запустите DAG и найдите причину в логе первой задачи. Исправьте Conn Id, верните исходное число повторов и создайте новый запуск. Ручная отметка задачи как успешной не проверяет подключение.
### 2.2. Добавить SQL-задачу
Основная практика. Сложность: средняя.
Добавьте `PostgresOperator` с `task_id="update_student"` между `insert_data` и `query_data`. Его SQL должен установить Alice возраст 25. Используйте то же соединение, что и остальные задачи.
**Что должно получиться:** после запуска у Alice возраст 25, остальные строки не изменились. Повторный полный запуск дает те же три строки: сначала DAG очищает таблицу, потом вставляет данные и выполняет UPDATE.
### 2.A. Добавить колонку
По желанию. Сложность: средняя. Начните после 2.2.
Добавьте `email VARCHAR(100)` в `create_table_sql` и заполните его в `insert_data_sql`. Для уже созданной таблицы сначала выполните:
```sql
ALTER TABLE public.students_sample ADD COLUMN email VARCHAR(100);
```
Команда нужна один раз. `CREATE TABLE IF NOT EXISTS` не меняет существующую схему, а `TRUNCATE` очищает только строки.
**Что должно получиться:** после запуска в таблице три строки с непустым `email`. Задание 2.B можно выполнять и без этой колонки. Удаление таблицы или всей базы для опыта не требуется.
### 2.B. Сохранить отчет
По желанию. Сложность: средняя. Требуется 2.2; задание 2.A можно пропустить.
После `query_data` добавьте Python-задачу. Через `PostgresHook` получите количество студентов, средний возраст и количество студентов каждого возраста. Запишите результат в `/opt/airflow/data/output/students_report.txt`. У хука есть методы `get_first()` и `get_records()`; они позволяют обойтись без ручной работы с курсором.
**Что должно получиться:** в файле 3 студента, средний возраст около 23.33 и по одному студенту возрастов 21, 24 и 25. Эти числа должны совпасть с SQL по `students_sample`.
<a id="files"></a>
## 3. Работа с файлами
Начните с исходного `file_operations_dag.py`. Он сам создает 100 строк в `input/sample_data.csv`; данные каждого полного запуска могут отличаться. Чужие входные файлы для этого блока не нужны.
### 3.1. Изменить преобразование
Основная практика. Сложность: начальная.
Запустите DAG, откройте `data/output/processed_data.csv` и `summary.txt`. Найдите в `transform_data()` правило вычисления `salary_category`. Измените порог категории High с 70000 на 80000, оставив границу Medium равной 50000. Запустите DAG еще раз.
**Что должно получиться:** в обработанном CSV 100 строк, у каждой строки с зарплатой от 80000 категория High. Сумма количеств High, Medium и Low в отчете равна 100. Сверяйте отчет с CSV того же запуска: случайные данные не позволяют сравнивать только числа из двух отчетов.
### 3.A. Добавить отдел и опыт
По желанию. Сложность: средняя.
В `generate_sample_data()` добавьте `department`, `experience_years`, `education_level`. Отдел выбирайте из `sales`, `support`, `engineering`; опыт должен быть от 0 до `age - 18`. Дополните `read_and_validate_data()` проверками новых полей. В `transform_data()` сохраните сотрудников с опытом больше трех лет в отдельный `output/experienced.csv`, сохранив полный `processed_data.csv`.
**Что должно получиться:** основной файл по-прежнему содержит 100 строк, опыт у всех допустимый, а в `experienced.csv` нет опыта 3 года и меньше.
### 3.B. Отчет по отделам
По желанию. Сложность: средняя. Требуется 3.A.
Добавьте задачу после `write_summary`. Она читает полный `processed_data.csv` и пишет `output/departments.json`: для каждого отдела количество сотрудников и среднюю зарплату. Для расчета подойдет pandas `groupby`, для записи - `to_json`.
**Что должно получиться:** сумма численности отделов равна 100, средние зарплаты совпадают с группировкой исходного CSV. График для этого задания не требуется.
### 3.C. Разделить обработку по отделам
По желанию. Сложность: высокая. Требуется 3.A, отчет 3.B необязателен.
После `transform_data` поставьте три параллельные задачи. Каждая читает полный `processed_data.csv`, отбирает свой отдел, проверяет уникальность `id` и сохраняет отдельный файл. После них добавьте сборку этих трех файлов и проверьте количество строк и уникальность `id` уже в общем результате. Не перезаписывайте исходный файл из параллельных задач.
Для опыта с повтором одна из задач должна намеренно падать только на первой попытке; используйте `ti.try_number`, `retries=1` и `retry_delay=timedelta(seconds=2)`. Этот прием разобран в блоке 7.
**Что должно получиться:** объединенный результат содержит те же 100 уникальных `id`. В графе три задачи имеют общего предшественника, но не зависят друг от друга. Повтор не добавляет дубликаты: файл отдела перезаписывается.
<a id="load-dq"></a>
## 4. Загрузка CSV и проверка качества
Здесь работают два DAG и одна таблица `public.orders`. Начните с исходных `csv_to_postgres.py` и `csv_to_postgres_dq.py`. SQL-блок использовал другую таблицу. Сначала выполняйте основные задания, затем расширяйте схему по желанию.
### 4.1. Проследить загрузку и XCom
Основная практика. Сложность: начальная.
Запустите `csv_to_postgres`. В задаче `generate_csv` откройте вкладку XCom с ключом `return_value`: там путь к CSV. Найдите этот файл в `data/output`. Затем выберите `load_csv_to_postgres`, откройте Details и прокрутите до Rendered Templates. В `opKwargs` вместо шаблона параметра `csv_path` появился путь к файлу. Часть пути в UI может выглядеть как `***`; сравните имя CSV с XCom.
Проверьте результат в БД:
```sql
SELECT COUNT(*) FROM public.orders;
SELECT order_id, amount FROM public.orders ORDER BY order_id LIMIT 5;
```
**Что должно получиться:** первый запуск на пустой таблице загружает 1000 строк. В XCom передается путь к файлу, сами строки хранятся в CSV и PostgreSQL. Каждый новый полный запуск генерирует новые заказы; после двух последовательных запусков на изначально пустой таблице будет 2000 строк. Это отличается от повторного выполнения только задачи загрузки с тем же CSV.
### 4.2. Добавить проверку и увидеть ошибку
Основная практика. Сложность: средняя.
После успешной загрузки отдельно запустите `csv_to_postgres_dq`. Автоматической связи между этими двумя DAG нет.
По образцу `_check_has_rows()` добавьте функцию и Python-задачу `check_positive_amount` перед `data_quality_summary`. Проверьте число строк, где `amount <= 0`, и бросьте `ValueError`, если такие строки есть. Новую задачу включите в существующую цепочку.
**Что должно получиться на корректных данных:** все задачи и DAG Run успешны, сводка перечисляет результаты проверок.
Для отрицательного сценария поменяйте знак суммы одного заказа:
```sql
UPDATE public.orders SET amount = -amount
WHERE order_id = (SELECT MIN(order_id) FROM public.orders);
```
Запустите только DQ-DAG. Новая проверка должна получить `failed`, сводка - `upstream_failed`, весь DAG Run - `failed`. В ее логе должно быть понятно, какое условие нарушено. У DQ-задач нет повторов: повторный запуск с теми же неверными данными не исправляет ошибку.
Восстановите сумму тем же UPDATE еще один раз и повторите DQ. Дождитесь успешного результата перед продолжением. Во время опыта не запускайте загрузчик и не удаляйте строки: восстановление относится к тому же минимальному `order_id`.
### 4.A. Провести новое поле через загрузку
По желанию. Сложность: высокая. Требуется завершенный основной блок 4.
Добавьте `status` со значениями `NEW`, `PROCESSING`, `COMPLETED`. Поле нужно провести через четыре участка: генератор CSV, DDL целевой таблицы, список колонок COPY и обе части итогового `INSERT ... SELECT` в `_load_csv()`. Во всех списках добавляйте его в конец. Порядок колонок CSV должен совпадать с COPY: `CSV HEADER` пропускает первую строку, но не сопоставляет поля по ее именам.
В DDL используйте `status VARCHAR(20) NOT NULL DEFAULT 'NEW'`. Существующую таблицу подготовьте один раз:
```sql
ALTER TABLE public.orders
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';
```
В `EXPECTED_ORDERS_SCHEMA` DQ-DAG добавьте в конец `("status", "character varying")`. После загрузки проверьте значения `status` в БД и выполните DQ.
**Что должно получиться:** новые заказы содержат статусы из CSV, старые получили `NEW`, проверка схемы проходит. Проверяйте значения, а не только успешность задачи: забытый `status` в INSERT может оставить всем строкам значение по умолчанию.
Завершив опыт, удалите `status` из генератора, DDL, COPY и обеих частей INSERT ... SELECT. Уберите его и из `EXPECTED_ORDERS_SCHEMA` в DQ. Затем выполните `ALTER TABLE public.orders DROP COLUMN status;` и запустите DQ. Проверку положительной суммы из 4.2 сохраните.
### 4.B. Заменить PythonOperator на PostgresOperator
По желанию. Сложность: средняя. Используйте базовую схему после 4.2 или после восстановления в 4.A.
Замените задачу `create_orders_table` на `PostgresOperator` с тем же `task_id`, SQL создания таблицы и соединением `postgres_training`. Образец оператора есть в SQL-блоке. `_get_conn()` оставьте: загрузчик продолжает использовать эту функцию.
**Что должно получиться:** после замены DAG загружает новую порцию, затем DQ проходит. Имя задачи и цепочка зависимостей сохраняются.
### 4.C. Выполнить все проверки после ошибки
По желанию. Сложность: высокая. Требуется 4.2.
Временно замените ожидаемый тип `amount` в `EXPECTED_ORDERS_SCHEMA` на `integer`. Сначала запустите исходную цепочку: `check_orders_schema` упадет, последующие задачи получат `upstream_failed`.
У задач после проверки схемы, включая сводку, задайте `trigger_rule="all_done"`. Повторите запуск. Прочитайте `_log_dq_summary()`: она собирает состояния всех проверок и сама падает, если хотя бы одна неуспешна.
**Что должно получиться:** проверки строк, дубликатов и положительной суммы выполняются даже после ошибки схемы. Сводка и DAG Run остаются `failed`. Успешная завершающая задача с `all_done` могла бы скрыть отказ внутри DAG; именно поэтому сводка проверяет результат.
Верните тип `numeric` и исходные `trigger_rule`, затем выполните успешный DQ-запуск.
<a id="etl"></a>
## 5. Разбор ETL-примера
Начните с исходного `data_processing_dag.py`. Он сам создает свои `customers.csv` и `orders.csv`; таблица `public.orders` из блока 4 ему не нужна. Шаг `simulate_load` показывает подготовку к загрузке и печатает пример DDL. Записи в БД в этом DAG нет.
### 5.1. Проследить путь данных
Основная практика. Сложность: начальная.
Запустите DAG. Найдите две задачи извлечения и их файлы `output/extracted_customers.csv`, `output/extracted_orders.csv`. Проверьте, что `transform_data()` читает именно их. В графе обе задачи извлечения могут выполняться параллельно, а преобразование ждет обе.
В `extract_orders()` временно оставьте только заказы с `amount >= 100` перед сохранением файла. Запустите DAG снова и сравните отчет с результатом до изменения.
**Что должно получиться:** исходный отчет содержит 6 заказов с выручкой 2105; после фильтра - 4 заказа с выручкой 2000. Лог `simulate_load` явно сообщает об имитации. Верните фильтр к исходному состоянию и запустите DAG перед дополнительными заданиями.
### 5.A. Добавить справочник продуктов
По желанию. Сложность: средняя.
В `create_sample_data()` добавьте `input/products.csv` с полями `product`, `category`, `weight_kg`. Для каждого из шести продуктов в заказах должна быть одна строка справочника. Добавьте параллельную задачу `extract_products`, сохраняющую `output/extracted_products.csv`, и подключите ее к `transform_data`. Объедините извлеченные заказы со справочником по `product`.
**Что должно получиться:** в результате 6 заказов, у всех заполнена категория, общая выручка осталась 2105. Если JOIN размножил строки или потерял заказы, проверьте уникальность и полноту справочника.
### 5.B. Расширить отчет
По желанию. Сложность: средняя. Справочник из 5.A не нужен.
В `generate_report()` добавьте топ-3 заказа по сумме, выручку по месяцам и среднюю сумму заказа каждого клиента. Сохраните `output/detailed_report.txt`.
**Что должно получиться:** топ-3 суммы - 1200, 400 и 300; все заказы относятся к октябрю 2023 года; средняя сумма заказа Alice Johnson равна 640. У исходного набора всего шесть строк, поэтому результат можно сверить вручную.
### 5.C. Валидация и архив
По желанию. Сложность: высокая.
Между преобразованием и `simulate_load` добавьте проверку: результат непустой, `customer_id` и `name` заполнены, суммы положительны. Для этой задачи задайте `retry_delay=timedelta(seconds=2)`, сохранив один повтор. В `create_sample_data()` временно укажите для одного заказа несуществующий `customer_id`. Настройте `on_failure_callback`, который пишет в лог идентификатор задачи и причину ошибки; образец есть в задании 7.B.
После `generate_report` добавьте копирование `transformed_data.csv` в архивный файл с датой и временем в имени. Для этого подойдет `shutil.copy`.
**Что должно получиться:** после исчерпания повторов проверка падает, последующие шаги получают `upstream_failed`, новый архив не появляется. После возврата клиента к исходному значению весь DAG успешен, а содержимое нового архива совпадает с результатом запуска. Сообщение callback этой проверки находится в логе ее последней попытки.
<a id="branching"></a>
## 6. Ветвление
Используйте исходный `branching_dag.py`. Ветки пока имитируют обработку, входные файлы не нужны. Дождитесь завершения каждого запуска перед изменением Variable.
### 6.1. Выбрать ветку через Variable
Основная практика. Сложность: средняя.
В Admin > Variables создайте ключ `branch_format` со значением `csv`. Запустите DAG, затем смените значение на `json` и сделайте новый запуск. Посмотрите граф и лог `merge_results`.
**Что должно получиться:** при `csv` CSV-ветка получает `success`, JSON-ветка - `skipped`; при `json` наоборот. `merge_results` выполняется в обоих случаях и читает XCom только текущего запуска. Правило `none_failed_min_one_success` допускает пропущенную ветку, если другая успешна.
Для третьего запуска задайте `xml`. `choose_format` должен упасть с понятным сообщением, DAG Run - завершиться ошибкой. Верните `branch_format=csv` и проверьте успешный запуск.
### 6.A. Добавить ветку ручной проверки
По желанию. Сложность: высокая.
Добавьте задачу `review_branch` и значение `review` в выбор формата. Для неизвестного значения направляйте выполнение в эту же ветку. Возвращайте из нее строку `review`, включите ее в зависимости перед слиянием и в список `task_ids` при чтении XCom в `merge_results`.
**Что должно получиться:** при `review` или неизвестном формате выполняется только новая ветка; две остальные пропущены, слияние и весь запуск успешны. Проверьте, что `csv` и `json` продолжают работать. Верните значение `csv`.
### 6.B. Прочитать CSV и JSON
По желанию. Сложность: высокая. Третья ветка из 6.A не требуется.
Создайте два входных файла в `data/input`: `branch_records.csv` и `branch_records.json`. Оба должны содержать две записи с `id` 1 и 2 и суммами `amount` 10 и 20. В функциях веток прочитайте соответствующий файл, проверьте положительность сумм и верните словарь с количеством записей и общей суммой. Слияние уже читает XCom выбранной ветки.
**Что должно получиться:** отдельные запуски для CSV и JSON дают по 2 записи и сумму 30. Сравните их логи вручную. В каждом запуске работает одна ветка; файл результата другой ветки из старого запуска для сравнения не нужен.
<a id="errors"></a>
## 7. Ошибки и повторы
Используйте исходный `error_handling_dag.py`. Режим задается Variable `error_mode`; по умолчанию это `retry_once`. Обе рабочие задачи имеют одну повторную попытку с задержкой 10 секунд. Каждый сценарий начинайте новым запуском и ждите его завершения перед сменой режима.
### 7.1. Увидеть повторную попытку
Основная практика. Сложность: начальная.
Установите `error_mode=retry_once` и запустите DAG. Откройте `retry_task` и логи ее попыток. Первая намеренно падает, вторая завершается успешно. Состояние `up_for_retry` видно во время ожидания; если оно уже сменилось, причину и номера попыток можно найти в логах.
**Что должно получиться:** `unreliable_task`, `retry_task` и `success_handler` успешны, `failure_handler` пропущен, весь DAG Run успешен. В логе первой попытки есть временная учебная ошибка, во второй - сообщение о выполнении. `retries=1` означает две попытки всего.
### 7.2. Проверить окончательную ошибку
Основная практика. Сложность: средняя.
Добавьте `retries=0` непосредственно в оператор `retry_task`, дождитесь обновления кода и создайте новый запуск в режиме `retry_once`.
**Что должно получиться:** `retry_task` получает `failed`, `success_handler` - `upstream_failed`, а `failure_handler` выполняется. Весь DAG Run остается `failed`, хотя обработчик ошибки выполнился успешно.
Уберите локальное переопределение `retries`. Теперь по очереди запустите режимы `fail_first` и `fail_second`. Обработчик должен заметить отказ любой из двух задач. В графе проверьте, что каждый обработчик зависит от обеих рабочих задач. Верните `error_mode=retry_once` и получите успешный запуск.
### 7.3. Добавить свое сообщение в лог
Основная практика. Сложность: начальная.
В `handle_success()` добавьте `logging.info()` с собственным сообщением о завершении. У `logging` есть уровни INFO, WARNING и ERROR; `print` тоже попадает в лог, но не позволяет явно выбрать уровень сообщения.
**Что должно получиться:** после успешного запуска сообщение есть в логе `success_handler`. При окончательном отказе эта функция не выполняется, поэтому сообщения о всеобщем успехе нет.
### 7.A. Сравнить задержки
По желанию. Сложность: высокая.
В режиме `fail_second` задайте для `retry_task` три повтора, `retry_delay=timedelta(seconds=5)` и `retry_exponential_backoff=True`. Сопоставьте время окончания неудачной попытки с началом следующей. Задержка увеличивается; планировщик может добавить время ожидания, поэтому точного удвоения по часам требовать не нужно.
**Что должно получиться:** четыре неуспешные попытки, после них `failure_handler` и итог `failed`. Верните параметры повторов к исходным и режим к `retry_once`.
### 7.B. Добавить callback
По желанию. Сложность: высокая.
Добавьте функцию `notify_failure(context)` и укажите ее в `on_failure_callback` рабочих задач. В ней используйте `logging.error` и выведите `context['ti'].task_id` и `context['exception']`. В разных опытах бросьте `ConnectionError`, `ValueError` и `OSError` с различимыми сообщениями. Настройка настоящей почты для этого задания не нужна.
**Что должно получиться:** после исчерпания повторов callback сообщает, какая задача и почему упала. При временной ошибке с последующим успехом он не вызывается. В этом примере callback назначен оператору: откройте Logs упавшей задачи и выберите последнюю попытку. После сообщения об отказе найдите свой текст из callback.
Если нужно наблюдать каждый повтор, для этого есть отдельный `on_retry_callback`. Верните исходные исключения и `error_mode=retry_once` перед переходом дальше.
<a id="resources"></a>
## 8. Пулы, XCom и TaskGroup
Используйте исходный `resource_management_dag.py`. Он имитирует чтение двух источников по 20 секунд и передает небольшие числа через XCom. Ни таблицы, ни файлы из предыдущих заданий ему не нужны.
### 8.1. Ограничить доступные слоты
Основная практика. Сложность: средняя.
Перед запуском в Admin > Pools создайте `training_pool` с двумя слотами. В DAG задача `read_customers` занимает два слота, `read_orders` - один. Запустите DAG и наблюдайте за задачами и пулом.
**Что должно получиться:** эти две задачи выполняются последовательно, хотя между ними нет зависимости: вместе им нужны три слота. Во время выполнения одной другая ожидает. Если пул не создан, задачи тоже будут ждать, поэтому сначала проверьте его имя и размер.
Поменяйте `pool_slots` у `read_customers` на 1 и повторите запуск. Теперь обе задачи могут работать одновременно, если свободны ресурсы executor. Сравните интервалы выполнения, затем верните значение 2.
### 8.2. Добавить метрику в XCom
Основная практика. Сложность: средняя.
В UI откройте XCom задачи `calculate_metrics`: исходный словарь содержит `customers=20` и `orders=30`. Добавьте в возвращаемый словарь `total_rows`, равный сумме этих двух значений. Запустите DAG и проверьте XCom и лог `log_metrics`.
**Что должно получиться:** в обоих местах `total_rows=50`. Возвращаемое Python-функцией значение сохранено под ключом `return_value` и доступно следующей задаче через `xcom_pull`.
### 8.3. Сгруппировать задачи
Основная практика. Сложность: средняя.
Импортируйте `TaskGroup` из `airflow.utils.task_group`. Внутри блока `with DAG(...)` поместите определения двух задач чтения в `with TaskGroup("extract"):`. Метрики и лог оставьте снаружи, зависимости между задачами сохраните.
После группировки полные идентификаторы станут `extract.read_customers` и `extract.read_orders`. Обновите обе ссылки в `calculate_metrics()`.
**Что должно получиться:** в графе появилась сворачиваемая группа. Нажмите на ее название со стрелкой, чтобы увидеть две задачи внутри. Метрика по-прежнему равна 50; ограничения пула продолжают действовать.
### 8.A. Подставить XCom в шаблон
По желанию. Сложность: средняя. Требуется 8.2; группировку 8.3 можно сохранить.
Добавьте после `calculate_metrics` задачу с `BashOperator`. В `bash_command` подставьте `{{ ti.xcom_pull(task_ids='calculate_metrics')['total_rows'] }}` в команду `echo`. Назовите задачу `print_total`.
**Что должно получиться:** в Details задачи `print_total`, в разделе Rendered Templates, уже подставлено число 50. В логе команды тоже 50.
### 8.B. Отправить уведомление
По желанию. Сложность: высокая. Требуется 7.B: вернитесь к `error_handling_dag.py` с callback отказа. Нужен отдельно настроенный SMTP и адрес, на который вы можете отправить тестовое письмо. В стандартном стенде SMTP не настроен; отсутствие письма здесь не означает ошибку основного маршрута.
Используйте пример из [раздела об алертинге](09%20-%20Пулы,%20XCom,%20TaskGroup%20и%20алертинг%20в%20Airflow.md#система-оповещений-алертинг). Добавьте отправку письма в callback отказа. Укажите DAG, задачу, идентификатор запуска и причину ошибки. Для успешного DAG Run задайте отдельный `on_success_callback` у `DAG(...)`.
**Что должно получиться:** после постоянной ошибки приходит сообщение об отказе, после успешного запуска - об успехе. Уведомление не меняет результат обработки данных. Запуск с отказом сохраняет `failed`; ошибку доставки ищите в логах callback. Возвращение к основной практике не требует настройки SMTP: отключите почтовый callback и верните исходный режим ошибок.
## Когда можно двигаться дальше
Основная практика закончена, когда вы можете найти свой запуск и его логи, добавить задачу по образцу, объяснить выбранную ветку и повторную попытку, проверить файл или строки в таблице и прочитать XCom. По последнему блоку достаточно показать ожидание слотов и работающую TaskGroup.
Задания по желанию расширяют эти опыты. Их выполнение не требуется для перехода к следующему блоку программы.