diff --git a/02 - Понятное введение в ключевые понятия Airflow.md b/02 - Понятное введение в ключевые понятия Airflow.md
index ccac530..3050d96 100644
--- a/02 - Понятное введение в ключевые понятия Airflow.md
+++ b/02 - Понятное введение в ключевые понятия Airflow.md
@@ -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 предоставляет множество готовых операторов:
@@ -157,4 +159,4 @@ flowchart LR
Task1 -.->|Как делать?| Op1["Оператор\n(например, PythonOperator)"]
Task2 -.->|Чего ждать?| Sen1["Сенсор\n(например, S3Sensor)"]
Task3 -.->|Как делать?| Op2["Оператор\n(например, PostgresOperator)"]
-```
\ No newline at end of file
+```
diff --git a/07 - Шаблоны, переменные и подключения в Airflow.md b/07 - Шаблоны, переменные и подключения в Airflow.md
index 618fc87..5eb5226 100644
--- a/07 - Шаблоны, переменные и подключения в Airflow.md
+++ b/07 - Шаблоны, переменные и подключения в Airflow.md
@@ -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).
diff --git a/09 - Пулы, XCom, TaskGroup и алертинг в Airflow.md b/09 - Пулы, XCom, TaskGroup и алертинг в Airflow.md
index 8e1443b..7f1f58b 100644
--- a/09 - Пулы, XCom, TaskGroup и алертинг в Airflow.md
+++ b/09 - Пулы, XCom, TaskGroup и алертинг в Airflow.md
@@ -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"""
- Привет,
- Я закончил работу над расчётом за "{calculation_dt}". Это заняло {duration} часов/минут/секунд.
- Логи и запуски тоже можно посмотреть тут.
-
-
-
-
- Навеки твой,
- Airflow бот
- """
-
- 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"
Запуск: {escape(context['run_id'])}
"
+ f"Причина: {escape(str(context['exception']))}
"
+ f'Лог задачи
'
+ ),
)
- # Агрегация и запись данных
- 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"Запуск: {escape(context['run_id'])}
",
+ )
```
-Поскольку последовательность задач внутри групп указывается при их создании, в конце необходимо определить порядок выполнения самих групп, чтобы планировщик понимал общую логику выполнения.
+Для примера с ошибками задайте `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). Отправка настоящей почты остается заданием по желанию.
diff --git a/AGENTS.md b/AGENTS.md
index 994938c..5dea9cd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`
diff --git a/README.md b/README.md
index 66660a9..df810e8 100644
--- a/README.md
+++ b/README.md
@@ -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.*
diff --git a/_attachments/practice-failed-run.png b/_attachments/practice-failed-run.png
new file mode 100644
index 0000000..19359ba
Binary files /dev/null and b/_attachments/practice-failed-run.png differ
diff --git a/_attachments/practice-rendered-template.png b/_attachments/practice-rendered-template.png
new file mode 100644
index 0000000..377edb1
Binary files /dev/null and b/_attachments/practice-rendered-template.png differ
diff --git a/_attachments/practice-task-group.png b/_attachments/practice-task-group.png
new file mode 100644
index 0000000..ed121fb
Binary files /dev/null and b/_attachments/practice-task-group.png differ
diff --git a/airflow-docker/README.md b/airflow-docker/README.md
index a08140f..ef84db7 100644
--- a/airflow-docker/README.md
+++ b/airflow-docker/README.md
@@ -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["Каталог
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["Пользователь
(браузер)"]
- 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) сохраняет согласованные решения, разбор исходных заданий и результаты проверок.
diff --git a/airflow-docker/dag-specifications.md b/airflow-docker/dag-specifications.md
index ab674da..127a732 100644
--- a/airflow-docker/dag-specifications.md
+++ b/airflow-docker/dag-specifications.md
@@ -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`: достаточно одного окончательного отказа, ждать завершения другой рабочей задачи ему не обязательно.
diff --git a/airflow-docker/dags/branching_dag.py b/airflow-docker/dags/branching_dag.py
index 3b7b2e2..b30587e 100644
--- a/airflow-docker/dags/branching_dag.py
+++ b/airflow-docker/dags/branching_dag.py
@@ -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
\ No newline at end of file
+ start_task >> choose_task >> [process_csv_task, process_json_task]
+ [process_csv_task, process_json_task] >> merge_task >> end_task
diff --git a/airflow-docker/dags/csv_to_postgres.py b/airflow-docker/dags/csv_to_postgres.py
index 9aa14c2..76e8fe0 100644
--- a/airflow-docker/dags/csv_to_postgres.py
+++ b/airflow-docker/dags/csv_to_postgres.py
@@ -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,
diff --git a/airflow-docker/dags/csv_to_postgres_dq.py b/airflow-docker/dags/csv_to_postgres_dq.py
index 83822ed..30ee234 100644
--- a/airflow-docker/dags/csv_to_postgres_dq.py
+++ b/airflow-docker/dags/csv_to_postgres_dq.py
@@ -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",
diff --git a/airflow-docker/dags/data_processing_dag.py b/airflow-docker/dags/data_processing_dag.py
index 4086e84..a2dbec8 100644
--- a/airflow-docker/dags/data_processing_dag.py
+++ b/airflow-docker/dags/data_processing_dag.py
@@ -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
)
@@ -197,4 +196,4 @@ report_task = PythonOperator(
)
# Установка зависимостей
-create_data_task >> [extract_customers_task, extract_orders_task] >> transform_task >> load_task >> report_task
\ No newline at end of file
+create_data_task >> [extract_customers_task, extract_orders_task] >> transform_task >> load_task >> report_task
diff --git a/airflow-docker/dags/error_handling_dag.py b/airflow-docker/dags/error_handling_dag.py
index 012f978..3fb7b2c 100644
--- a/airflow-docker/dags/error_handling_dag.py
+++ b/airflow-docker/dags/error_handling_dag.py
@@ -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.
diff --git a/airflow-docker/dags/resource_management_dag.py b/airflow-docker/dags/resource_management_dag.py
new file mode 100644
index 0000000..7ad4fd7
--- /dev/null
+++ b/airflow-docker/dags/resource_management_dag.py
@@ -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
diff --git a/airflow-docker/dags/sql_basic_dag.py b/airflow-docker/dags/sql_basic_dag.py
index fd7b655..4c1ff54 100644
--- a/airflow-docker/dags/sql_basic_dag.py
+++ b/airflow-docker/dags/sql_basic_dag.py
@@ -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
diff --git a/airflow-docker/educational-tasks.md b/airflow-docker/educational-tasks.md
deleted file mode 100644
index a40dbad..0000000
--- a/airflow-docker/educational-tasks.md
+++ /dev/null
@@ -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!
diff --git a/docs/specs/2026-09-28-practice-improvement-plan.md b/docs/specs/2026-09-28-practice-improvement-plan.md
new file mode 100644
index 0000000..9127f9a
--- /dev/null
+++ b/docs/specs/2026-09-28-practice-improvement-plan.md
@@ -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.
diff --git a/docs/validation/2026-09-28-practice-runs.jsonl b/docs/validation/2026-09-28-practice-runs.jsonl
new file mode 100644
index 0000000..dc4cfbb
--- /dev/null
+++ b/docs/validation/2026-09-28-practice-runs.jsonl
@@ -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"}}}
diff --git a/docs/validation/2026-09-28-practice.md b/docs/validation/2026-09-28-practice.md
new file mode 100644
index 0000000..1327230
--- /dev/null
+++ b/docs/validation/2026-09-28-practice.md
@@ -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.
diff --git a/docs/validation/2026-09-28-theory-review.md b/docs/validation/2026-09-28-theory-review.md
new file mode 100644
index 0000000..5ece226
--- /dev/null
+++ b/docs/validation/2026-09-28-theory-review.md
@@ -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 остается гипотезой, а не открытой ошибкой реализации.
diff --git a/educational-tasks.md b/educational-tasks.md
new file mode 100644
index 0000000..d4296dd
--- /dev/null
+++ b/educational-tasks.md
@@ -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 выбрать нужную.
+
+
+
+## 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; ограничение можно оставить.
+
+
+
+## 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`.
+
+
+
+## 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`. В графе три задачи имеют общего предшественника, но не зависят друг от друга. Повтор не добавляет дубликаты: файл отдела перезаписывается.
+
+
+
+## 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-запуск.
+
+
+
+## 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 этой проверки находится в логе ее последней попытки.
+
+
+
+## 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. Сравните их логи вручную. В каждом запуске работает одна ветка; файл результата другой ветки из старого запуска для сравнения не нужен.
+
+
+
+## 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` перед переходом дальше.
+
+
+
+## 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.
+
+Задания по желанию расширяют эти опыты. Их выполнение не требуется для перехода к следующему блоку программы.