9 Commits
Author SHA1 Message Date
ddadminandClaude Opus 5 4ed7e6f43e docs(agents): метки триажа заведены в Gitea
Все пять меток (needs-triage, needs-info, ready-for-agent,
ready-for-human, wontfix) созданы в репо; заметка о их отсутствии
заменена командой для добавления новых.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 15:25:40 -04:00
ddmitry 6658a3b1aa Merge pull request 'feat(labs): прием справочника через PXF в учебном маршруте' (#16) from docs/9-pxf-airports into main 2026-09-22 18:29:17 +03:00
ddadmin 1e72776254 feat(labs): добавлена лабораторная приема справочника через PXF 2026-09-22 11:26:21 -04:00
ddadmin 1dd5838fb5 docs(labs): добавлены ссылки на программу в solution
Указатели в AGENTS.md и оглавлении ведут к авторской заметке в solution; учебная ветка сохраняет свой состав документации.

Проверка: ссылки сверены с локальной веткой solution, git diff --cached --check пройден.
2026-09-22 09:21:08 -04:00
ddmitry 351dbbb3c1 Merge pull request 'docs: согласованы руководства загрузок и учебный маршрут' (#7) from docs/4-loading-guides into main 2026-09-20 22:59:48 +03:00
ddadmin 35144e49f6 docs: согласованы руководства загрузок и учебный маршрут
Руководства STG/ODS/DDS/DM сверены с действующими SQL и DAG,
ожидания учебных заглушек связаны с ТЗ. Самопроверка заданий
отделена от проверок разработки и исторических планов.

Для dim_routes закреплено обновление описательных полей только
у открытых версий. SQL, DAG, DDL и настройки стенда не менялись.

Проверки: make lint; make test (4 passed, 14 skipped); карта;
292 локальные ссылки; маршрут чтения и карта в Chromium.
Живой ETL не запускался.
2026-09-20 15:53:39 -04:00
ddmitry a6fdd43e15 docs: связаны карта DWH, разбор SQL и задания (#6) 2026-09-20 22:14:28 +03:00
ddadmin 20eb7f7b84 docs: уточнен порядок загрузок после SCD2-проверки 2026-09-20 15:13:20 -04:00
ddadmin b7ecfb1bb7 docs: связаны карта DWH, разбор SQL и задания 2026-09-20 14:31:49 -04:00
29 changed files with 1724 additions and 896 deletions

No files matched your search

+5
View File
@@ -73,6 +73,11 @@
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
## Для агента (особенности аудитории)
При создании и пересмотре лабораторных сначала прочитайте
[развитие лабораторных в ветке solution](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/solution/docs/design/labs_development.md):
аудитория, принятые решения и критерии заданий.
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
+4
View File
@@ -224,10 +224,14 @@ test:
lint:
$(UV) run black --check airflow tests
$(UV) run isort --check-only airflow tests
$(UV) run black --check scripts/check_airports_snapshot.py
$(UV) run isort --check-only scripts/check_airports_snapshot.py
fmt:
$(UV) run black airflow tests
$(UV) run isort airflow tests
$(UV) run black scripts/check_airports_snapshot.py
$(UV) run isort scripts/check_airports_snapshot.py
clean-venv:
python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)"
+10 -6
View File
@@ -94,7 +94,8 @@ SELECT COUNT(*) FROM dm.sales_report;
-- Остальные DDS/DM-таблицы пока пусты — вы реализуете их в задании.
```
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
Порядок повторных запусков и переходов между слоями описан в
[руководстве по запуску DAG](docs/dag_execution_order.md).
7) Разберитесь в данных и приступайте к заданию:
@@ -104,7 +105,8 @@ SELECT COUNT(*) FROM dm.sales_report;
[интерактивную карту](docs/design/architecture-map.html) — локальный файл
`docs/design/architecture-map.html` нужно открыть в браузере; в Gitea HTML
показывается как исходный текст. Затем переходите к
[учебным заданиям](docs/assignment/README.md).
[учебным заданиям](docs/assignment/README.md): начните с приема справочника
через PXF, после него — загрузки ODS.
## DAG-и в стенде
@@ -113,8 +115,9 @@ SELECT COUNT(*) FROM dm.sales_report;
- `bookings_stg_ddl` — создаёт/обновляет весь STG слой для bookings (9 таблиц: bookings, tickets, airports, airplanes,
routes, seats, flights, segments, boarding_passes; включая внешние `*_ext` через PXF);
- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки.
- `lab_pxf_airports` — готовит общий снимок четырех справочников для [лабораторной PXF](docs/assignment/pxf_airports.md); использует те же SQL и DQ, без генерации транзакций.
- `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings.
- `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет DQ‑проверки.
- `bookings_to_gp_ods` — заменяет снимки справочников и обновляет транзакционные таблицы ODS из STG; выполняет DQ-проверки.
- `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings.
- `bookings_to_gp_dds` — загружает данные из ODS в DDS (SCD1/SCD2 + факт) и выполняет DQ‑проверки.
- `bookings_dm_ddl` — создаёт/обновляет DM-витрины (sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview).
@@ -154,17 +157,18 @@ make clean # полный reset: удалить контейнер
## Документация
- [Учебные задания](docs/assignment/README.md)
- [Структура DWH: от бронирования до витрины](docs/design/db_schema.md)
- [От карты к SQL загрузки](docs/design/reading_the_pipeline.md)
- [Учебные задания: порядок работы и проверка](docs/assignment/README.md)
- [Интерактивная карта таблиц и связей](docs/design/architecture-map.html)
- [Как читать пайплайн по скрипту загрузки](docs/design/reading_the_pipeline.md)
- [План тестирования/проверок и негативные кейсы](TESTING.md)
- [Дополнительные заметки и технические детали](docs/README.md)
- [Детали по STG DAG](docs/bookings_to_gp_stage.md)
- [Детали по ODS DAG](docs/bookings_to_gp_ods.md)
- [Детали по DDS DAG](docs/bookings_to_gp_dds.md)
- [Детали по DM DAG](docs/bookings_to_gp_dm.md)
Для работы над репозиторием: [проверки разработки и стенда](TESTING.md).
## Типичные проблемы и решения
| Проблема | Решение |
+104 -66
View File
@@ -1,80 +1,118 @@
# План тестирования (для студентов)
# Проверка изменений репозитория
Этот документ — пошаговый чек‑лист, как проверить, что всё работает: от «быстрых локальных проверок» до запуска стенда в Docker и просмотра данных в Greenplum. Подходит начинающим: просто выполняйте шаги по порядку.
Этот документ предназначен для работы над кодом, документацией и стендом.
Если вы выполняете учебное задание, начните с
[его самопроверки](docs/assignment/README.md#запуск-и-проверка): там объяснены
`*_dq.sql`, группы `bookings_validate` и порядок восстановления после SCD2-теста.
Если что‑то пошло не так, смотрите раздел «Быстрый reset» ниже.
## Локальные проверки
## 1. Быстрая проверка окружения
- `uv sync` — подтягиваем Python и зависимости из `pyproject.toml`/`uv.lock`.
- Проверяем версию uv: `uv --version` (ожидаем ≥ 0.9).
- Убедитесь, что `docker compose version` доступна и Docker запущен.
Из корня репозитория:
## 2. Локальные автоматические проверки (без Docker)
- `make test` — короткие unit-тесты (`tests/test_greenplum_helpers.py`, `tests/test_dags_smoke.py`).
- Smoke-тесты DAG автоматически `skip`, если Airflow не установлен в venv, поэтому прогонится за миллисекунды.
- `make lint` — black/isort в режиме проверки (после `make fmt` должен проходить без ошибок).
- `make fmt` — автоисправление форматирования; полезно запускать перед пушем.
- (опционально) `uv run pytest -q -k dags_smoke` — только DAG smoke.
```bash
uv sync
make lint
make test
```
## 3. Подготовка Docker-стенда
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные:
- `GP_PORT` — внутренний порт Greenplum в Docker-сети (по умолчанию 5432, менять не нужно); внешний порт для подключения с хоста фиксирован на `5435`, поэтому локальный PostgreSQL на 5432 не помешает.
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
- Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, выполните `make build` перед `make up`.
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`); `greenplum` считается `healthy` только когда поднят и Greenplum, и PXF.
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
`make lint` проверяет Black и isort для `airflow/` и `tests/`.
`make fmt` исправляет форматирование в этих каталогах.
Тесты находятся в трех файлах:
## 4. Smoke тесты DAG в Airflow UI
1. Открыть http://localhost:8080 (admin/admin).
2. (опционально) Зайти в Admin → Connections и убедиться, что DAG’и видят подключения:
- `greenplum_conn` и `bookings_db` задаются через переменные `AIRFLOW_CONN_...` в docker-compose и могут не отображаться в списке, но `airflow connections get greenplum_conn` / `bookings_db` внутри контейнера должны отрабатывать без ошибок.
- [test_ods_sql_contract.py](tests/test_ods_sql_contract.py) проверяет
контракты SQL справочников ODS по исходным файлам.
- [test_dags_smoke.py](tests/test_dags_smoke.py) проверяет структуру DAG.
Если импорт `airflow` не предоставляет `DAG`, проверки пропускаются.
Смотрите причины пропусков: `uv run pytest -q -rs`.
- [test_ods_snapshot_integration.py](tests/test_ods_snapshot_integration.py)
по умолчанию пропускается; отдельный запуск описан ниже.
- DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG):
- предварительно выполнить один раз: `make bookings-init` (быстрое восстановление демобазы `demo` из seed-дампа, ~18 сек) и `make ddl-gp` (создаёт STG/ODS/DDS слои в Greenplum, включая внешние `*_ext` через PXF);
- перед Trigger проверить, что в source реально есть данные (все значения должны быть `> 0`):
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airplanes_data;"`
- если хотя бы один `COUNT(*) = 0`, не запускать DAG: повторить `make bookings-init`; если после этого `bookings.bookings` всё ещё пустая, выполнить `make bookings-generate-day` и снова проверить `COUNT(*)`;
- важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен (быстрое восстановление из seed-дампа);
- включить DAG `bookings_to_gp_stage` и запустить `Trigger DAG`;
- убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ);
- при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `_load_id` (см. пример запросов в разделе 5).
Успешный pytest с пропущенными smoke-тестами не подтверждает работоспособность
DAG в Airflow. Число выполненных и пропущенных тестов указывайте в отчете.
- (опционально, для менторов/разработчиков) Smoke-тест DAG через Airflow CLI без UI:
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01` — прогоняет `bookings_to_gp_stage` целиком в «off-line» режиме;
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags trigger bookings_to_gp_stage` — создаёт реальный запуск DAG (логи и статус можно смотреть либо через UI, либо командой `airflow tasks list`/`airflow tasks logs` внутри контейнера).
Для документации проверьте относительные ссылки и якоря, имена файлов,
таблиц и задач. Для карты выполните:
## 5. Проверка данных в Greenplum
- `make gp-psql` — запустить psql в контейнере от имени `gpadmin`.
- (опционально) Проверить, что PXF действительно запущен:
- `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
- Команды внутри psql:
- `\dt public.*` — таблицы схему public.
- (после настройки PXF) `SELECT COUNT(*) FROM public.ext_bookings_bookings;` — проверка чтения из демо-БД bookings через PXF.
- (после настройки PXF) `SELECT * FROM public.ext_bookings_bookings LIMIT 5;` — визуальное сравнение с таблицей `bookings.bookings` в исходной БД.
- Завершить `\q`.
```bash
uv run docs/design/architecture-map/render.py --check
git diff --check
```
## 6. Негативные сценарии и fallback
- **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`).
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up`), удостовериться, что загрузка и DQ работают через ENV.
- **PXF и демобаза bookings** (после настройки PXF и выполнения `make ddl-gp`): временно остановить `bookings-db` (`docker compose stop bookings-db`) и попробовать выполнить `SELECT COUNT(*) FROM public.ext_bookings_bookings;` в `make gp-psql` — ожидается ошибка подключения. Затем запустить `bookings-db` (`docker compose start bookings-db`) и убедиться, что запрос снова работает.
Сборщик сверяет карту с DDL, загрузками и ссылками. Действия в браузере
проверяются отдельно по [чек-листу карты](docs/design/architecture-map/README.md#проверка-в-браузере).
## 7. Быстрый reset (если «что-то сломалось»)
- Перезапустить стенд:
- Мягкий вариант (сохранить данные): `make stop`, затем `make up`.
- Полный reset (очистить данные в Docker-томах): `make clean`, затем `make up` (Greenplum/Airflow/bookings будут подняты и инициализированы с нуля).
- Иногда Greenplum не стартует после «грязных» остановок (из‑за старых внутренних файлов). Лечение: всегда делайте `make down` перед повторным `make up`.
## Проверка на стенде
## 8. Снятие метрик и мониторинг
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
- Логи задач: в Airflow UI → конкретный таск → Log.
Подготовьте стенд по [README](README.md) и проверьте его состояние:
## 9. Завершение работы
- `make down` — выключает сервисы и удаляет контейнеры/сети (volumes сохраняются).
- Полный сброс данных (удаляет volumes): `make clean`.
```bash
make up
docker compose ps
```
## Текущий статус (пример успешного прогона)
- `uv run pytest -q` — 14 passed, 9 smoke-тестов DAG пропущены (Airflow не установлен в venv).
- `make lint` — проходит (DAG‑файлы отформатированы black/isort).
- Полный ETL-цикл (STG→ODS→DDS→DM) проверен на стенде 2026-03-09: все DAG-и завершились с Success.
Порты берутся из `.env`: `AIRFLOW_WEB_PORT`, `GP_HOST_PORT`, `BOOKINGS_DB_PORT`.
Для локальных подключений используйте их, а для Greenplum внутри Docker-сети
применяется `GP_PORT` (по умолчанию 5432).
Загрузки используют Airflow Connections из Compose;
подробности находятся в [справочнике стенда](docs/stack.md).
Если изменились `Dockerfile.airflow` или `airflow/requirements.txt`, перед
`make up` выполните `make build`. Инициализация Bookings нужна при создании
стенда: `make bookings-init` пересоздает исходную базу из seed-дампа.
Не используйте ее для обычной проверки SQL на существующих данных.
Выполните DDL и загрузки по [порядку DAG](docs/dag_execution_order.md).
В Airflow откройте Graph и Log задач измененного слоя; в Greenplum
(`make gp-psql`) выполните проверки из его руководства:
[STG](docs/bookings_to_gp_stage.md#проверка-результата),
[ODS](docs/bookings_to_gp_ods.md#проверка-результата),
[DDS](docs/bookings_to_gp_dds.md#проверка-результата),
[DM](docs/bookings_to_gp_dm.md#проверка-результата).
Для проверки чтения через PXF используйте действующую внешнюю таблицу:
```sql
SELECT COUNT(*) FROM stg.bookings_ext;
SELECT * FROM stg.bookings_ext LIMIT 5;
```
Сравнить источник можно через `make bookings-psql`.
При ошибке соединения начните с `docker compose ps` и логов нужного сервиса,
затем проверьте настройки из `docs/stack.md`.
## Дополнительные прогоны
Интеграционный тест снимка ODS требует работающего Greenplum и базы `gp_dwh`:
```bash
RUN_ODS_INTEGRATION=1 uv run pytest -q tests/test_ods_snapshot_integration.py
```
Он создает, затем удаляет служебные таблицы
`public.it_stg_airports_ods` и `public.it_ods_airports_ods`.
Используйте тестовый стенд, где эти имена не заняты нужными данными.
Тест проверяет замену снимка, преобразование названий и DQ готового `airports`;
задания `airplanes` и `seats` он не проверяет.
`make e2e-etl` запускает DDL и два прохода STG → ODS → DDS → DM через API Airflow.
Скрипт использует `localhost:8080`; при другом `AIRFLOW_WEB_PORT` проходите
цепочку вручную. Каждый STG добавляет учебный день. Финальные SQL выводят
счетчики, но не превращают все неверные значения в ошибку завершения скрипта:
результаты нужно прочитать с учетом заглушек `main`.
`make e2e-smoke` начинает с `make clean` и **удаляет все Docker-тома стенда**,
затем инициализирует Bookings и выполняет E2E. Запускайте его только там,
где данные можно потерять. Обычный `make down` сохраняет тома;
`make stop`, затем `make up` подходят для перезапуска с сохранением данных.
[Исторический QA-план](docs/reference/qa-plan.md) сохраняет многодневные,
негативные и граничные сценарии полной реализации. Это материал для
подготовки отдельной диагностики: перед выполнением сверяйте его SQL
с текущей веткой и учитывайте операции очистки данных.
## Что указать в отчете
Укажите коммит, команды и результаты, включая пропуски тестов.
Разделите чтение исходников, проверки ссылок, действия в браузере и реальные
загрузки. Для запуска на стенде сохраните Run ID и логи проверенных задач.
Если ETL или интерфейс не проверялись, укажите это прямо.
+70
View File
@@ -0,0 +1,70 @@
"""Готовый DAG первой лабораторной: общий снимок справочников без генерации дней."""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
with DAG(
dag_id="lab_pxf_airports",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args={
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
},
tags=["lab", "bookings", "pxf"],
description="Лабораторная PXF: четыре снимка STG с общим Run ID для ODS",
) as dag:
load_airports_to_stg = PostgresOperator(
task_id="load_airports_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/airports_load.sql",
)
check_airports_dq = PostgresOperator(
task_id="check_airports_dq",
postgres_conn_id="greenplum_conn",
sql="stg/airports_dq.sql",
)
load_airplanes_to_stg = PostgresOperator(
task_id="load_airplanes_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/airplanes_load.sql",
)
check_airplanes_dq = PostgresOperator(
task_id="check_airplanes_dq",
postgres_conn_id="greenplum_conn",
sql="stg/airplanes_dq.sql",
)
load_routes_to_stg = PostgresOperator(
task_id="load_routes_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/routes_load.sql",
)
check_routes_dq = PostgresOperator(
task_id="check_routes_dq",
postgres_conn_id="greenplum_conn",
sql="stg/routes_dq.sql",
)
load_seats_to_stg = PostgresOperator(
task_id="load_seats_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/seats_load.sql",
)
check_seats_dq = PostgresOperator(
task_id="check_seats_dq",
postgres_conn_id="greenplum_conn",
sql="stg/seats_dq.sql",
)
load_airports_to_stg >> check_airports_dq
load_airplanes_to_stg >> check_airplanes_dq
# Готовим один общий батч: DQ маршрутов и мест читает связанные справочники.
[check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq
check_airplanes_dq >> load_seats_to_stg >> check_seats_dq
+48 -29
View File
@@ -1,40 +1,59 @@
# Документация
Этот каталог содержит дополнительные материалы к учебному стенду.
## Знакомство и первое задание
## Быстрый путь (для менти)
После [запуска стенда](../README.md):
- [Быстрый старт и команды](../README.md)
- [Структура DWH: от бронирования до витрины](design/db_schema.md)
- [Интерактивная карта таблиц и связей](design/architecture-map.html)
- [Учебные задания](assignment/README.md)
- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md)
- [План тестирования и проверки](../TESTING.md)
- [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md)
- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md)
- [Учебный DAG: ods -> dds](bookings_to_gp_dds.md)
- [Учебный DAG: dds -> dm](bookings_to_gp_dm.md)
1. [Структура DWH](design/db_schema.md): смысл строк и связей на примере поездки.
2. [Учебные задания](assignment/README.md): прием справочника через PXF,
затем загрузки ODS, измерения и витрины. Каждая работа указывает свои проверки.
Карту открывайте из локальной копии: `docs/design/architecture-map.html` в браузере.
Сервер не нужен. Просмотр исходного HTML в Gitea не показывает карту.
Перед заданиями ODS прочитайте [разбор готового справочника](design/reading_the_pipeline.md#ods-airports).
Сборка факта из того же руководства понадобится перед DDS.
Поля и бизнес-правила расширений находятся в [ТЗ](assignment/analyst_spec.md).
## Найти деталь во время работы
[Интерактивная карта](design/architecture-map.html) ведет от таблицы к ее
источникам, потребителям, SQL и пункту задания.
Откройте локальный файл `docs/design/architecture-map.html` в браузере;
просмотр исходного HTML в Gitea не показывает карту.
[Руководство](design/db_schema.md#как-открыть-карту) объясняет открытие и адресные ссылки.
## Дизайн (`design/`)
| Вопрос | Основной документ |
|---|---|
| В каком порядке запускать слои? | [Порядок запуска DAG](dag_execution_order.md) |
| Что делает загрузка и где искать ошибку? | Руководства [STG](bookings_to_gp_stage.md), [ODS](bookings_to_gp_ods.md), [DDS](bookings_to_gp_dds.md), [DM](bookings_to_gp_dm.md) |
| Какие поля и преобразования нужны в моем задании? | [ТЗ](assignment/analyst_spec.md) |
| Как называются служебные поля и что означают метки времени? | [Соглашения об именах](design/naming_conventions.md) |
| Какие проверки включить в DQ? | [Классы DQ-проверок](reference/dq_taxonomy.md) |
| Как устроены контейнеры, Connections и настройки? | [Справочник стенда](stack.md) |
- [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md)
- [Структура DWH и маршрут по карте](design/db_schema.md)
- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md)
- [Дизайн-документ STG](design/bookings_stg_design.md)
- [Дизайн-документ ODS](design/bookings_ods_design.md)
- [Дизайн-документ DDS](design/bookings_dds_design.md)
- [Дизайн-документ DM](design/bookings_dm_design.md)
## Проверки разработки и диагностика
> Полные дизайн-документы (PRD, assignment_design, архитектурные решения) — в ветке `solution`.
[TESTING.md](../TESTING.md) описывает проверки изменений репозитория:
pytest, линтеры, проверку карты и прогоны стенда. Для выполнения первого
задания достаточно самопроверки из его инструкции.
## Справочники (`reference/`)
Для углубленного разбора доступны
[исторический QA-план](reference/qa-plan.md) с многодневными и негативными
сценариями полной реализации и [заметки о генераторе Bookings](reference/bookings_db_issues.md).
QA-план требует отдельного тестового стенда и сверки с текущими SQL.
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
- [QA-план отладки пайплайна](reference/qa-plan.md)
- [Классы DQ-проверок](reference/dq_taxonomy.md)
- [Порядок запуска DAG-ов](dag_execution_order.md)
## Разработка учебных заданий
Перед созданием или пересмотром лабораторных прочитайте
[заметку о развитии программы в ветке solution](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/solution/docs/design/labs_development.md):
аудитория, принятые решения, предложения по обязательному циклу и общие критерии заданий.
## История разработки
Планы слоев сохранены по прежним адресам:
[STG](design/bookings_stg_design.md), [ODS](design/bookings_ods_design.md),
[DDS](design/bookings_dds_design.md), [DM](design/bookings_dm_design.md).
В них остаются исходные обоснования, варианты SQL и последовательность
создания файлов. В начале каждого плана есть переход к действующему руководству.
Для реализации учебных загрузок используйте ТЗ и готовые SQL-примеры.
Полные материалы проектирования (PRD, дизайн заданий, архитектурные решения)
находятся в ветке `solution`.
+2 -2
View File
@@ -14,5 +14,5 @@
Правую колонку можно менять под свой словарь.
В Gitea-репо меток пока нет. При первом `/triage` создайте их:
`tea labels create --name needs-triage --color "#e4e669" --description "..."` и так далее для каждой строки таблицы.
Все пять меток заведены в Gitea-репо (`tea labels list`). Новую метку добавляют так:
`tea labels create --name <имя> --color <hex без #> --description "..."`.
+80 -56
View File
@@ -1,85 +1,109 @@
# Учебные задания
## О чём задание
В стенде уже работает цепочка от Bookings до `dm.sales_report`.
Сначала вы восстановите прием справочника в STG, затем дополните цепочку
загрузками ODS, измерениями DDS и четырьмя витринами DM.
Требования к этим расширениям описаны в [ТЗ](analyst_spec.md).
В стенде уже работает **эталонный вертикальный срез** — полная цепочка
`STG → ODS → DDS → DM` для витрины `dm.sales_report`. Ваша задача —
реализовать недостающие загрузки по аналогии с эталоном.
## Подготовка
На месте ваших заданий сейчас стоят **заглушки** (`SELECT 1;`). Вы замените
их на реальную SQL-логику, и после этого весь DWH будет заполнен.
Пройдите шаги 1–6 [быстрого старта](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
поднимите стенд, создайте таблицы, запустите эталонную цепочку и проверьте данные.
Затем прочитайте [руководство о структуре DWH](../design/db_schema.md).
Открывайте [карту](../design/architecture-map.html) рядом с заданием;
для Gitea есть [инструкция открытия локального HTML](../design/db_schema.md#как-открыть-карту).
## С чего начать чтение
## Прием справочника: PXF → STG
Сначала посмотрите [руководство о структуре DWH и карту](../design/db_schema.md).
Пока пишете свой слой, держите рядом [метод чтения скрипта загрузки](../design/reading_the_pipeline.md).
Для `*_dq.sql` сверяйтесь с [классами DQ-проверок](../reference/dq_taxonomy.md).
Пройдите [«Восстановите прием справочника из источника»](pxf_airports.md).
Вы разберете готовый участок `airports`, восстановите внешнюю таблицу
и загрузку. На повторном запуске и переименовании аэропорта выясните,
как загрузка сохраняет снимки источника.
## Перед началом
## Первая загрузка: ODS
Убедитесь, что стенд запущен и данные загружены —
пройдите шаги 1–6 из [README (Быстрый старт)](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
После приема справочника реализуйте `ods.airplanes`, затем `ods.seats`.
На карте откройте [модели самолетов](../design/architecture-map.html#node=ods.airplanes)
и [места](../design/architecture-map.html#node=ods.seats): источники STG уже заполнены.
Позже обе таблицы понадобятся `dds.dim_airplanes` для характеристик модели
и расчета ее вместимости.
После этого в Greenplum будут данные в эталонных таблицах (`stg.*`, `ods.bookings`,
`dds.dim_airports`, `dds.fact_flight_sales`, `dm.sales_report` и др.).
Таблицы уже созданы, задачи Airflow подключены к SQL-файлам.
В учебных `*_load.sql` и `*_dq.sql` пока стоит `SELECT 1;`.
В заданиях ODS, DDS и DM вы заменяете эти заглушки; менять DAG
и создавать таблицы заново не требуется.
## Шаг 1. Изучите эталон
### Готовый пример
Прежде чем писать код, разберите, как устроены эталонные загрузки.
По одному файлу на каждый ключевой паттерн:
Пройдите [разбор stg.airports → ods.airports](../design/reading_the_pipeline.md#ods-airports).
Это близкий пример полного снимка с выбором батча, преобразованиями и дедупликацией.
Для первых загрузок ODS этого разбора достаточно.
| Паттерн | Файл для изучения | Что посмотреть |
|---------|-------------------|----------------|
| ODS: TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
| DDS: SCD1 UPSERT (измерение) | `sql/dds/dim_airports_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT |
| DM: витрина с UPSERT | `sql/dm/sales_report_load.sql` | HWM-инкремент, агрегация фактов по измерениям |
### Самостоятельная работа
Описания каждого DAG — в `docs/`:
[STG](../bookings_to_gp_stage.md) ·
[ODS](../bookings_to_gp_ods.md) ·
[DDS](../bookings_to_gp_dds.md) ·
[DM](../bookings_to_gp_dm.md)
Откройте [часть 1 ТЗ](analyst_spec.md#часть-1-ods-слой-operational-data-store).
Сверяйте с ней поля и правила каждой таблицы:
## Шаг 2. Откройте ТЗ
| Объект и требования | Файлы для реализации | DDL для сверки |
|---|---|---|
| [ods.airplanes](analyst_spec.md#11-odsairplanes) | [airplanes_load.sql](../../sql/ods/airplanes_load.sql), [airplanes_dq.sql](../../sql/ods/airplanes_dq.sql) | [airplanes_ddl.sql](../../sql/ods/airplanes_ddl.sql) |
| [ods.seats](analyst_spec.md#12-odsseats) | [seats_load.sql](../../sql/ods/seats_load.sql), [seats_dq.sql](../../sql/ods/seats_dq.sql) | [seats_ddl.sql](../../sql/ods/seats_ddl.sql) |
Техническое задание от аналитика: **[analyst_spec.md](analyst_spec.md)**.
Пример проверок: [airports_dq.sql](../../sql/ods/airports_dq.sql).
Учтите разницу ключей: у модели это `airplane_code`, у места -
`(airplane_code, seat_no)`.
Там описаны все таблицы, которые нужно реализовать, с маппингами, бизнес-правилами
и подсказками. Рекомендуемый порядок (от простого к сложному):
### Запуск и проверка
1. ODS: `airplanes`, `seats`
2. DDS: `dim_airplanes`, `dim_passengers` (SCD1)
3. DDS: `dim_routes` (SCD2 — ключевой вызов курсовой)
4. DM: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`
1. Используйте общий снимок после восстановления источника
в лабораторной PXF. Новый запуск STG для этой работы не нужен.
2. Запустите `bookings_to_gp_ods` целиком. Он выберет согласованный снимок
в `resolve_stg_batch_id`, затем выполнит
`load_ods_airplanes → dq_ods_airplanes → load_ods_seats → dq_ods_seats`.
SQL использует XCom этого запуска, поэтому отдельно от выбора батча
загрузку запускать не стоит.
3. В Greenplum посмотрите строки обеих таблиц: модели, типы числовых полей,
ключи мест и метки загрузки. Повторите ODS без нового запуска STG:
бизнес-строки и количество ключей должны сохраниться без дублей.
4. После реализации обеих таблиц запустите `bookings_validate` и откройте
группу `validate_ods`. Она проверит наличие данных, совпадение ключей
с выбранным STG-батчем, отсутствие дублей и NULL в ключах,
а также общий `_load_id` двух таблиц.
## Шаг 3. Реализуйте задания
В `bookings_validate` автоматически запускаются все три группы.
Пока готовы только задания ODS, оценивайте `validate_ods`: ошибки DDS и DM
на этом этапе ожидаемы. После выполнения всех заданий должны пройти все группы.
- **Файлы-заглушки уже на месте** — например, `sql/ods/airplanes_load.sql`
содержит `SELECT 1;`. Ваша задача: заменить `SELECT 1;` на реальную SQL-логику.
- **DDL уже создан** — таблицы существуют, создавать их не нужно.
- **Таски в DAG-ах уже подключены** — `PostgresOperator` ссылается на ваши SQL-файлы.
Менять Python-код DAG-ов не нужно.
- **Нейминг полей:** сверяйтесь с `docs/design/naming_conventions.md`.
Перед следующей загрузкой дождитесь завершения всего `bookings_validate`
и убедитесь, что `validate_dds.scd2_restore` завершился успешно.
SCD2-тест временно меняет данные маршрута, запускает вашу загрузку
и восстанавливает таблицы из копии. Параллельная загрузка может потерять
свои изменения при восстановлении. Если `scd2_restore` упал, сначала
разберите его лог.
## Шаг 4. Проверьте себя валидационным DAG-ом
`*_dq.sql` тоже нужно реализовать: успешный `SELECT 1;` ничего не проверяет.
DAG **`bookings_validate`** — автоматическая проверка вашей реализации.
## Следующие этапы
- Три группы проверок: `validate_ods`, `validate_dds`, `validate_dm` — запускаются
параллельно. Можно проверять ODS, пока DDS ещё не готов.
- Включает активный SCD2-тест для `dim_routes`: загружает изменённые данные
и проверяет, что версионирование работает корректно.
- Сообщения об ошибках подскажут, что именно не так и что делать дальше.
| Этап | Требования | Готовый пример рядом |
|---|---|---|
| DDS: `dim_airplanes`, `dim_passengers` | [Модели](analyst_spec.md#21-ddsdim_airplanes-scd1), [пассажиры](analyst_spec.md#22-ddsdim_passengers-scd1) | [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) |
| DDS: `dim_routes` | [Версии маршрута SCD2](analyst_spec.md#23-ddsdim_routes-scd2) | [Разбор сборки факта](../design/reading_the_pipeline.md#fact-flight-sales) показывает, как используется версия |
| DM: `airport_traffic` → `route_performance` → `monthly_overview` → `passenger_loyalty` | [Часть 3 ТЗ](analyst_spec.md#часть-3-dm-слой-data-marts--витрины) | [sales_report_load.sql](../../sql/dm/sales_report_load.sql) |
**Цель: все проверки зелёные.**
Перед DDS разберите [загрузку факта](../design/reading_the_pipeline.md#fact-flight-sales).
После реализации измерений выполните [пересчет факта и витрин](analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверяйте готовый слой своей группой в `bookings_validate`.
## Если застряли
- Изучите эталонные SQL-скрипты — в них те же паттерны, что нужны для заданий.
- Загляните в `docs/design/naming_conventions.md` — единый источник нейминга полей.
- Сверьтесь с **веткой `solution`** — там полная рабочая реализация.
Вернитесь к карточке объекта на карте: там есть DDL, загрузка, DQ и пункт ТЗ.
Для конкретного вопроса используйте [соглашения об именах](../design/naming_conventions.md),
[справочник DQ](../reference/dq_taxonomy.md) и руководства DAG:
[ODS](../bookings_to_gp_ods.md), [DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md).
Полную реализацию можно посмотреть в ветке `solution`.
## Для менторов
Дизайн заданий и педагогическая логика — в ветке `solution`
(`docs/design/assignment_design.md`).
Дизайн заданий и педагогическая логика находятся в ветке `solution`,
в `docs/design/assignment_design.md`.
+90 -30
View File
@@ -13,12 +13,19 @@
>
> SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`.
Программа начинается с [приема справочника через PXF](pxf_airports.md).
Затем переходите к расширениям из этого ТЗ; порядок их запуска и проверки
находится во [входе в задания](README.md#первая-загрузка-ods). В каждом разделе ниже есть
ссылка на объект карты и близкий готовый пример. Карту открывайте
[локально в браузере](../design/db_schema.md#как-открыть-карту).
---
## Рекомендуемый порядок выполнения
> **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes`
> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`.
> тоже эталонный. В первой лабораторной вы временно разбираете и восстанавливаете
> прием `airports`; расширения по этому ТЗ начинаются с ODS: `airplanes` и `seats`.
1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`)
2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения)
@@ -36,7 +43,7 @@
## Общие правила
- **Нейминг полей:** см. `docs/design/naming_conventions.md` — единый источник
- **Нейминг полей:** см. [соглашения об именах](../design/naming_conventions.md) — единый источник
истины для служебных полей (`_load_id`, `_load_ts`, `created_at`, `updated_at`,
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
@@ -46,7 +53,10 @@
Вам нужно только заменить содержимое SQL-файлов — DAG менять не нужно.
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
при повторном запуске (не создавать дубликатов).
- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`.
- **Метки загрузки:** в ODS-снимках `airplanes` и `seats` сохраняйте в `_load_id`
идентификатор исходного STG-батча из `resolve_stg_batch_id`, как в `ods.airports`.
Так проверка сможет сопоставить ODS с тем снимком, из которого он загружен.
В заданиях DDS и DM используйте `'{{ run_id }}'` текущего запуска слоя.
---
@@ -67,11 +77,11 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
`ods.routes` тоже эталонный: факт `fact_flight_sales` использует его для резолвинга
аэропортов вылета/прилёта, независимо от студенческого `dds.dim_routes`.
**Что вам делать:** Изучите эталонные скрипты как образец — именно так написан
«боевой» код загрузки:
- `sql/stg/airports_load.sql` — инкрементальная загрузка (HWM)
- `sql/stg/airplanes_load.sql` — full snapshot с проверкой `_load_id`
- `sql/ods/airports_load.sql` — TRUNCATE+INSERT из STG
После лабораторной PXF разберите [загрузку `ods.airports`](../design/reading_the_pipeline.md#ods-airports).
При необходимости проследить получение снимка откройте
[sql/stg/airports_load.sql](../../sql/stg/airports_load.sql) или
[sql/stg/airplanes_load.sql](../../sql/stg/airplanes_load.sql): оба читают полный
снимок источника. `NOT EXISTS` защищает от повторной вставки ключа в том же `_load_id`.
---
@@ -81,10 +91,14 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
> Справочники (`airplanes`, `seats`) загружаются стратегией TRUNCATE + INSERT
> из последнего согласованного батча STG. (`ods.routes` реализован в эталоне.)
>
> **Аналог для изучения:** `sql/ods/airports_ddl.sql`, `sql/ods/airports_load.sql`
> **Аналог для изучения:** [DDL airports](../../sql/ods/airports_ddl.sql),
> [загрузка airports](../../sql/ods/airports_load.sql) и [ее разбор](../design/reading_the_pipeline.md#ods-airports).
### 1.1. ods.airplanes
[На карте: модель самолета и ее источник](../design/architecture-map.html#node=ods.airplanes).
Готовый пример полного снимка: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Очищенный справочник моделей воздушных судов с правильными типами.
**Источник:** `stg.airplanes`
@@ -95,7 +109,7 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
| `model` | TEXT NOT NULL | Наименование модели | `model` (парсинг JSON: `model::JSON->>'ru'` — если JSON, иначе `model` как есть) |
| `range_km` | INTEGER | Дальность полёта, км | `range::INTEGER` |
| `speed_kmh` | INTEGER | Крейсерская скорость, км/ч | `speed::INTEGER` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
@@ -115,6 +129,9 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 1.2. ods.seats
[На карте: места и связь с моделью](../design/architecture-map.html#node=ods.seats).
Пример выбора снимка и дедупликации: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Карта посадочных мест с корректными типами.
**Источник:** `stg.seats`
@@ -124,7 +141,7 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
| `airplane_code` | TEXT NOT NULL | Код модели (PK, часть 1) | `airplane_code` |
| `seat_no` | TEXT NOT NULL | Номер места (PK, часть 2) | `seat_no` |
| `fare_conditions` | TEXT NOT NULL | Класс обслуживания | `fare_conditions` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
@@ -149,10 +166,14 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
> ключами. SCD1-измерения обновляют атрибуты «на месте». SCD2-измерение
> хранит историю изменений через версионирование.
>
> **Аналог для SCD1:** `sql/dds/dim_airports_ddl.sql`, `sql/dds/dim_airports_load.sql`
> **Аналог для SCD1:** [DDL dim_airports](../../sql/dds/dim_airports_ddl.sql),
> [загрузка dim_airports](../../sql/dds/dim_airports_load.sql).
### 2.1. dds.dim_airplanes (SCD1)
[На карте: характеристики и вместимость модели](../design/architecture-map.html#node=dds.dim_airplanes).
Пример SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение моделей самолётов. Содержит технические характеристики
и рассчитанное общее количество мест (обогащение из `ods.seats`).
@@ -184,7 +205,8 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
в `ods.seats` для данного `airplane_code` (LEFT JOIN).
**Генерация суррогатного ключа:** `MAX(airplane_sk) + ROW_NUMBER()`.
Безопасно при `concurrency=1` в DAG.
Загрузки одной таблицы должны выполняться последовательно. В DDS DAG
параллельные запуски ограничены параметром `max_active_runs=1`.
**Distribution Key:** `airplane_sk`
@@ -192,6 +214,9 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 2.2. dds.dim_passengers (SCD1)
[На карте: пассажир из билетов](../design/architecture-map.html#node=dds.dim_passengers).
Пример обновления атрибутов и вставки новых ключей: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение пассажиров. Извлекается из таблицы билетов — каждый
уникальный `passenger_id` становится строкой измерения.
@@ -230,6 +255,10 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 2.3. dds.dim_routes (SCD2)
[На карте: версии маршрута](../design/architecture-map.html#node=dds.dim_routes).
В [разборе готового факта](../design/reading_the_pipeline.md#fact-joins) видно,
как загрузчик выбирает версию на дату рейса. Алгоритм самой SCD2-загрузки дан ниже.
> **Это ключевой вызов курсовой.** Реализация SCD Type 2 — обязательный навык
> для Data Engineer. Ниже — алгоритм текстом; SQL вы пишете самостоятельно.
> Если застряли — сверьтесь с веткой `solution`.
@@ -302,8 +331,9 @@ md5(concat_ws('|',
фактов на исторические рейсы находил корректный `route_sk`.
- **Последующие версии** (маршрут уже был): `valid_from = CURRENT_DATE`.
6. **SCD1-обновление денормализованных полей:** Обновите `departure_city`,
`arrival_city`, `airplane_model`, `total_seats` для ВСЕХ открытых версий
(даже если `hashdiff` не менялся).
`arrival_city`, `airplane_model`, `total_seats` только у открытых версий
(`valid_to IS NULL`), даже если `hashdiff` не менялся. Новая версия при этом
не создается. Закрытые версии сохраняют значения на момент закрытия.
**Интервалы версий:** полуоткрытые `[valid_from, valid_to)`.
Текущая (актуальная) версия: `valid_to IS NULL`.
@@ -317,10 +347,15 @@ md5(concat_ws('|',
> **Цель:** Построить аналитические витрины поверх DDS.
> Каждая витрина отвечает на конкретный бизнес-вопрос.
>
> **Аналог для изучения:** `sql/dm/sales_report_ddl.sql`, `sql/dm/sales_report_load.sql`
> **Аналог для изучения:** [DDL sales_report](../../sql/dm/sales_report_ddl.sql),
> [загрузка sales_report](../../sql/dm/sales_report_load.sql).
### 3.1. dm.airport_traffic
[На карте: аэропорт за день](../design/architecture-map.html#node=dm.airport_traffic).
Пример пересчета затронутых дат: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
Две роли аэропорта проследите в [готовом факте](../design/reading_the_pipeline.md#fact-joins).
**Бизнес-вопрос:** «Какой пассажиропоток и выручка у каждого аэропорта по дням?»
**Описание:** Ежедневная статистика по каждому аэропорту: сколько рейсов
@@ -359,6 +394,11 @@ md5(concat_ws('|',
и `arrival_airport_sk`. Затем сгруппируйте по `(traffic_date, airport_sk)`.
**Метрики:**
Здесь `SUM(is_boarded)` означает число строк с признаком посадки.
Поле булево; в SQL используйте `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)`,
как в `sales_report_load.sql`.
- `departures_flights` — `COUNT(DISTINCT flight_id)` для роли «вылет»
- `departures_passengers` — `SUM(is_boarded)` для роли «вылет»
- `departures_revenue` — `SUM(price)` для роли «вылет»
@@ -377,6 +417,10 @@ md5(concat_ws('|',
### 3.2. dm.route_performance
[На карте: статистика маршрута](../design/architecture-map.html#node=dm.route_performance).
Соединение факта с измерениями и агрегация показаны в
[sales_report_load.sql](../../sql/dm/sales_report_load.sql); здесь по ТЗ нужен полный пересчет.
**Бизнес-вопрос:** «Какие маршруты самые эффективные? Где высокий load factor,
а где теряем пассажиров?»
@@ -427,9 +471,8 @@ md5(concat_ws('|',
- `avg_boarding_rate` — `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)`
- `avg_load_factor` — `total_boarded / (total_flights * total_seats)`
**Тип хранения Greenplum:** AO Column Store (zstd). Идеален для аналитики:
отличное сжатие, чтение только нужных колонок. AO не поддерживает UPDATE —
поэтому используем Full Rebuild.
**Тип хранения Greenplum:** AO Column Store (zstd), сжатое колоночное хранение.
В этом задании небольшой результат полностью пересоздается при каждом запуске.
**Distribution Key:** `route_bk`
@@ -440,6 +483,10 @@ md5(concat_ws('|',
### 3.3. dm.monthly_overview
[На карте: месяц и модель самолета](../design/architecture-map.html#node=dm.monthly_overview).
Пример отбора затронутых периодов: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
В этом задании период и уровни агрегации определены ниже.
**Бизнес-вопрос:** «Какова помесячная динамика: рейсы, выручка, load factor
в разрезе типов самолётов?»
@@ -479,9 +526,11 @@ md5(concat_ws('|',
**Ключевой приём — Двухуровневая агрегация:**
Чтобы честно посчитать среднюю заполняемость (`avg_load_factor`), нельзя просто
поделить `SUM(boarded)` на `SUM(seats)` — это даёт ошибку (парадокс Симпсона).
Правильный путь:
В этом задании `avg_load_factor` - среднее долей занятых мест по рейсам:
каждый рейс имеет одинаковый вес. Отношение `SUM(boarded) / SUM(seats)`
дает среднее с весом по вместимости и при разной вместимости может отличаться.
Нужный расчет:
1. **Уровень 1 (рейс):** Для каждого `flight_id` посчитайте `load_factor = boarded / total_seats`.
2. **Уровень 2 (месяц):** Возьмите `AVG(load_factor)` по всем рейсам месяца.
@@ -502,6 +551,10 @@ md5(concat_ws('|',
### 3.4. dm.passenger_loyalty
[На карте: история пассажира](../design/architecture-map.html#node=dm.passenger_loyalty).
В [sales_report_load.sql](../../sql/dm/sales_report_load.sql) посмотрите отделение
затронутых дат от полной истории за эти даты. Здесь единица пересчета - пассажир.
**Бизнес-вопрос:** «Кто наши самые лояльные пассажиры? Сколько они летают,
тратят, какой класс предпочитают?»
@@ -568,6 +621,10 @@ md5(concat_ws('|',
## Пересчёт факта после реализации измерений
[Факт на карте](../design/architecture-map.html#node=dds.fact_flight_sales) и
[разбор его INSERT и UPDATE](../design/reading_the_pipeline.md#найдите-запись-результата)
помогут проследить, когда заполняются ключи измерений.
После того, как вы реализуете все DDS-измерения (`dim_airplanes`, `dim_passengers`,
`dim_routes`), нужно пересчитать факт — он загружался ещё без ваших SK:
@@ -596,16 +653,19 @@ FROM dds.fact_flight_sales;
## Валидация
После реализации каждого слоя запустите валидационный DAG `bookings_validate`
в Airflow UI. Он проверит:
После реализации слоя следуйте [порядку запуска и проверки](README.md#запуск-и-проверка).
В `bookings_validate` смотрите группу своего этапа:
- Таблицы существуют и содержат данные
- PK не содержат NULL
- SCD2: корректность `valid_from`/`valid_to`, отсутствие «дыр» в версиях
- Кросс-слойная консистентность (ODS vs STG по кол-ву записей)
- DM-витрины содержат данные за загруженные дни
- `validate_ods` проверяет данные `airplanes` и `seats`, их ключи
и согласованность с выбранным снимком STG.
- `validate_dds` проверяет наличие данных в учебных измерениях, уникальность
пассажиров и сценарий изменения маршрута SCD2 с восстановлением таблиц.
- `validate_dm` проверяет, что четыре учебные витрины содержат строки.
Сообщения об ошибках укажут, что именно не так и что делать дальше.
Этот DAG дополняет ваши `*_dq.sql`. В частности, непустота витрины еще
не подтверждает формулы метрик: проверяйте их по требованиям выше и
[классам DQ](../reference/dq_taxonomy.md). Перед следующей загрузкой дождитесь
всего DAG и успешного `validate_dds.scd2_restore`.
---
+317
View File
@@ -0,0 +1,317 @@
# Восстановите прием справочника из источника
Хранилище получает справочник аэропортов из Postgres. Названия и другие
атрибуты в источнике могут меняться, поэтому загрузка должна сохранять
состояние справочника на момент приема данных.
Вы разберете готовый участок `airports` и напишете его заново: создадите
внешнюю таблицу PXF и загрузку полного снимка в STG. Затем испытаете ее
на повторном запуске и изменении источника. Полученный снимок понадобится
следующему слою — ODS.
## Задача
Ваш участок на [карте](../design/architecture-map.html#node=stg.airports):
`bookings.airports_data → stg.airports_ext → stg.airports`.
| Что написать | Где |
|---|---|
| DDL внешней таблицы `stg.airports_ext` | [airports_ddl.sql](../../sql/stg/airports_ddl.sql) |
| Загрузку полного снимка во внутреннюю `stg.airports` | [airports_load.sql](../../sql/stg/airports_load.sql) |
Внутренняя таблица, JDBC-сервер PXF, DAG и проверки качества уже готовы.
Рядом есть пример для другого справочника:
[DDL airplanes](../../sql/stg/airplanes_ddl.sql) и
[его загрузка](../../sql/stg/airplanes_load.sql). Используйте его, учитывая
состав полей и ключ аэропорта.
Требования к вашей загрузке:
- Источник — `bookings.airports_data` в базе `demo`, ключ — `airport_code`.
- Все шесть полей (`airport_code`, `airport_name`, `city`, `country`,
`coordinates`, `timezone`) попадают в STG без потерь и преобразований.
Внешняя и внутренняя таблицы используют TEXT; JSON и координаты
пока хранятся как текст.
- Строки одного снимка получают `_load_id = '{{ run_id }}'`,
`_load_ts = now()` и `event_ts = now()`. Точного времени изменения
аэропорта в источнике нет, поэтому `event_ts` — время получения снимка.
- Новый запуск сохраняет новый полный снимок, оставляя предыдущие.
Повторное выполнение загрузки в том же запуске на неизменном источнике
не меняет бизнес-данные и не создает дубли ключей внутри снимка.
## Подготовка
Нужен свой стенд после [быстрого старта и знакомства с моделью](README.md#подготовка).
Во время работы не запускайте другие загрузки: они могут изменить данные
посреди опыта. Если запущен `bookings_validate`, дождитесь его завершения.
Сначала освободите участок для своей реализации. Команды ниже удаляют
внешнюю таблицу и **всю историю только `stg.airports`**. Это нужно, чтобы
данные быстрого старта не скрыли отсутствие вашей загрузки.
<details>
<summary>Подготовка файлов и таблиц</summary>
Сохраните свои SQL-файлы в Git или отдельной копии. В `airports_ddl.sql`
удалите блок от `DROP EXTERNAL TABLE` до конца `CREATE EXTERNAL TABLE`;
DDL схемы и внутренней таблицы оставьте. Содержимое `airports_load.sql`
замените на `SELECT 1;`. DAG и DQ менять не нужно.
В Greenplum (`make gp-psql`):
```sql
DROP EXTERNAL TABLE IF EXISTS stg.airports_ext;
TRUNCATE TABLE stg.airports;
SELECT COUNT(*) FROM stg.airports; -- 0
```
Остальные справочники и текущий ODS сохраняются. Пока участок
не восстановлен, ODS запускать не нужно.
</details>
## Соберите загрузку
Начните с внешней таблицы. Добейтесь, чтобы запрос к `stg.airports_ext`
возвращал те же поля и значения, что запрос к `bookings.airports_data`
в Postgres. Для начала достаточно рассмотреть несколько аэропортов.
<details>
<summary>Ориентиры для PXF и применение DDL</summary>
В `LOCATION` укажите таблицу источника, `PROFILE=JDBC` и `SERVER=bookings-db`.
`SERVER` — имя готового [каталога конфигурации PXF](../../pxf/servers/bookings-db/jdbc-site.xml).
Формат чтения — `FORMAT 'CUSTOM' (formatter='pxfwritable_import')`.
Логин и пароль в DDL не нужны. Если меняли учетные данные источника,
согласуйте их с PXF по [справочнику стенда](../stack.md).
Примените свой файл из `make gp-psql`:
```sql
\set ON_ERROR_STOP on
\i /sql/stg/airports_ddl.sql
SELECT * FROM stg.airports_ext ORDER BY airport_code LIMIT 3;
```
В Postgres (`make bookings-psql`) выполните такой же запрос к
`bookings.airports_data`. Сверьте состав и порядок полей с DDL.
Если чтение не работает, начните с сообщения об ошибке и
[диагностики STG](../bookings_to_gp_stage.md#если-загрузка-не-прошла).
</details>
Теперь напишите загрузку снимка. Продумайте, как ваш SQL отличит
повторное выполнение от нового запуска и сохранит историю.
Выполнять загрузку будет готовый DAG `lab_pxf_airports`.
Получите первый снимок и сравните его с источником готовой сверкой.
Она проверяет ключи, все бизнес-поля и служебные метки. Зеленые задачи
в Airflow показывают, что код выполнился; сверка помогает обнаружить
ошибку в самих данных.
<details>
<summary>Первый запуск в Airflow и сверка с Postgres</summary>
Для сверки нужен `uv` с Python 3.11 из
[окружения разработчика](../stack.md#локальное-окружение-разработчика-uv).
В терминале из корня репозитория сохраните исходный справочник:
```bash
mkdir -p data/lab9
uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-before.json
```
Скрипт читает Postgres напрямую, минуя вашу внешнюю таблицу, и не перезаписывает
существующий файл. Эта копия позволит сравнить первый снимок с источником
даже после изменения названия аэропорта.
Откройте Airflow UI по адресу из `.env` (`AIRFLOW_WEB_PORT`), войдите с
`AIRFLOW_USER` и `AIRFLOW_PASSWORD`. Включите `lab_pxf_airports`, нажмите
**Trigger DAG** и дождитесь успеха всех задач. В **Grid** выберите запуск;
его **Run ID** подставьте в команду:
```bash
first_run='вставьте Run ID первого запуска'
uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json
```
Если задача упала, откройте ее **Log**. SQL-файлы доступны контейнеру
из рабочей папки: после правки пересобирать образ не нужно.
`{{ run_id }}` подставляет Airflow, поэтому файл загрузки не следует
выполнять напрямую через `psql`.
</details>
## Испытайте загрузку
### Повторное выполнение
Представьте, что после сбоя пришлось повторить уже выполненную задачу.
Что произойдет с данными при повторном выполнении вашего SQL? За счет
чего в одном снимке не появится вторая копия каждого аэропорта?
Повторите загрузку **в том же запуске** и снова выполните сверку первого
снимка. При ошибке исправьте SQL и повторите опыт на том же Run ID.
<details>
<summary>Как повторить задачу, сохранив Run ID</summary>
В запуске первого снимка выберите `load_airports_to_stg` → **Clear task**.
Снимите **Upstream**, **Downstream**, **Past**, **Future**, **Recursive**
и **Only Failed**, если включены. В **Affected Tasks** должна остаться
только эта задача этого запуска. Нажмите **Clear**, дождитесь успеха,
затем так же повторите `check_airports_dq`.
Новый **Trigger DAG** создал бы другой Run ID. Здесь нужно повторное
выполнение с прежним `_load_id`, чтобы испытать защиту от дублей.
Если загрузка уже записала дубли или неверные значения, исправление SQL
само их не удалит.
Удалите из `stg.airports` строки только этого `_load_id`, затем повторите
исправленную загрузку и DQ в том же запуске. После успешной сверки повторите
их еще раз: теперь вы проверяете защиту от дублей на заполненном снимке.
</details>
### Изменение в источнике
Теперь аэропорт переименовали. До новой загрузки предположите, где будет
видно новое название: в Postgres, во внешней таблице, в первом снимке STG?
Измените русское название одного аэропорта, сохранив ключ и остальные
языки. Для опыта ниже есть готовый SQL, который также сохраняет исходное
название для восстановления.
<details>
<summary>Изменить название в Postgres</summary>
В `make bookings-psql`:
```sql
\set ON_ERROR_STOP on
CREATE TABLE public.lab9_airport_original AS
SELECT airport_code, airport_name
FROM bookings.airports_data ORDER BY airport_code LIMIT 1;
UPDATE bookings.airports_data AS a
SET airport_name = jsonb_set(
b.airport_name, '{ru}', to_jsonb((b.airport_name->>'ru') || ' [lab9]'), false
)
FROM public.lab9_airport_original AS b
WHERE a.airport_code = b.airport_code
RETURNING a.airport_code, b.airport_name AS original, a.airport_name AS changed;
```
Запрос изменяет один аэропорт. Если таблица копии уже существует после
предыдущего опыта, сначала [восстановите источник](#завершение-работы),
сверьте результат и удалите таблицу копии. Затем можно начать новый опыт.
</details>
Найдите этот аэропорт в `stg.airports_ext` и `stg.airports` обычными
SELECT-запросами. Сопоставьте наблюдение со своим предположением.
Запустите `lab_pxf_airports` заново и рассмотрите оба снимка в STG.
Какое название хранится в каждом? Почему одного сравнения количества
строк недостаточно, чтобы заметить переименование?
Сверьте новый снимок с измененным источником, а первый — с исходным.
Это обнаружит, например, загрузку старых данных под новым Run ID.
<details>
<summary>Сверить оба снимка</summary>
В том же терминале, где задан `first_run`:
```bash
uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-after.json
next_run='вставьте Run ID нового запуска'
uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json
uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json
```
Если терминал закрывался, значения Run ID можно снова взять из Airflow.
</details>
## Передайте снимок в ODS
Запустите `bookings_to_gp_ods` без параметров `conf`. Найдите измененный
аэропорт в `ods.airports`: название должно быть новым, а `_load_id` —
совпадать с последним запуском STG. Так вы увидите, что следующий слой
использовал результат вашей загрузки.
ODS выбирает общий батч четырех справочников: `airports`, `airplanes`,
`routes`, `seats`. Поэтому готовый `lab_pxf_airports` загружает все четыре,
используя те же SQL, что основной STG-DAG. Генератор новых дней и
транзакционные таблицы в этом опыте не участвуют.
<details>
<summary>Проверить выбор снимка и данные ODS</summary>
В Log задачи `resolve_stg_batch_id` строка `Используем stg_batch_id=...`
показывает выбранный Run ID. Он должен совпасть с `next_run`.
После завершения DAG выполните:
```bash
uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json --layer ods
```
Сверка учитывает преобразование JSON в русские названия и требует нужный
`_load_id` у всех строк ODS. Если выбран старый батч, посмотрите, завершились
ли все загрузки и DQ последнего запуска `lab_pxf_airports`.
</details>
## Завершение работы
Верните исходное название и получите еще один общий снимок через
`lab_pxf_airports`. Проведите его по цепочке
`bookings_to_gp_ods` → `bookings_to_gp_dds` → `bookings_to_gp_dm`.
В `ods.airports` и `dds.dim_airports` должно вернуться исходное название.
<details>
<summary>Восстановить источник и сверить результат</summary>
В `make bookings-psql`:
```sql
UPDATE bookings.airports_data AS a
SET airport_name = b.airport_name
FROM public.lab9_airport_original AS b
WHERE a.airport_code = b.airport_code
RETURNING a.airport_code, a.airport_name;
```
После нового запуска `lab_pxf_airports` и ODS сравните восстановленный
снимок с исходной копией:
```bash
restored_run='вставьте Run ID запуска STG после восстановления'
uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json
uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json --layer ods
```
Повтор UPDATE восстановления безопасен. После успешной сверки выполните
в Postgres `DROP TABLE public.lab9_airport_original;`.
Если прервались на изменении источника,
продолжите с этого восстановления и нового запуска цепочки.
Для повторения всей лабораторной возьмите новые имена контрольных файлов.
При полной пересборке сохраните SQL-файлы: `make clean` удаляет тома.
Затем снова пройдите быстрый старт со своим решением.
</details>
Снимки в STG остаются историей опыта: первоначальный, с измененным названием
и восстановленный. Последний общий снимок и текущий ODS снова содержат
исходные значения; транзакционные данные остались от быстрого старта.
Результат работы — ваши два SQL-файла и объяснение их поведения: откуда
берется каждый снимок, что защищает его от дублей при повторе и как
изменение источника попадает в следующую загрузку.
Дальше — [загрузки `ods.airplanes` и `ods.seats`](README.md#первая-загрузка-ods).
Они используют уже подготовленный снимок, поэтому заново запускать STG
для начала следующей работы не нужно. Там вы будете преобразовывать поля
полученных данных и заменять текущее состояние в ODS.
+109 -161
View File
@@ -1,196 +1,144 @@
# DAG `bookings_to_gp_dds`: `ods` → `dds` в Greenplum
# Загрузка ODS → DDS: `bookings_to_gp_dds`
Этот DAG — учебный пример загрузки аналитического слоя **DDS** (Star Schema) из текущего состояния **ODS**.
Здесь сосредоточены ключевые паттерны аналитического хранилища: SCD1, SCD2 с hashdiff,
point-in-time join, защитные LEFT JOIN для устойчивости к data quality аномалиям.
DAG наполняет измерения и собирает `dds.fact_flight_sales`.
Одна строка факта соответствует сегменту билета: `(ticket_no, flight_id)`.
У одного билета может быть несколько таких строк.
Сборка показана [на карте](design/architecture-map.html#node=dds.fact_flight_sales)
и в [разборе SQL](design/reading_the_pipeline.md#fact-flight-sales).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- Загружает 6 измерений DDS:
- `dds.dim_calendar` — статическое измерение дат (Full Rebuild);
- `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` — SCD1 UPSERT;
- `dds.dim_routes` — **SCD2** с `hashdiff`, `valid_from`, `valid_to` + денормализация.
- Загружает факт `dds.fact_flight_sales` — инкрементальный UPSERT по зерну `(ticket_no, flight_id)`.
Дождитесь успешных STG и ODS. Таблицы DDS должны быть созданы через
`bookings_dds_ddl` или общий `make ddl-gp`.
Затем в Airflow UI запустите `bookings_to_gp_dds` целиком.
DDS читает состояние ODS; передавать `stg_batch_id` ему не нужно.
> На ветке `main` измерения `dim_routes`, `dim_passengers`, `dim_airplanes` —
> заглушки. Эталон: `dim_calendar`, `dim_airports`, `dim_tariffs`, `fact_flight_sales`.
- Для каждой таблицы выполняет пару задач `load → dq`.
- Использует `_load_id = {{ run_id }}`. DDS не требует `stg_batch_id`, потому что читает
текущее состояние ODS.
| Объект | Что уже выполняется на `main` |
|---|---|
| `dim_calendar` | Заполнение датами с 2016-01-01 по 2030-12-31, только если таблица пуста |
| `dim_airports` | Обновление изменившихся атрибутов и вставка новых аэропортов с постоянными SK |
| `dim_tariffs` | Добавление новых классов обслуживания из `ods.segments` |
| `fact_flight_sales` | Обновление существующих сегментов и вставка новых пар `(ticket_no, flight_id)` |
| `dim_airplanes`, `dim_passengers`, `dim_routes` | Загрузки и DQ пока содержат `SELECT 1;`; требования находятся в [части 2 ТЗ](assignment/analyst_spec.md#часть-2-dds-слой-detailed-data-store--измерения) |
## Что должно быть готово перед запуском
До выполнения заданий три студенческих измерения остаются пустыми.
Готовый факт при этом загружается: отсутствующие ключи измерений сохраняются
как `NULL`, чтобы можно было изучить цепочку до написания собственного SQL.
1) Стенд поднят:
## Порядок задач
```bash
make up
```
В [DAG](../airflow/dags/bookings_to_gp_dds.py) сначала выполняется
`load_dds_dim_calendar → dq_dds_dim_calendar`. Затем:
2) STG и ODS уже загружены:
1. Параллельно могут загружаться `dim_airports`, `dim_airplanes`, `dim_tariffs`
и `dim_passengers`; у каждого измерения своя следующая DQ-задача.
2. `load_dds_dim_routes` ждет `dq_dds_dim_airports` и `dq_dds_dim_airplanes`,
затем выполняется `dq_dds_dim_routes`.
3. `load_dds_fact_flight_sales` ждет DQ всех пяти измерений после календаря.
4. `dq_dds_fact_flight_sales → finish_dds_summary` завершают запуск.
- выполнены DAG-и `bookings_to_gp_stage` и `bookings_to_gp_ods`;
- DDL-объекты созданы (`bookings_dds_ddl` или `make ddl-gp`).
Это порядок задач Airflow. Источники строк и поля соединений показаны
на карте отдельно от него.
## Как запустить
## Как работают готовые загрузки
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DDS впервые — выполните `bookings_dds_ddl`.
3) Запустите `bookings_to_gp_dds`.
[dim_calendar_load.sql](../sql/dds/dim_calendar_load.sql) проверяет
`NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1)`. Если есть хотя бы одна
строка, скрипт не вставляет даты. Повторный запуск не пересоздает календарь
и не дополняет частично заполненную таблицу.
[Его DQ](../sql/dds/dim_calendar_dq.sql) проверяет также покрытие дат рейсов ODS.
## Граф зависимостей
[dim_airports_load.sql](../sql/dds/dim_airports_load.sql) сохраняет SK
существующего аэропорта, обновляет изменившиеся атрибуты через
`IS DISTINCT FROM` и вставляет новые бизнес-ключи.
[dim_tariffs_load.sql](../sql/dds/dim_tariffs_load.sql) только добавляет
недостающие значения `fare_conditions`.
Метки измененных строк относятся к текущему запуску DDS;
календарь не содержит `_load_id` и `_load_ts`.
```
load_dds_dim_calendar → dq_dds_dim_calendar
├─ load_dds_dim_airports → dq_dds_dim_airports ─┐
│ ├─ load_dds_dim_routes
├─ load_dds_dim_airplanes → dq_dds_dim_airplanes ─┘ └─ dq_dds_dim_routes
│ │
├─ load_dds_dim_tariffs → dq_dds_dim_tariffs │
│ │
└─ load_dds_dim_passengers → dq_dds_dim_passengers │
│
все 5 dq_dds_dim_* ─────────────────────────────────────────┘
└─ load_dds_fact_flight_sales
└─ dq_dds_fact_flight_sales
└─ finish_dds_summary
```
В [fact_flight_sales_load.sql](../sql/dds/fact_flight_sales_load.sql)
есть две операции:
Ключевой момент: `dim_routes` зависит от `dim_airports` и `dim_airplanes` (денормализация),
а факт ждёт завершения **всех** пяти измерений.
- `UPDATE` меняет цену, место, признак посадки и метки загрузки, если эти
бизнес-поля изменились. SK измерений остаются прежними.
- `INSERT` собирает данные из ODS и измерений для пар
`(ticket_no, flight_id)`, которых еще нет в факте.
## Как это работает внутри (по шагам)
Факт не отбирает дельту по HWM. Скрипт читает ODS и сопоставляет бизнес-ключи
с уже записанными строками.
### 1) `load_dds_dim_calendar` → `dq_dds_dim_calendar`
Аэропорты вылета и прилета находятся через `ods.routes → dim_airports`.
Из ODS выбирается строка с наибольшим `validity` для `route_no`; сортировка
идет по TEXT, дата рейса в этом отборе не участвует.
`route_sk` определяется отдельно: по дате вылета в полуоткрытом интервале
`[valid_from, valid_to)` измерения `dim_routes`. Из найденной версии берется
код модели для поиска `airplane_sk`.
[Разбор соединений](design/reading_the_pipeline.md#fact-joins) показывает,
какие строки сохраняет `LEFT JOIN` и что происходит при отсутствии измерения.
- **SQL:** `sql/dds/dim_calendar_load.sql`, `sql/dds/dim_calendar_dq.sql`
- **Паттерн:** Full Rebuild — каждый запуск пересоздаёт календарь целиком.
Измерение маленькое и детерминированное, дельту считать нет смысла.
## Учебные измерения
### 2–5) SCD1-измерения (параллельно после calendar)
Начните с [моделей самолетов](assignment/analyst_spec.md#21-ddsdim_airplanes-scd1)
и [пассажиров](assignment/analyst_spec.md#22-ddsdim_passengers-scd1), затем
переходите к [версиям маршрута](assignment/analyst_spec.md#23-ddsdim_routes-scd2).
Поля, hashdiff и алгоритм SCD2 определены в ТЗ.
| # | Задача | SQL-файлы | Что загружает |
|---|--------|-----------|---------------|
| 2 | `load_dds_dim_airports` → `dq_dds_dim_airports` | `sql/dds/dim_airports_load.sql`, `sql/dds/dim_airports_dq.sql` | Аэропорты (код, город, координаты) |
| 3 | `load_dds_dim_airplanes` → `dq_dds_dim_airplanes` | `sql/dds/dim_airplanes_load.sql`, `sql/dds/dim_airplanes_dq.sql` | Самолёты (код, модель, кол-во мест) ⚠️ заглушка на main |
| 4 | `load_dds_dim_tariffs` → `dq_dds_dim_tariffs` | `sql/dds/dim_tariffs_load.sql`, `sql/dds/dim_tariffs_dq.sql` | Тарифы (класс обслуживания) |
| 5 | `load_dds_dim_passengers` → `dq_dds_dim_passengers` | `sql/dds/dim_passengers_load.sql`, `sql/dds/dim_passengers_dq.sql` | Пассажиры (ID, имя, контакты) ⚠️ заглушка на main |
У маршрута изменение версионируемых атрибутов создает новую строку.
Описательные поля из других измерений обновляются только у открытых версий
(`valid_to IS NULL`), без новой версии; закрытые версии сохраняют свои значения.
Это правило также записано в [алгоритме ТЗ](assignment/analyst_spec.md#алгоритм-scd2-пошагово).
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
После реализации измерений выполните
[пересчет факта и витрин](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Обычный повторный запуск DDS не заполнит SK в старых строках факта,
поскольку его `UPDATE` эти поля не меняет.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Описание ниже — спецификация того, что нужно реализовать. Образец SCD2 — в ветке `solution`.
## Проверка результата
### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2)
- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql`
- **Паттерн:** SCD2 — самый нетривиальный паттерн в проекте. Работает в 3 фазы:
**Фаза 1. Hashdiff и закрытие старых версий.**
Скрипт считает MD5-хеш от шести бизнес-атрибутов маршрута (`departure_airport`, `arrival_airport`,
`airplane_code`, `days_of_week`, `departure_time`, `duration`).
Если хеш текущей версии в DDS не совпадает с хешем из ODS — старая версия закрывается
(`valid_to = CURRENT_DATE`). Также закрываются маршруты, исчезнувшие из ODS.
**Фаза 2. Вставка новых версий.**
Для изменённых и совершенно новых маршрутов создаётся новая строка.
`valid_from` выставляется в `1900-01-01` для первой версии маршрута и `CURRENT_DATE` для версии 2+.
Суррогатный ключ (`route_sk`) генерируется через `MAX(route_sk) + ROW_NUMBER()`.
> **Важно:** такая генерация SK безопасна только при `max_active_runs=1` (Airflow гарантирует
> последовательный запуск). В боевых системах используют sequence.
**Фаза 3. Обновление денормализованных атрибутов.**
`dim_routes` хранит денормализованные SCD1-атрибуты из `dim_airports` (города)
и `dim_airplanes` (модель, кол-во мест). Если, например, город переименовали —
фаза 3 обновляет **все** версии маршрута (и текущие, и исторические),
при этом `_load_id` и `_load_ts` не перезаписываются (lineage версий сохраняется).
### 7) `load_dds_fact_flight_sales` → `dq_dds_fact_flight_sales`
- **SQL:** `sql/dds/fact_flight_sales_load.sql`, `sql/dds/fact_flight_sales_dq.sql`
- **Зерно:** `(ticket_no, flight_id)` — один билет на один рейс.
- **Паттерн:** инкрементальный UPSERT.
Три учебных приёма в этом скрипте:
**Защитные LEFT JOIN (defensive coding).**
Все JOIN-ы с измерениями — `LEFT JOIN`. На ветке `solution` все измерения заполнены,
и NULL SK не возникают в штатном режиме. На ветке `main` студенческие измерения
(`dim_passengers`, `dim_routes`, `dim_airplanes`) — заглушки, поэтому соответствующие
SK будут NULL до реализации студентом.
DQ-проверки факта на main:
- `tariff_sk` — **запрещён** NULL (EXCEPTION);
- `departure_airport_sk`, `arrival_airport_sk` (через `ods.routes`, эталон) — порог **1%** NULL (EXCEPTION);
- `passenger_sk`, `route_sk`, `airplane_sk` (студенческие) — только **NOTICE** (100% NULL допустимо);
- `calendar_sk` — порог **1%** NULL.
> После реализации всех измерений: `TRUNCATE dds.fact_flight_sales` → перезагрузка →
> все SK заполнены. Полную версию DQ см. в ветке `solution`.
**Два пути lookup для аэропортов и маршрутов.**
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
`dim_airports`. Аэропорты вылета/прилёта одинаковы во всех версиях маршрута, поэтому
point-in-time логика не нужна — безопасно брать актуальную версию из ODS.
`route_sk` и `airplane_sk` разрешаются через point-in-time join с SCD2 `dim_routes`:
```sql
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
```
Это гарантирует, что факт привязывается к той версии маршрута, которая была актуальна
на дату рейса.
**UPDATE мутабельных полей.**
UPDATE обновляет только `seat_no`, `price`, `is_boarded` (данные, которые реально
могут измениться — посадка пассажира, корректировка цены). SK измерений не перезаписываются —
они зафиксированы на момент вставки.
### 8) `finish_dds_summary`
Ждёт завершения DQ факта и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` выполните:
```sql
SELECT COUNT(*) FROM dds.dim_calendar;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.dim_tariffs;
SELECT COUNT(*) FROM dds.fact_flight_sales;
-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
(SELECT COUNT(*) FROM ods.segments) AS segment_rows;
-- dim_routes, dim_passengers, dim_airplanes — заглушки на main.
-- Проверки ниже станут осмысленны после реализации задания.
SELECT COUNT(*) FROM dds.dim_routes;
-- Проверка SCD2 (после реализации dim_routes):
-- текущие версии маршрутов (valid_to IS NULL)
SELECT COUNT(*) AS current_versions,
(SELECT COUNT(*) FROM dds.dim_routes) AS total_versions
FROM dds.dim_routes
WHERE valid_to IS NULL;
SELECT ticket_no, flight_id, departure_airport_sk, arrival_airport_sk,
route_sk, airplane_sk, passenger_sk, price, is_boarded
FROM dds.fact_flight_sales
ORDER BY ticket_no, flight_id
LIMIT 10;
```
Ожидаемо: `fact_rows ≈ ods_rows`, `dim_calendar` и `dim_airports` непусты.
`dim_routes` — после реализации задания: `current_versions ≤ total_versions`.
На подготовленном стенде готовые измерения и факт непусты.
`fact_rows` должен точно совпасть с `segment_rows`.
[Готовая DQ факта](../sql/dds/fact_flight_sales_dq.sql) также ищет дубли
по паре ключей и проверяет обязательные поля.
## Типичные ошибки
На учебной ветке `NULL` в `passenger_sk`, `route_sk` и `airplane_sk`
вызывает только `NOTICE`. `NULL` в `tariff_sk` вызывает ошибку;
для строк с отсутствующим аэропортом и для `calendar_sk` допустима доля
не выше 1%. Эти послабления не заменяют проверку заполнения ключей после задания.
- `relation "dds..." does not exist`:
- не применён DDS DDL (`bookings_dds_ddl` или `make ddl-gp`).
- DQ падает на `dim_routes`:
- проверьте согласованность `ods.routes` (дубли/аномальные версии) и перезапустите DAG.
- DQ падает на `fact_flight_sales` по coverage:
- проверьте, что ODS DAG завершился успешно без пропуска задач.
Свои `*_dq.sql` реализуйте по ТЗ и
[справочнику DQ](reference/dq_taxonomy.md). Для дополнительной проверки DDS
используйте `validate_dds` в `bookings_validate`, соблюдая
[порядок запуска и восстановления](assignment/README.md#запуск-и-проверка).
Далее переходите к [DM](bookings_to_gp_dm.md).
## Если загрузка не прошла
- Нет `dds.*`: выполните `bookings_dds_ddl`.
- DQ календаря сообщает о непокрытых датах: сравните даты вылета ODS
с диапазоном и содержимым `dim_calendar`. Повторный запуск загрузки
непустого календаря не заполнит пропуски.
- Число строк факта отличается от ODS: проверьте ключи билета, бронирования
и рейса, затем соединения в `fact_src`. Потеря строки на `INNER JOIN`
и размножение строк из-за нескольких подходящих версий дают разные причины
одного расхождения.
- Ошибка в вашей DQ маршрутов: проверьте открытые версии, интервалы и hashdiff
по ТЗ. Успешная заглушка `SELECT 1;` этих свойств не проверяет.
+86 -154
View File
@@ -1,181 +1,113 @@
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
# Загрузка DDS → DM: `bookings_to_gp_dm`
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
Эталон: витрина `sales_report`. Остальные 4 витрины — задания (заглушки `SELECT 1;` на ветке `main`).
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
это ключевая учебная ценность данного DAG.
DAG собирает витрины из факта и измерений DDS. На `main` полностью работает
`dm.sales_report`; остальные четыре загрузки и их DQ содержат `SELECT 1;`.
Успешный запуск с заглушками не означает, что все витрины наполнены.
## Что делает DAG
На [карте sales_report](design/architecture-map.html#node=dm.sales_report)
можно проследить источники полей. Как сегменты билетов превращаются
в строки витрины, объясняет [руководство о структуре](design/db_schema.md#агрегация-фактов-в-витрине-продаж).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
## Перед запуском
| Витрина | Зерно (grain) | Паттерн загрузки | Статус на main |
|---------|---------------|------------------|----------------|
| `dm.sales_report` | (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk) | Инкрементальный UPSERT (HWM по датам) | Эталон |
| `dm.route_performance` | route_bk | Full Rebuild (TRUNCATE + INSERT) | Задание (заглушка) |
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) | Задание (заглушка) |
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) | Задание (заглушка) |
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) | Задание (заглушка) |
Дождитесь успешного `bookings_to_gp_dds`. Создайте таблицы через
`bookings_dm_ddl`, если еще не запускали его или общий `make ddl-gp`.
Затем запустите `bookings_to_gp_dm` в Airflow UI.
Общий порядок находится в [инструкции запуска DAG](dag_execution_order.md).
## Что должно быть готово перед запуском
Для готовой `sales_report` достаточно готового факта, календаря, аэропортов
и тарифов. Для своих витрин сначала заполните нужные измерения и выполните
[пересчет факта после заданий DDS](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
1) Стенд поднят:
## Что делает sales_report
```bash
make up
```
Зерно витрины: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)`.
Одна строка содержит продажи сегментов за дату вылета, пару аэропортов и тариф.
`tickets_sold = COUNT(*)` считает строки факта в этой группе.
2) STG, ODS и DDS уже загружены:
[sales_report_load.sql](../sql/dm/sales_report_load.sql) работает в три шага:
- выполнены DAG-и `bookings_to_gp_stage`, `bookings_to_gp_ods`, `bookings_to_gp_dds`;
- DDL-объекты созданы (`bookings_dm_ddl` или `make ddl-gp`).
1. Находит даты вылета фактов с `_load_ts > MAX(_load_ts)` витрины.
Для пустой витрины нижняя граница равна `1900-01-01`.
2. Для этих дат пересчитывает агрегаты по всем их фактам во временную таблицу.
В расчет входят и строки, загруженные ранее: например, новая посадка
меняет итог дня, сохраняя остальные продажи этого дня.
3. Обновляет существующие группы при изменении проверяемых полей и вставляет
новые. `_load_id` записанных строк равен текущему `run_id` DM.
## Как запустить
Если после пропуска запуска в DDS накопились факты с более новой `_load_ts`,
следующий запуск включит их даты в пересчет. Изменение только описания
измерения само по себе не выбирает дату: отбор привязан к метке факта.
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DM впервые — выполните `bookings_dm_ddl`.
3) Запустите `bookings_to_gp_dm`.
[sales_report_dq.sql](../sql/dm/sales_report_dq.sql) берет даты из строк витрины,
записанных текущим запуском. Для них проверяет непустоту при наличии фактов,
дубли ключа, обязательные поля, неотрицательную выручку и долю посадок от 0 до 1.
Если витрина в этом запуске не менялась, DQ выводит `NOTICE` и завершает работу.
## Граф зависимостей
## Учебные витрины
```
start_dm
├── load_dm_sales_report → dq_dm_sales_report ─┐
├── load_dm_route_performance → dq_dm_route_performance ─┤
├── load_dm_passenger_loyalty → dq_dm_passenger_loyalty ─┼─ finish_dm_summary
├── load_dm_airport_traffic → dq_dm_airport_traffic ─┤
└── load_dm_monthly_overview → dq_dm_monthly_overview ─┘
```
Ниже указано ожидаемое поведение заданий. Поля и формулы находятся в ТЗ;
загрузку и DQ для каждого объекта нужно написать самостоятельно.
Все 5 веток полностью независимы и работают параллельно.
| Задание | Одна строка | Что нужно пересчитывать |
|---|---|---|
| [airport_traffic](assignment/analyst_spec.md#31-dmairport_traffic) | Дата и аэропорт | Полные итоги затронутых дат, с учетом ролей вылета и прилета |
| [route_performance](assignment/analyst_spec.md#32-dmroute_performance) | Маршрут по `route_bk` | Всю историю при каждом запуске, объединяя версии маршрута |
| [monthly_overview](assignment/analyst_spec.md#33-dmmonthly_overview) | Год, месяц и модель по `airplane_sk` | Затронутые месяцы; сначала метрики рейса, затем месяца |
| [passenger_loyalty](assignment/analyst_spec.md#34-dmpassenger_loyalty) | Пассажир по `passenger_sk` | Всю историю пассажиров, чьи факты изменились |
## Как это работает внутри (по шагам)
Готовый пример отбора затронутых дат находится в `sales_report_load.sql`.
В заданиях меняется единица пересчета: дата, месяц, пассажир или вся таблица.
Следуйте [порядку заданий](assignment/README.md#следующие-этапы), чтобы каждый
следующий расчет опирался на уже освоенный прием.
### 1) `load_dm_sales_report` → `dq_dm_sales_report`
## Порядок задач
- **SQL:** `sql/dm/sales_report_load.sql`, `sql/dm/sales_report_dq.sql`
- **Паттерн:** инкрементальный UPSERT (HWM по датам).
В [DAG](../airflow/dags/bookings_to_gp_dm.py) `start_dm` запускает пять
независимых веток. Каждая имеет вид
`load_dm_<витрина> → dq_dm_<витрина> → finish_dm_summary`.
Финальная задача ждет все пять DQ. Витрины читают DDS и не читают друг друга,
поэтому между их задачами загрузки нет зависимостей.
Витрина агрегирует продажи билетов по дате, аэропортам вылета/прилёта и тарифу.
Инкрементальность работает через HWM: витрина сравнивает свой `MAX(_load_ts)` с `_load_ts`
фактов в DDS и пересчитывает агрегаты только для **затронутых дат**.
## Проверка результата
> Если витрина пуста — `1900-01-01` заберёт всю историю (первичная загрузка).
> Если DAG не запускался несколько дней — при следующем запуске витрина автоматически
> «догонит» всю накопленную дельту.
Учебные приёмы:
- **TEMP TABLE** для однократной агрегации (канон для MPP);
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
- **Паттерн:** Full Rebuild (TRUNCATE + INSERT).
Витрина агрегирует эффективность маршрутов за всю историю. Таблица маленькая (~1000 строк),
поэтому пересоздать её с нуля дешевле, чем вычислять дельту. Дополнительная причина:
таблица хранится в формате **AO Column Store**, который не поддерживает эффективный UPDATE/DELETE.
Трёхшаговый паттерн:
1. **TRUNCATE** — очистка (единственный эффективный способ для AO).
2. **Агрегация** по `route_bk` — факты суммируются через **все исторические версии** маршрута
(route_sk из SCD2), чтобы не терять данные при версионировании.
3. **JOIN** с текущей (актуальной, `valid_to IS NULL`) версией `dim_routes` для денормализации.
> Благодаря денормализации `dim_routes` — один JOIN вместо четырёх
> (аэропорты вылета/прилёта, самолёт уже хранятся в `dim_routes`).
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
`avg_ticket_price = total_revenue / total_tickets`.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
- **Паттерн:** инкрементальный UPSERT по «затронутым ключам».
В отличие от `sales_report` (где инкремент по датам), здесь HWM находит **конкретных пассажиров**
с новыми фактами, а затем пересчитывает для них всю историю. Это гарантирует точность
накопительных агрегатов (`total_spent`, `first/last_flight_date`).
Учебные приёмы:
- **DISTINCT ON** (PostgreSQL-специфика) для нахождения моды (самый частый тариф пассажира);
- **Агрегация SCD2 по BK**: при подсчёте уникальных маршрутов используем `route_bk`,
а не `route_sk`, т.к. один маршрут может иметь несколько версий;
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
при data quality аномалиях в измерениях).
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
- **Паттерн:** инкрементальный UPSERT по датам.
Витрина показывает пассажиропоток аэропортов по дням (вылеты + прилёты).
Учебный приём — **Dual-role dimension через UNION ALL**: один билет превращается
в два «события» (вылет из одного аэропорта и прилёт в другой).
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
- **Паттерн:** инкрементальный UPSERT по месяцам.
Витрина показывает помесячную статистику по типам самолётов.
Учебный приём — **двухуровневая агрегация**: чтобы честно посчитать `avg_load_factor`,
сначала считаем load factor для каждого рейса (`boarded / total_seats`),
затем берём среднее по месяцу. Прямая агрегация `SUM(boarded) / SUM(seats)` дала бы
искажённый результат (взвешенный по числу билетов, а не рейсов).
> **Ограничение SCD1:** `total_seats` берётся из текущего состояния `dim_airplanes`.
> Если самолёт переоборудовали в прошлом, для точного исторического расчёта
> потребовалось бы SCD2-измерение.
### 6) `start_dm` / `finish_dm_summary`
- `start_dm` — стартовый sentinel, от которого расходятся все 5 параллельных веток.
- `finish_dm_summary` — ждёт завершения всех DQ-задач и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` посмотрите готовую витрину:
```sql
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.passenger_loyalty; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.airport_traffic; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.monthly_overview; -- будет непусто после реализации задания
-- Инвариант sales_report: посаженных не больше, чем продано
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
SELECT flight_date, departure_airport_bk, arrival_airport_bk,
fare_conditions, tickets_sold, passengers_boarded, total_revenue
FROM dm.sales_report
ORDER BY flight_date DESC, departure_airport_bk, arrival_airport_bk,
fare_conditions
LIMIT 10;
SELECT COUNT(*) AS invalid_boarding_rows
FROM dm.sales_report
WHERE tickets_sold < passengers_boarded;
```
Ожидаемо: `dm.sales_report` непуста. Остальные — после реализации заданий.
После первого успешного запуска `sales_report` непуста,
`invalid_boarding_rows = 0`. Возьмите одну строку и найдите на карте,
откуда пришли ее дата, два аэропорта и тариф.
## Типичные ошибки
После реализации своих витрин запустите `bookings_validate` и посмотрите
`validate_dm`. Проверки этой группы требуют непустые таблицы;
правильность метрик должна проверять ваша DQ по ТЗ.
Соблюдайте [правила запуска валидации](assignment/README.md#запуск-и-проверка):
в том же DAG выполняется активный SCD2-тест с восстановлением данных.
- `relation "dm..." does not exist`:
- не применён DM DDL (`bookings_dm_ddl` или `make ddl-gp`).
- DQ падает на `sales_report` по `boarding_rate`:
- проверьте, что DDS загрузился корректно (`dds.fact_flight_sales` непуста).
- DQ падает на `passenger_loyalty` с ошибкой FK:
- проверьте, что `dds.dim_passengers` содержит всех пассажиров из факта.
- `finish_dm_summary` не выполняется:
- одна из DQ-задач упала; найдите в логах Airflow задачу с ошибкой и исправьте.
## Если загрузка не прошла
- Нет `dm.*`: выполните `bookings_dm_ddl`.
- `sales_report` пуста: проверьте наличие факта и соответствий его SK
календарю, аэропортам и тарифам. Витрина соединяет их через `INNER JOIN`.
- Учебная витрина пуста: проверьте замену загрузочной заглушки и наличие
необходимых SK факта после заданий DDS.
- Ошибка метрики: разберите одну группу и сравните результат со строками факта
и формулой в ТЗ. Дополнительные примеры проверок есть в
[справочнике DQ](reference/dq_taxonomy.md).
- `finish_dm_summary` не выполняется: найдите первую упавшую задачу,
откройте ее Log и исправьте причину до следующего запуска слоя.
+102 -143
View File
@@ -1,180 +1,139 @@
# DAG `bookings_to_gp_ods`: `stg` → `ods` в Greenplum
# Загрузка STG → ODS: `bookings_to_gp_ods`
Этот DAG — учебный пример загрузки типизированного слоя **ODS** из уже подготовленного слоя **STG**.
Логика каноничная: **TRUNCATE + INSERT** для snapshot-справочников, **SCD1 UPSERT** для транзакционных
таблиц + DQ-проверки.
DAG приводит данные STG к целевым типам и оставляет одну строку на бизнес-ключ.
Снимки справочников заменяют предыдущее состояние; транзакционные таблицы
получают новые и измененные строки. Смысл ключей показан на
[карте ODS](design/architecture-map.html#node=ods.airports),
а готовый снимок разобран в [stg.airports → ods.airports](design/reading_the_pipeline.md#ods-airports).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- Определяет `stg_batch_id` — последний согласованный батч, по которому все 4 snapshot-справочника
(`airports`, `airplanes`, `routes`, `seats`) уже приехали в STG.
- Загружает 9 таблиц ODS: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`,
`flights`, `segments`, `boarding_passes` (на ветке `main` загрузка `airplanes` и `seats` —
заглушки; реализуйте их по ТЗ в `docs/assignment/analyst_spec.md`).
- Для каждой таблицы выполняет пару задач `load → dq`.
- Snapshot-справочники фильтруются по `stg_batch_id`, транзакционные таблицы — по HWM (`_load_ts`).
- Для snapshot-справочников дополнительно синхронизирует ключи (удаляет из ODS записи,
отсутствующие в выбранном STG-батче).
- Для `flights` дополнительно добирает рейсы из истории `stg.flights`, если на них
ссылаются `stg.segments` (чтобы сохранить ссылочную целостность `segments.flight_id → flights.flight_id`).
Дождитесь успешного `bookings_to_gp_stage`. Создайте ODS через
`bookings_ods_ddl`, если еще не запускали его или общий `make ddl-gp`.
Подготовка стенда описана в [README](../README.md), последовательность слоев
в [порядке запуска DAG](dag_execution_order.md).
## Что должно быть готово перед запуском
На `main` работают `airports`, `routes`, `bookings`, `tickets`, `flights`,
`segments` и `boarding_passes`. Загрузки и DQ для `airplanes` и `seats`
содержат `SELECT 1;`. Успешный DAG с этими заглушками оставит две таблицы пустыми.
Их нужно заполнить в [первом задании](assignment/README.md#первая-загрузка-ods).
1) Стек поднят:
## Запуск и выбор снимка
```bash
make up
```
В Airflow UI запустите `bookings_to_gp_ods` целиком.
Первая задача, `resolve_stg_batch_id`, выбирает последний общий `_load_id`
четырех справочников STG: `airports`, `airplanes`, `routes`, `seats`.
Общие батчи находятся через `INTERSECT`, последний выбирается по времени
загрузки их строк. Результат передается в SQL через XCom.
2) STG-слой создан и заполнен:
- запущен `bookings_stg_ddl` (или `make ddl-gp`);
- хотя бы один раз выполнен DAG `bookings_to_gp_stage`.
3) ODS-таблицы созданы (один из вариантов):
- учебный: запустить DAG `bookings_ods_ddl`;
- шорткат: `make ddl-gp` (создаёт и STG, и ODS).
## Как запустить
1) Откройте Airflow UI: http://localhost:8080.
2) Запустите DAG `bookings_to_gp_ods`.
3) (Опционально) передайте `stg_batch_id` в конфиге запуска:
Resolver проверяет наличие батча в таблицах, а не статус запуска STG в Airflow.
Поэтому сначала дождитесь всех STG DQ. Если нужен определенный снимок,
передайте в Trigger DAG JSON с его реальным `_load_id`:
```json
{"stg_batch_id": "manual__2026-02-22T12:00:00+00:00"}
{"stg_batch_id": "<Run ID успешного запуска STG>"}
```
Если конфиг не передан, DAG автоматически возьмёт последний согласованный snapshot-батч.
Явно переданный идентификатор resolver принимает без проверки согласованности.
Убедитесь, что он есть во всех четырех справочниках; произвольная строка
или Run ID запуска ODS здесь не подойдут.
## Граф зависимостей
## Что выполняет загрузка
```
resolve_stg_batch_id
├─ load_ods_bookings → dq_ods_bookings
│ └─ load_ods_tickets → dq_ods_tickets ──────────────────┐
│ │
├─ load_ods_airports → dq_ods_airports ─┐ │
│ ├─ load_ods_routes │
├─ load_ods_airplanes → dq_ods_airplanes ─┤ └─ dq_ods_routes
│ │ └─ load_ods_flights
│ │ └─ dq_ods_flights ─┐
│ │ │
│ │ dq_ods_flights + dq_ods_tickets
│ │ └─ load_ods_segments
│ │ └─ dq_ods_segments
│ │ └─ load_ods_boarding_passes
│ │ └─ dq_ods_boarding_passes ─┐
│ │ │
│ └─ load_ods_seats │
│ └─ dq_ods_seats ─────────────────────────────────┤
│ │
└────────────────────────────────────────────────────────────────── finish_ods_summary ◀──────────┘
```
| Объекты | Реализация на `main` | Как отбираются данные |
|---|---|---|
| `airports`, `routes` | Готовые `TRUNCATE + INSERT` | Один снимок по `stg_batch_id`, дедупликация по бизнес-ключу |
| `airplanes`, `seats` | Заглушки; требуемый полный снимок описан в [части 1 ТЗ](assignment/analyst_spec.md#часть-1-ods-слой-operational-data-store) | По выбранному `stg_batch_id` после реализации задания |
| `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Готовые `UPDATE + INSERT` через временную таблицу | Новые строки STG по `_load_ts` относительно `MAX(_load_ts)` соответствующей ODS-таблицы |
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_ods_summary`.
В [airports_load.sql](../sql/ods/airports_load.sql) и
[routes_load.sql](../sql/ods/routes_load.sql) сначала очищается таблица,
затем вставляется выбранный снимок. Вместе с заменой исчезают ключи,
которых больше нет в снимке. Это один полный пересчет небольшой AO-таблицы;
отдельного `DELETE` для синхронизации ключей нет.
## Как это работает внутри (по шагам)
Для транзакционных таблиц, например [bookings](../sql/ods/bookings_load.sql),
дельта фиксируется во временной таблице до `UPDATE` и `INSERT`.
Поэтому обе операции читают один набор строк, даже когда обновление меняет
максимальную `_load_ts` в ODS. Дельта может охватывать несколько STG-батчей;
параметр `stg_batch_id` ее не ограничивает.
### 1) `resolve_stg_batch_id` (Python)
[flights_load.sql](../sql/ods/flights_load.sql) дополнительно читает историю
STG для рейсов, на которые ссылаются новые сегменты. Так сегмент может получить
свой рейс, даже если тот пришел раньше текущего инкремента.
Определяет, какой STG-батч использовать для snapshot-справочников.
Если `stg_batch_id` не передан через `dag_run.conf`, ищет последний **согласованный** батч —
`_load_id`, который есть одновременно во всех четырёх snapshot-таблицах
(`stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`).
Для этого используется **INTERSECT** по `_load_id`.
Метки загрузки имеют два варианта:
> **Зачем согласованность?** Чтобы ODS загружал только те данные, для которых приехали
> ВСЕ связанные справочники. Иначе возможна потеря ссылочной целостности при сборке витрин.
- У снимков `_load_id` сохраняет выбранный STG-батч, `_load_ts = now()`.
- У транзакционных строк обе метки переносятся из STG. Сохраненная `_load_ts`
служит границей следующего инкремента.
### 2–10) Загрузка 9 таблиц: `load_ods_*` → `dq_ods_*`
Названия и смысл служебных полей собраны в
[соглашениях об именах](design/naming_conventions.md).
Каждая пара задач работает одинаково:
## Порядок задач на main
| # | Задача | SQL-файл | Тип загрузки |
|---|--------|----------|--------------|
| 2 | `load_ods_bookings` → `dq_ods_bookings` | `sql/ods/bookings_load.sql`, `sql/ods/bookings_dq.sql` | HWM (инкремент) |
| 3 | `load_ods_tickets` → `dq_ods_tickets` | `sql/ods/tickets_load.sql`, `sql/ods/tickets_dq.sql` | HWM (инкремент) |
| 4 | `load_ods_airports` → `dq_ods_airports` | `sql/ods/airports_load.sql`, `sql/ods/airports_dq.sql` | snapshot по `stg_batch_id` |
| 5 | `load_ods_airplanes` → `dq_ods_airplanes` | `sql/ods/airplanes_load.sql`, `sql/ods/airplanes_dq.sql` | snapshot по `stg_batch_id` ⚠️ заглушка на main |
| 6 | `load_ods_routes` → `dq_ods_routes` | `sql/ods/routes_load.sql`, `sql/ods/routes_dq.sql` | snapshot по `stg_batch_id` |
| 7 | `load_ods_seats` → `dq_ods_seats` | `sql/ods/seats_load.sql`, `sql/ods/seats_dq.sql` | snapshot по `stg_batch_id` ⚠️ заглушка на main |
| 8 | `load_ods_flights` → `dq_ods_flights` | `sql/ods/flights_load.sql`, `sql/ods/flights_dq.sql` | HWM (инкремент) |
| 9 | `load_ods_segments` → `dq_ods_segments` | `sql/ods/segments_load.sql`, `sql/ods/segments_dq.sql` | HWM (инкремент) |
| 10 | `load_ods_boarding_passes` → `dq_ods_boarding_passes` | `sql/ods/boarding_passes_load.sql`, `sql/ods/boarding_passes_dq.sql` | HWM (инкремент) |
В [DAG](../airflow/dags/bookings_to_gp_ods.py) после resolver одновременно
могут стартовать три ветки. В таблице указаны зависимости задач Airflow:
### Два паттерна загрузки
| Загрузка | Что должно завершиться перед ней |
|---|---|
| `load_ods_bookings`, `load_ods_airports`, `load_ods_airplanes` | `resolve_stg_batch_id` |
| `load_ods_tickets` | `dq_ods_bookings` |
| `load_ods_routes` | `dq_ods_airports` |
| `load_ods_seats` | `dq_ods_airplanes` |
| `load_ods_flights` | `dq_ods_routes` |
| `load_ods_segments` | `dq_ods_flights` и `dq_ods_tickets` |
| `load_ods_boarding_passes` | `dq_ods_segments` |
В ODS используются **два разных паттерна** — выбор зависит от типа данных и формата хранения:
Каждый `load_ods_*` ведет к своему `dq_ods_*`.
`finish_ods_summary` ждет `dq_ods_boarding_passes` и `dq_ods_seats`.
На `main` маршруты не ждут студенческий `airplanes`; проверка ссылки
`routes.airplane_code → airplanes` в их DQ отключена.
**Snapshot-справочники** (`airports`, `airplanes`, `routes`, `seats`) — **TRUNCATE + INSERT**:
## Проверка результата
1. **TRUNCATE** — полная очистка таблицы.
2. **INSERT** — вставка всех строк из STG-батча (`_load_id = stg_batch_id`) с дедупликацией
через `ROW_NUMBER()`.
> Почему не UPSERT? Эти таблицы хранятся в формате **AO Row** (`appendonly=true`),
> который не поддерживает эффективный row-level UPDATE (вызывает bloat).
> Для маленьких справочников (~100–300 строк) полная перезагрузка быстрее и чище.
> На ветке `main` загрузка `airplanes` и `seats` — заглушки.
> Паттерн TRUNCATE + INSERT описан выше; используйте `airports_load.sql` как образец.
**Транзакционные таблицы** (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) —
**SCD1 UPSERT**:
1. **TEMP TABLE** — собирает дельту (новые/изменённые строки) с дедупликацией внутри батча
через `ROW_NUMBER()`. Временная таблица автоматически удаляется (`ON COMMIT DROP`).
2. **UPDATE** — обновляет существующие строки. Использует `IS DISTINCT FROM` для корректного
сравнения `NULL`-значений (обычный `<>` не обнаружит изменение `NULL → значение`).
3. **INSERT** — добавляет новые строки (которых нет в ODS по бизнес-ключу).
Транзакционные таблицы фильтруются по **HWM** — `WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table)`.
Это сделано, чтобы не потерять инкременты, если STG-DAG запускался несколько раз
до запуска ODS-DAG'а.
### DQ-проверки (одинаковый паттерн)
Каждый `*_dq.sql` — PL/pgSQL-блок (`DO $$...$$`), который проверяет:
- нет дублей по бизнес-ключу в ODS;
- все ключи из STG текущего батча присутствуют в ODS;
- обязательные поля не содержат NULL.
При ошибке — `RAISE EXCEPTION` с понятным текстом. Для инкрементальных таблиц пустой батч допустим.
### 11) `finish_ods_summary`
Ждёт завершения обеих параллельных веток (`dq_ods_boarding_passes` и `dq_ods_seats`)
и логирует краткую сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` посмотрите строки готовых таблиц:
```sql
SELECT airport_code, airport_name, city, _load_id, _load_ts
FROM ods.airports
ORDER BY airport_code
LIMIT 10;
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM ods.tickets;
SELECT COUNT(*) FROM ods.flights;
SELECT COUNT(*) FROM ods.segments;
-- Проверка: в ODS не должно быть дублей по бизнес-ключу
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY 1
GROUP BY book_ref
HAVING COUNT(*) > 1;
```
Ожидаемо: в последнем запросе `0` строк.
После первого полного запуска готовые таблицы непусты; последний запрос
не должен вернуть строк. У `ods.airports` видны русские названия и выбранный
STG-батч. Сравните его с XCom задачи `resolve_stg_batch_id`.
## Типичные ошибки
Готовые DQ проверяют ключи, обязательные поля и связи. Для снимков
[airports_dq.sql](../sql/ods/airports_dq.sql) и
[routes_dq.sql](../sql/ods/routes_dq.sql) ищут и пропущенные, и лишние ключи
относительно выбранного батча. DQ транзакционных таблиц проверяют покрытие
ключей выбранного батча, хотя загрузка читает всю дельту по HWM.
Пустой транзакционный батч допустим, пустой снимок справочника считается ошибкой.
- `stg_batch_id не найден`:
- передайте `stg_batch_id` в `dag_run.conf`, или
- сначала загрузите STG через `bookings_to_gp_stage`.
- Ошибки `relation "ods...." does not exist`:
- не применён ODS DDL (`bookings_ods_ddl` / `make ddl-gp`).
- Ошибки DQ по ссылочной целостности:
- проверьте, что ODS DAG выполнялся с корректным `stg_batch_id` и без пропуска upstream задач.
После реализации `airplanes` и `seats` пройдите
[самопроверку заданий ODS](assignment/README.md#запуск-и-проверка).
Дальше можно переходить к [DDS](bookings_to_gp_dds.md).
## Если загрузка не прошла
- `stg_batch_id не найден`: проверьте, что STG загрузил все четыре справочника.
Одна заполненная `stg.bookings` для выбора снимка недостаточна.
- Нет `ods.*`: выполните `bookings_ods_ddl`.
- Ошибка приведения типа: в логе найдите поле, затем сравните значение
в STG с преобразованием в `*_load.sql` и типом в DDL.
- DQ сообщает о пропущенных ключах: сопоставьте выбранный батч, SQL загрузки
и конкретную связь на карте. Для учебных таблиц проверьте, что заменены
обе заглушки: загрузка и DQ.
+81 -127
View File
@@ -1,155 +1,109 @@
# DAG `bookings_to_gp_stage`: `bookings-db` → `stg` в Greenplum
# Загрузка Bookings → STG: `bookings_to_gp_stage`
Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres**
(`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой
и простыми проверками качества данных.
DAG читает девять таблиц Bookings через внешние таблицы PXF и сохраняет
строки в Greenplum. Весь STG, включая DQ, уже реализован на `main`;
в [первой лабораторной](assignment/pxf_airports.md) вы разберете и восстановите
прием `airports`.
Смысл бронирования, билета и сегмента разобран в
[руководстве о структуре](design/db_schema.md); на карте начните со
[stg.bookings](design/architecture-map.html#node=stg.bookings).
Карту открывайте [локально в браузере](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- В источнике (`bookings-db`) генерирует следующий учебный день данных в `bookings.bookings`
(генератор всегда “шагает” вперёд от `max(book_date)`).
- В Greenplum загружает инкремент в `stg.bookings` через внешнюю таблицу `stg.bookings_ext`, используя PXF.
- Сверяет количество строк между источником (за окно инкремента) и загруженным батчем.
- Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF.
- Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля).
- Загружает справочники (full load): `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + DQ.
- Загружает транзакции: `stg.flights` (инкремент), `stg.segments` (инкремент), `stg.boarding_passes` (инкремент через tickets/bookings) + DQ.
Выполните подготовку из [быстрого старта](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
стенд должен работать, база `demo` содержать исходные данные,
а DAG `bookings_stg_ddl` создать внутренние таблицы `stg.*` и внешние `stg.*_ext`.
Альтернатива DDL DAG: `make ddl-gp`, который создает все четыре слоя.
## Что должно быть готово перед запуском
Подключения `bookings_db` и `greenplum_conn` заданы через `AIRFLOW_CONN_...`
в Compose и могут не отображаться в списке Connections.
Порты, учетные данные и настройка PXF описаны в [справочнике стенда](stack.md).
1) Стек поднят:
В Airflow UI (по умолчанию http://localhost:8080) запустите
`bookings_to_gp_stage` через Trigger DAG и дождитесь завершения всех задач.
Каждый полный запуск добавляет следующий учебный день в источник.
Дата запуска Airflow не задает день генерируемых данных.
```bash
make up
```
## Какие строки загружаются
2) Демо‑БД bookings установлена и содержит данные:
[generate_bookings_day](../sql/src/bookings_generate_day_if_missing.sql)
вызывает `continue(...)` до конца следующего дня после `MAX(book_date)`.
Для пустой установленной базы он вызывает `generate(...)`, используя настройки
`bookings.start_date`, `bookings.init_days` и `bookings.jobs`.
Эти настройки задаются при инициализации Bookings.
```bash
make bookings-init
```
Внешняя таблица `*_ext` читает источник; внутренний STG хранит полученные строки.
Бизнес-поля сохраняются как TEXT. `_load_id` равен `run_id` запуска STG,
`_load_ts` фиксирует время записи. Опорное `event_ts` зависит от таблицы:
3) В Greenplum созданы STG‑объекты (внешние `*_ext` через PXF и внутренние таблицы слоя `stg`)
для всех таблиц потока: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`,
`segments`, `boarding_passes` (выберите один вариант):
| Таблицы | Отбор строк | Готовый SQL |
|---|---|---|
| `bookings` | Новые `book_date` | [bookings_load.sql](../sql/stg/bookings_load.sql) |
| `tickets`, `segments`, `boarding_passes` | По `book_date` связанного бронирования через билеты | [tickets](../sql/stg/tickets_load.sql), [segments](../sql/stg/segments_load.sql), [boarding_passes](../sql/stg/boarding_passes_load.sql) |
| `flights` | Новые `scheduled_departure` | [flights_load.sql](../sql/stg/flights_load.sql) |
| `airports`, `airplanes`, `routes`, `seats` | Полный снимок при каждом запуске; `event_ts = now()` | [airports](../sql/stg/airports_load.sql), [airplanes](../sql/stg/airplanes_load.sql), [routes](../sql/stg/routes_load.sql), [seats](../sql/stg/seats_load.sql) |
- учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI;
- технический шорткат: `make ddl-gp`.
У каждой инкрементальной таблицы своя нижняя граница:
`MAX(event_ts)` по предыдущим батчам этой таблицы. Верхней границы нет.
`NOT EXISTS` не дает повторно вставить тот же ключ в текущий `_load_id`.
У справочников сохраняются снимки разных запусков, поэтому повторение
бизнес-ключа между батчами ожидаемо.
4) Airflow Connections:
Такой инкремент не перечитывает все старые строки источника: например,
изменение рейса с датой ниже границы само по себе в новый STG-батч не попадет.
Для проверки повторяемости загрузки учитывайте и генератор:
повторный запуск `generate_bookings_day` снова продвинет источник на день.
- `bookings_db` — подключение к источнику `bookings-db`;
- `greenplum_conn` — подключение к Greenplum.
## Порядок задач
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в `docker-compose.yml`,
поэтому могут не отображаться в UI — для `PostgresOperator` это нормально.
В [DAG](../airflow/dags/bookings_to_gp_stage.py) после каждой загрузки стоит DQ.
Ниже стрелки показывают порядок выполнения задач Airflow.
Связи строк по ключам смотрите на карте.
## Как запустить
1. `generate_bookings_day → load_bookings_to_stg → check_row_counts`.
2. `load_tickets_to_stg → check_tickets_dq`.
3. После билетов параллельно могут выполняться загрузки аэропортов и моделей.
`load_routes_to_stg` ждет `check_airports_dq` и `check_airplanes_dq`;
`load_seats_to_stg` ждет только `check_airplanes_dq`.
4. После проверки маршрутов идет цепочка
`flights → segments → boarding_passes`, у каждой таблицы своя DQ-задача.
5. `finish_summary` ждет `check_boarding_passes_dq` и `check_seats_dq`.
1) Откройте Airflow UI: http://localhost:8080 (admin/admin).
2) Запустите DAG `bookings_to_gp_stage` вручную (Trigger DAG).
3) Дождитесь статуса Success у всех задач.
Инкрементальные DQ допускают пустое окно: выводят `NOTICE` и завершаются успешно.
Пустой источник справочника считается ошибкой. Сверки выполняются для текущего
батча; примеры: [bookings_dq.sql](../sql/stg/bookings_dq.sql) и
[airports_dq.sql](../sql/stg/airports_dq.sql).
## Как это работает внутри (по шагам)
## Проверка результата
1) `generate_bookings_day`
- выполняет `sql/src/bookings_generate_day_if_missing.sql` в `bookings-db`;
- если `bookings.bookings` пустая — вызывает `generate(...)` на `BOOKINGS_INIT_DAYS`;
- иначе — вызывает `continue(...)`, добавляя ровно один следующий день.
2) `load_bookings_to_stg`
- выполняет `sql/stg/bookings_load.sql` в Greenplum;
- берёт строки из `stg.bookings_ext`, которые попадают в новое окно инкремента;
- вставляет их в `stg.bookings`, добавляя тех.колонки:
- `event_ts` (опорная метка времени для инкремента),
- `_load_ts`,
- `_load_id={{ run_id }}`.
3) `check_row_counts`
- выполняет `sql/stg/bookings_dq.sql` в Greenplum;
- считает количество строк в источнике за то же окно инкремента и сравнивает с количеством строк,
вставленных в `stg.bookings` для текущего `_load_id`;
- при расхождении делает `RAISE EXCEPTION` с понятным текстом.
4) `load_tickets_to_stg`
- выполняет `sql/stg/tickets_load.sql` в Greenplum;
- так как в `bookings.tickets` нет явной временной колонки, окно инкремента берётся по `book_date`
из связанной внешней таблицы `stg.bookings_ext` (JOIN по `book_ref`);
- вставляет строки в `stg.tickets`, добавляя `event_ts`, `_load_ts` и `_load_id={{ run_id }}`.
5) `check_tickets_dq`
- выполняет `sql/stg/tickets_dq.sql` в Greenplum;
- проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля;
- при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”.
6) Справочники (full load, параллельно где возможно)
Справочники загружаются “снэпшотом” (все строки) и затем проверяются DQ-скриптом.
Порядок определяется зависимостями данных — **airports** и **airplanes** грузятся **параллельно**,
потому что не зависят друг от друга:
```
check_tickets_dq
├─ load_airports → check_airports_dq ─┐
│ ├─ load_routes → check_routes_dq
└─ load_airplanes → check_airplanes_dq ─┤
└─ load_seats → check_seats_dq
```
- `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`)
- `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`)
- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) — зависит от **airports** и **airplanes** (DQ проверяет ссылочную целостность)
- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) — зависит от **airplanes** (DQ проверяет `airplane_code → airplanes`)
7) Транзакции
- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) — зависит от **routes**
- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **flights**
- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **segments**
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_summary`.
Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно.
Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`).
8) `finish_summary`
- ждёт завершения **обеих** параллельных веток (`check_boarding_passes_dq` и `check_seats_dq`);
- логирует краткую сводку в конце запуска.
## Как проверить результат
```bash
make gp-psql
```
Примеры запросов:
Откройте Greenplum командой `make gp-psql`:
```sql
SELECT COUNT(*) FROM stg.bookings;
SELECT
event_ts,
_load_ts,
_load_id
SELECT book_ref, book_date, event_ts, _load_ts, _load_id
FROM stg.bookings
ORDER BY event_ts DESC
ORDER BY _load_ts DESC, book_ref
LIMIT 10;
```
## Типичные ошибки
После первого успешного запуска `stg.bookings` непуста. Сопоставьте `_load_id`
с Run ID в Airflow и объясните, чем `book_date` отличается от `_load_ts`.
Для просмотра первоисточника используйте `make bookings-psql`.
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
Для технических деталей PXF см. ветку `solution` (`docs/reference/pxf_bookings.md`).
Далее запускайте [ODS](bookings_to_gp_ods.md).
Общий порядок слоев находится в [порядке запуска DAG](dag_execution_order.md).
## Рекомендации по качеству решения
## Если загрузка не прошла
Ревью решения и список улучшений — в ветке `solution` (`docs/archive/`).
Откройте Log первой упавшей задачи в Airflow. Если упала DQ, начните с ключа
и батча, названных в сообщении, затем откройте ее SQL из карточки таблицы.
- Нет базы `demo`: пройдите инициализацию из README. `make bookings-init`
пересоздает исходную базу из seed-дампа, поэтому не используйте его для
обычного повторного запуска уже заполненного стенда.
- Нет `stg.*` или `stg.*_ext`: выполните `bookings_stg_ddl`.
- Ошибка PXF или соединения: проверьте `docker compose ps`, логи `greenplum`
и `bookings-db`, затем настройки из [справочника стенда](stack.md).
Повторное применение DDL само по себе не исправляет недоступное подключение.
+53 -26
View File
@@ -1,35 +1,62 @@
# Порядок запуска DAG (Cross-DAG Dependencies)
# Порядок запуска DAG
В этом стенде пайплайны разделены на несколько DAG-ов по слоям DWH (STG, ODS, DDS, DM).
Они настроены с `schedule=None`, так как это учебный проект.
Все загрузочные DAG в стенде запускаются вручную (`schedule=None`).
Завершение одного слоя не запускает следующий автоматически.
Подготовка Docker и источника находится в [быстром старте](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
Чтобы данные корректно прошли от источника до витрин, запускать DAG-и нужно в определённом порядке.
## Создание таблиц
## 1. DDL-скрипты (выполняются один раз)
Перед первой загрузкой выполните по порядку:
Для создания структуры таблиц в аналитических слоях:
1. Запустите `bookings_stg_ddl` — создаст STG-таблицы и внешние `*_ext` через PXF.
2. Запустите `bookings_ods_ddl` — создаст таблицы ODS (типизированные, SCD1).
3. Запустите `bookings_dds_ddl` — создаст таблицы для измерений и фактов в слое DDS.
4. Запустите `bookings_dm_ddl` — создаст таблицы витрин в слое DM.
1. `bookings_stg_ddl` создает STG и внешние таблицы `*_ext` для чтения через PXF.
2. `bookings_ods_ddl` создает типизированные таблицы ODS.
3. `bookings_dds_ddl` создает измерения и факт DDS.
4. `bookings_dm_ddl` создает витрины DM.
*(Технический шорткат: `make ddl-gp` применяет DDL для всех 4 слоёв сразу).*
Альтернатива четырем запускам: `make ddl-gp`.
DDL создает структуру; для наполнения нужны отдельные загрузки.
В заданиях таблицы и задачи уже подготовлены, поэтому перед каждым
повторным запуском загрузки применять DDL не требуется.
## 2. Ежедневная загрузка (ETL)
## Проход данных по слоям
Для прогрузки новой порции данных (или полного перерасчёта) соблюдайте следующую цепочку:
Запускайте следующий DAG после успешного завершения всех задач предыдущего.
Эта последовательность показывает зависимости загрузок; связи строк и зерно
объектов смотрите на [карте](design/architecture-map.html), открытой
[локально](design/db_schema.md#как-открыть-карту).
1. **`bookings_to_gp_stage`**
- Извлекает новые данные из демо-БД PostgreSQL и сохраняет их в `stg`-схему в Greenplum.
- Генерирует `stg_batch_id` для текущей загрузки.
2. **`bookings_to_gp_ods`**
- Берёт последний согласованный `stg_batch_id` из STG-слоя.
- Выполняет нормализацию и SCD1-UPSERT в слой ODS.
3. **`bookings_to_gp_dds`**
- Читает очищенные данные из ODS.
- Обновляет измерения (SCD1, SCD2) и инкрементально догружает новые рейсы в таблицу фактов `dds.fact_flight_sales`.
4. **`bookings_to_gp_dm`**
- Читает новые факты из DDS.
- Обновляет агрегированные витрины (использует HWM-инкрементальность по `_load_ts` или полный перерасчёт).
| Порядок | DAG и руководство | Что меняется в данных |
|---|---|---|
| 1 | [bookings_to_gp_stage](bookings_to_gp_stage.md) | Генератор добавляет учебный день в Bookings; STG получает инкременты транзакций и полные снимки справочников с `_load_id = run_id` STG |
| 2 | [bookings_to_gp_ods](bookings_to_gp_ods.md) | Resolver выбирает общий STG-батч справочников; их снимки заменяются целиком. Транзакционные загрузки читают дельту STG по `_load_ts` и обновляют строки по бизнес-ключу |
| 3 | [bookings_to_gp_dds](bookings_to_gp_dds.md) | Сначала выполняются загрузки измерений, затем факт: вставляются новые пары `(ticket_no, flight_id)`, у существующих обновляются цена, место и признак посадки |
| 4 | [bookings_to_gp_dm](bookings_to_gp_dm.md) | `sales_report` пересчитывает полные агрегаты дат, затронутых изменениями факта; поведение остальных витрин задано в ТЗ |
> **💡 Архитектурная заметка:** В реальном production-окружении (Airflow) эти связи между DAG-ами обычно настраиваются автоматически через `TriggerDagRunOperator`, `ExternalTaskSensor` или механизмы Data-Aware Scheduling (Datasets/Data Assets). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки.
На исходной `main` готовы весь STG, семь таблиц ODS, календарь, аэропорты,
тарифы, факт и `sales_report`. Загрузки и DQ остальных объектов являются
[заданиями](assignment/README.md). Успех этих задач с `SELECT 1;` оставляет
таблицы пустыми. У факта до реализации измерений могут быть `NULL` в
`passenger_sk`, `route_sk` и `airplane_sk`.
## Повторный запуск и проверка задания
Чтобы получить следующий учебный день, повторите цепочку STG → ODS → DDS → DM.
Когда проверяете правку SQL своего слоя на уже загруженных источниках,
начните с этого слоя; новый STG запустит генератор еще раз.
Подробности выбора батча и повторного запуска находятся в руководстве слоя.
После реализации измерений обычный повтор DDS не заполнит старые SK факта.
Выполните [пересчет по ТЗ](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений),
затем обновите DM.
Для самопроверки задания откройте
[«Запуск и проверка»](assignment/README.md#запуск-и-проверка).
`bookings_validate` запускайте отдельно от загрузок: дождитесь всего DAG
и успешного `validate_dds.scd2_restore` перед следующей записью в хранилище.
Тест SCD2 временно меняет данные и восстанавливает их из копии.
В [лабораторной PXF](assignment/pxf_airports.md) снимки справочников готовит
`lab_pxf_airports`: он выполняет только четыре справочника STG и их DQ.
Для передачи снимка и восстановления нижних слоев используйте обычные
DAG ODS → DDS → DM. Следующий учебный день по-прежнему загружается полной
цепочкой, начиная с `bookings_to_gp_stage`.
+32
View File
@@ -1222,6 +1222,10 @@
{
"label": "DQ",
"url": "../../sql/stg/airports_dq.sql"
},
{
"label": "Лабораторная: прием через PXF",
"url": "../assignment/pxf_airports.md"
}
],
"parts": [
@@ -1268,6 +1272,10 @@
{
"label": "DQ",
"url": "../../sql/ods/airports_dq.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#ods-airports"
}
],
"parts": []
@@ -1836,6 +1844,10 @@
{
"label": "Пересчет после реализации измерений",
"url": "../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-flight-sales"
}
],
"parts": []
@@ -2633,6 +2645,10 @@
{
"label": "SQL",
"url": "../../sql/ods/airports_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#ods-airports"
}
]
},
@@ -3356,6 +3372,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3397,6 +3417,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3469,6 +3493,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3505,6 +3533,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
+32
View File
@@ -1022,6 +1022,10 @@
{
"label": "DQ",
"url": "../../sql/stg/airports_dq.sql"
},
{
"label": "Лабораторная: прием через PXF",
"url": "../assignment/pxf_airports.md"
}
],
"parts": [
@@ -1068,6 +1072,10 @@
{
"label": "DQ",
"url": "../../sql/ods/airports_dq.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#ods-airports"
}
],
"parts": []
@@ -1636,6 +1644,10 @@
{
"label": "Пересчет после реализации измерений",
"url": "../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-flight-sales"
}
],
"parts": []
@@ -2433,6 +2445,10 @@
{
"label": "SQL",
"url": "../../sql/ods/airports_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#ods-airports"
}
]
},
@@ -3156,6 +3172,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3197,6 +3217,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3269,6 +3293,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
@@ -3305,6 +3333,10 @@
{
"label": "SQL",
"url": "../../sql/dds/fact_flight_sales_load.sql"
},
{
"label": "Разбор SQL загрузки",
"url": "reading_the_pipeline.md#fact-joins"
}
]
},
+17 -1
View File
@@ -1,4 +1,20 @@
# DDS Layer: план реализации Star Schema для bookings
# История проектирования DDS для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки DDS](../bookings_to_gp_dds.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=dds.fact_flight_sales);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "DDS Layer: план реализации Star Schema для bookings".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Контекст
+17 -1
View File
@@ -1,4 +1,20 @@
# План: DM-слой (Data Mart) для учебного стенда Bookings
# История проектирования DM для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки DM](../bookings_to_gp_dm.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=dm.sales_report);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "План: DM-слой (Data Mart) для учебного стенда Bookings".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Context
+17 -1
View File
@@ -1,4 +1,20 @@
# ODS Layer: эталонный учебный план реализации (v3)
# История проектирования ODS для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки ODS](../bookings_to_gp_ods.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=ods.airports);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "ODS Layer: эталонный учебный план реализации (v3)".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Контекст
+17 -1
View File
@@ -1,4 +1,20 @@
# Дизайн STG для bookings в Greenplum
# История проектирования STG для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки STG](../bookings_to_gp_stage.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=stg.bookings);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "Дизайн STG для bookings в Greenplum".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## 1. Цель и общий контур
+10 -10
View File
@@ -171,7 +171,7 @@ SQL использует `LEFT JOIN`, поэтому строка с таким
эталон работает до выполнения заданий. Остальные витрины видны рядом
пунктирными рамками: в их карточках описан требуемый результат по ТЗ.
## Модели самолетов и места: начало задания
## Модели самолетов и места: расширение цепочки
Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes).
У него два источника: `ods.airplanes` дает характеристики модели,
@@ -203,15 +203,15 @@ SQL использует `LEFT JOIN`, поэтому строка с таким
## Куда идти дальше
Для первого задания разберите готовую загрузку справочника:
[как читать SQL загрузки](reading_the_pipeline.md), затем откройте
[учебные задания](../assignment/README.md). Для точечного вопроса
Начните с [приема справочника через PXF](../assignment/pxf_airports.md),
затем переходите к [заданиям ODS](../assignment/README.md#первая-загрузка-ods).
Для точечного вопроса
о таблице возвращайтесь на карту: там находится каталог объектов,
их источников, потребителей, SQL и DQ.
Для проверки результата пригодится [справочник DQ](../reference/dq_taxonomy.md).
Технические материалы по слоям остаются доступны:
[STG](bookings_stg_design.md), [ODS](bookings_ods_design.md),
[DDS](bookings_dds_design.md), [DM](bookings_dm_design.md).
В них есть планы разработки; текущее поведение проверяйте по SQL,
а требования к самостоятельной работе - по ТЗ.
Проверки каждого этапа находятся в [учебном маршруте](../assignment/README.md).
Для разбора ошибки загрузки откройте руководство нужного слоя:
[STG](../bookings_to_gp_stage.md), [ODS](../bookings_to_gp_ods.md),
[DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md).
[Индекс документации](../README.md) ведет к справочникам, проверкам разработки
и истории проектирования.
+30 -15
View File
@@ -6,25 +6,24 @@
## Зачем этот документ
Чтобы имена полей не «плыли» между слоями, DAG и SQL-скриптами:
- студенты видят один и тот же словарь во всех задачах;
- новые реализации (ODS/DDS/DM) не расходятся с тем, как уже учили на `dwh-modeling`;
- ревью становится проще: сразу видно, где отклонение от стандарта.
Этот справочник задает имена служебных полей. Состав колонок конкретной
таблицы проверяйте по ее DDL, а требования к учебной загрузке - по
[ТЗ](../assignment/analyst_spec.md).
## 1. Базовые правила
- Имена колонок: `snake_case`, на английском.
- Бизнес-ключи источника не переименовываем без необходимости (`book_ref`, `ticket_no`, `route_no`).
- Булевы поля начинаются с `is_` (`is_outbound`, `is_boarded`).
- Денежные/количественные поля называем явно (`total_amount`, `segment_amount`, `range_km`).
- Денежные и количественные поля называем по смыслу (`total_amount`, `amount`, `range_km`).
## 2. Каноничные служебные поля
| Поле | Тип (рекомендация) | Смысл | Где применять |
|---|---|---|---|
| `_load_id` | `TEXT NOT NULL` | Идентификатор загрузки/батча | STG/ODS (новые объекты), при необходимости DDS |
| `_load_ts` | `TIMESTAMP NOT NULL` | Когда запись попала в слой | STG/ODS (новые объекты) |
| `event_ts` | `TIMESTAMP` | Когда событие произошло в источнике (effective time) | ODS/DDS при событийной природе данных |
| `_load_id` | `TEXT NOT NULL` | Идентификатор запуска или исходного STG-батча; зависит от слоя | STG, ODS, DDS и DM, кроме календаря |
| `_load_ts` | `TIMESTAMP NOT NULL` | Метка загрузки; в транзакционных ODS сохраняется время STG | STG, ODS, DDS и DM, кроме календаря |
| `event_ts` | `TIMESTAMP` | Опорное время источника или получения снимка | Все таблицы STG и транзакционные ODS |
| `created_at` | `TIMESTAMP NOT NULL` | Когда строка создана в таблице слоя | DDS/DM, где есть lifecycle строки |
| `updated_at` | `TIMESTAMP NOT NULL` | Когда строка обновлена в таблице слоя | DDS/DM, где есть UPDATE |
| `valid_from` | `DATE NOT NULL` (базовый трек) | Начало действия версии SCD2 | DDS SCD2 |
@@ -35,26 +34,42 @@
- Бизнес-ключ измерения: суффикс `_bk` (`customer_bk`, `airport_bk`).
- Суррогатный ключ измерения: суффикс `_sk` (`customer_sk`, `airport_sk`).
- В существующих таблицах есть бизнес-ключи без `_bk`: `passenger_id`
у пассажиров, `fare_conditions` у тарифов, `date_actual` у календаря.
Используйте имена из DDL и ТЗ; переименовывать их в задании не нужно.
## 4. Правило времени (важно для обучения)
- `event_ts` (effective time) и `_load_ts` (load time) — разные сущности, не смешиваем.
- Если `event_ts` отсутствует в источнике, используем `_load_ts` как fallback и явно документируем это в SQL/доке.
- Для snapshot-справочников (airports, airplanes, routes, seats) в STG нет бизнес-события с точным временем: `event_ts` заполняется через `now()` при загрузке. Это намеренно и задокументировано в каждом load-скрипте.
- В STG `event_ts` задает опору инкремента: `book_date` у бронирований,
билетов, сегментов и посадочных талонов; `scheduled_departure` у рейсов.
Например, у талона это дата бронирования, а не `boarding_time`.
- У снимков `airports`, `airplanes`, `routes`, `seats` в STG
`event_ts = now()`: точного времени изменения записи в источнике здесь нет.
- `_load_ts` относится к загрузке. Какой именно слой ее поставил, указано ниже.
Правила отбора строк описаны в руководствах [STG](../bookings_to_gp_stage.md)
и [ODS](../bookings_to_gp_ods.md).
## 5. Применение по слоям
### STG
- Используем канон `_load_id`, `_load_ts`, `event_ts` для всех таблиц.
- Все таблицы содержат `_load_id = run_id` запуска STG, `_load_ts = now()`
и `event_ts` по правилам выше.
### ODS
- Используем канон `_load_id`, `_load_ts`, `event_ts`.
- Базовый эталон ODS в этом стенде: SCD Type 1 (current state + UPSERT).
- Снимки `airports`, `airplanes`, `routes`, `seats`: `_load_id` выбранного
STG-батча, `_load_ts = now()`, без `event_ts`. Загрузка заменяет снимок целиком.
- Транзакционные таблицы: `_load_id`, `_load_ts` и `event_ts` переносятся из STG.
Сохраненная `_load_ts` служит границей инкремента для `UPDATE + INSERT`.
### DDS
### DDS и DM
- `_load_id` измененной или вставленной строки относится к текущему запуску
своего слоя, `_load_ts` фиксирует время записи.
- Календарь статический: у `dim_calendar` нет меток загрузки.
- `created_at` и `updated_at` используем там, где они предусмотрены DDL.
Например, у факта и полностью пересчитываемой `route_performance` их нет.
- Для SCD2 используем:
- `valid_from`, `valid_to`, `hashdiff`, `created_at`, `updated_at`.
- Интервалы считаем как `[valid_from, valid_to)`, current-версия: `valid_to IS NULL`.
+117 -51
View File
@@ -1,66 +1,132 @@
# Как читать пайплайн по скрипту загрузки
# От карты к SQL загрузки
Большую схему не нужно запоминать. Связи любой таблицы уже записаны в блоке
`FROM ... JOIN ...` её файла `*_load.sql`. Обычно каждая прочитанная таблица
даёт одно входящее ребро. Если одна таблица читается под разными псевдонимами,
как `dim_airports`, её можно показать одним узлом.
На [карте](architecture-map.html) найдите таблицу, которую хотите заполнить,
и откройте ее SQL загрузки. Проследите, какие строки скрипт читает,
что меняет и по какому ключу записывает результат.
Смысл строк и связей разобран в [руководстве о структуре DWH](db_schema.md).
## Метод
Для заданий на снимки ODS достаточно примера `ods.airports` ниже.
К сборке факта вернитесь перед заданиями DDS.
Адресные ссылки на карту работают в локальном просмотре;
если читаете в Gitea, используйте [инструкцию открытия карты](db_schema.md#как-открыть-карту).
`INNER JOIN` показывает обязательную часть основного потока. Если входной строки
нет, целевая строка не появится. `LEFT JOIN` к измерениям решает две задачи.
Факт может прийти раньше измерения: это опаздывающее измерение
(late-arriving dimension). Эталонный факт также не должен зависеть от того,
успел ли студент реализовать свои измерения. Поэтому строка факта сохраняется,
а её суррогатный ключ пока может быть `NULL`.
<a id="ods-airports"></a>
В этом CTE есть и другие `LEFT JOIN`. `ods.routes` даёт эталонный путь к
аэропортам, а `ods.boarding_passes` содержит необязательные `seat_no` и
признак посадки.
## Справочник: stg.airports → ods.airports
Если в загрузчике несколько SQL-операторов, смотрите на источник `INSERT`.
Именно он задаёт полный список входящих связей для новых строк. Например, в
`fact_flight_sales_load.sql` блок `FROM` у `UPDATE` читает только изменяемые
поля, а полный список находится в CTE `fact_src` перед `INSERT`.
Откройте [ods.airports на карте](architecture-map.html#node=ods.airports)
и связь [Типизация и загрузка ODS](architecture-map.html#edge=flow-stg.airports-ods.airports-13).
Здесь один источник: `stg.airports`. В STG накапливаются снимки разных запусков,
а в ODS нужна одна строка на аэропорт из выбранного снимка.
## Пример: `fact_flight_sales`
Рядом откройте [airports_load.sql](../../sql/ods/airports_load.sql).
Пройдите скрипт по порядку:
Откройте [`sql/dds/fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
и пройдите `fact_src` сверху вниз:
1. `TRUNCATE TABLE ods.airports` очищает предыдущий снимок.
Следующий `INSERT` заменяет его целиком, поэтому исчезнувший из нового
снимка аэропорт исчезнет и из ODS.
2. В CTE `src` найдите `FROM stg.airports` и фильтр `_load_id`:
1. `FROM ods.segments` задаёт зерно `(ticket_no, flight_id)` и поле `price`.
2. `JOIN ods.tickets` обязателен для `book_ref` и `passenger_id`.
3. `JOIN ods.bookings` обязателен для `book_date`.
4. `JOIN ods.flights` обязателен для расписания и `route_no`.
5. `LEFT JOIN ods.routes` находит аэропорты по `route_no`. Этот эталонный путь
не зависит от студенческого `dim_routes`.
6. `LEFT JOIN dds.dim_routes` ищет point-in-time версию маршрута и `route_sk`.
7. `LEFT JOIN dds.dim_calendar` даёт `calendar_sk` по дате вылета.
8. Два `LEFT JOIN dds.dim_airports` дают ключи аэропортов вылета и прилёта.
На схеме их можно показать одним узлом `dim_airports`.
9. `LEFT JOIN dds.dim_airplanes` даёт `airplane_sk` через найденную версию маршрута.
10. `LEFT JOIN dds.dim_tariffs` даёт `tariff_sk`.
11. `LEFT JOIN dds.dim_passengers` даёт `passenger_sk`.
12. `LEFT JOIN ods.boarding_passes` даёт `seat_no` и признак `is_boarded`.
```sql
WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
```
Этот путь разобран в разделе [«Сборка факта продаж»](db_schema.md#сборка-факта-продаж).
На карте сплошные стрелки показывают готовые загрузки, пунктирные — ожидаемые
по ТЗ. Тип соединения и поведение при отсутствии строки описаны в карточке связи.
Задача `resolve_stg_batch_id` в [ODS DAG](../../airflow/dags/bookings_to_gp_ods.py)
выбирает общий батч четырех справочников STG или берет `stg_batch_id`
из параметров запуска. Этот фильтр оставляет один снимок из истории STG.
3. Выражения `airport_name::json->>'ru'`, `city::json->>'ru'`
и `country::json->>'ru'` извлекают русские названия. Остальные бизнес-поля
переносятся без преобразования.
4. `ROW_NUMBER()` нумерует строки внутри `airport_code` по `_load_ts DESC`,
затем `event_ts DESC NULLS LAST`. Финальный `WHERE s.rn = 1`
оставляет одну строку на код.
5. Сопоставьте колонки `INSERT INTO ods.airports (...)` с финальным `SELECT`.
В `_load_id` сохраняется идентификатор выбранного STG-батча,
в `_load_ts` записывается время загрузки ODS. Поля и типы результата
заданы в [airports_ddl.sql](../../sql/ods/airports_ddl.sql).
6. `ANALYZE` обновляет статистику таблицы для планировщика запросов.
Проверки находятся отдельно: [airports_dq.sql](../../sql/ods/airports_dq.sql)
сравнивает множества ключей STG-батча и ODS, ищет дубли и пустые обязательные поля.
Повторная загрузка того же снимка должна сохранить бизнес-строки без дублей;
время `_load_ts` при этом обновится.
Теперь переходите к [первой самостоятельной загрузке](../assignment/README.md#первая-загрузка-ods).
<a id="пример-fact_flight_sales"></a>
<a id="fact-flight-sales"></a>
## Факт: сегмент билета и его измерения
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales)
и [fact_flight_sales_load.sql](../../sql/dds/fact_flight_sales_load.sql).
Одна строка факта соответствует паре `(ticket_no, flight_id)`.
В [примере поездки](db_schema.md#сборка-факта-продаж) два билета с тремя
сегментами дают три строки факта.
<a id="метод"></a>
<a id="fact-joins"></a>
### Найдите источник новых строк
В файле сначала идет `UPDATE`, а затем CTE `fact_src` и `INSERT`.
Начните с `fact_src`: здесь виден полный набор источников новых строк.
`FROM ods.segments AS seg` задает зерно, а `seg.amount AS price`
переносит цену сегмента в факт.
| Участок `fact_src` | Что добавляется к сегменту | Связь на карте |
|---|---|---|
| `JOIN ods.tickets`, затем `JOIN ods.bookings` | Бронирование, пассажир и дата покупки | [Билет и пассажир](architecture-map.html#edge=fact-1), [дата бронирования](architecture-map.html#edge=fact-2) |
| `JOIN ods.flights` | Дата вылета и номер маршрута | [Рейс, дата и маршрут](architecture-map.html#edge=fact-3) |
| `LEFT JOIN` подзапроса из `ods.routes` | Коды аэропортов из строки с наибольшим `validity` на `route_no` | [Коды двух аэропортов](architecture-map.html#edge=fact-4) |
| Два `LEFT JOIN dds.dim_airports` как `dep` и `arr` | `departure_airport_sk` и `arrival_airport_sk` по двум кодам | [Вылет](architecture-map.html#edge=fact-7), [прилет](architecture-map.html#edge=fact-8) |
| `LEFT JOIN dds.dim_routes`, затем `dds.dim_airplanes` | Версия маршрута на дату рейса и SK ее модели самолета | [Версия маршрута](architecture-map.html#edge=fact-5), [модель](architecture-map.html#edge=fact-9) |
| `LEFT JOIN dds.dim_calendar`, `dds.dim_tariffs`, `dds.dim_passengers` | SK по дате вылета, классу обслуживания и `passenger_id` | [Дата](architecture-map.html#edge=fact-6), [тариф](architecture-map.html#edge=fact-10), [пассажир](architecture-map.html#edge=fact-11) |
| `LEFT JOIN ods.boarding_passes` по обоим полям ключа | `seat_no`; наличие талона задает `is_boarded` | [Посадка и место](architecture-map.html#edge=fact-12) |
Если билет, бронирование или рейс не найдены, `INNER JOIN` исключит сегмент
из вставки. `LEFT JOIN` сохранит его при отсутствии совпадения: например,
без талона получатся `seat_no = NULL` и `is_boarded = false`.
Обратите внимание на два разных пути от маршрута:
- Аэропорты берутся через готовый `ods.routes`. Отбор `ORDER BY validity DESC`
работает по полю TEXT и не использует дату рейса.
- `route_sk` ищется в студенческом `dim_routes` по дате вылета
в интервале `[valid_from, valid_to)`. Из этой версии берется `airplane_code`
для поиска `airplane_sk`.
До выполнения заданий `route_sk`, `airplane_sk` и `passenger_sk` могут быть
`NULL`. Путь аэропортов от студенческих измерений не зависит.
Объяснение этой части модели: [аэропорты и версия маршрута](db_schema.md#аэропорты-вылета-и-прилета).
### Найдите запись результата
Финальный `INSERT` берет поля из `fact_src`. Условие `NOT EXISTS`
проверяет пару `(ticket_no, flight_id)` и пропускает уже записанные строки.
Вернитесь к `UPDATE` в начале файла: он меняет только цену, место,
признак посадки и метки загрузки у существующих строк.
Ключи измерений он не пересчитывает.
Поэтому после реализации измерений нужен
[пересчет факта по ТЗ](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверки результата находятся в [fact_flight_sales_dq.sql](../../sql/dds/fact_flight_sales_dq.sql).
## Как проверить свой слой
- Выпишите все таблицы из `FROM` и `JOIN` своего `*_load.sql`.
- Сравните список с эталонной схемой или дизайн-документом этого слоя.
- Проверьте служебные и SCD-поля по [`naming_conventions.md`](naming_conventions.md).
- Убедитесь, что рядом есть `*_dq.sql` и он покрывает нужные
[классы DQ-проверок](../reference/dq_taxonomy.md).
Перед записью SQL найдите свой объект на карте и откройте его пункт ТЗ.
Сформулируйте, что означает одна строка результата, по каким полям соединяются
источники и что произойдет при отсутствии совпадения. Затем сверяйте
`INSERT` с DDL, а преобразования и `*_dq.sql` с требованиями задания.
## Шаблон схемы одной таблицы
Близкие готовые примеры для следующих этапов:
Скопируйте шаблон и замените имена таблиц и полей:
- SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) обновляет
изменившиеся атрибуты через `IS DISTINCT FROM` и вставляет новые бизнес-ключи.
- DM: [sales_report_load.sql](../../sql/dm/sales_report_load.sql) находит
затронутые даты по `_load_ts` факта, пересчитывает за них полные агрегаты
во временную таблицу и выполняет `UPDATE` и `INSERT`.
```mermaid
graph LR
MAIN["schema.main_input"] -->|"grain, metric"| TARGET["schema.target"]
LOOKUP["schema.lookup"] -.->|"lookup_key"| TARGET
```
[Порядок заданий](../assignment/README.md#следующие-этапы) ведет к требованиям
каждого этапа. Имена служебных полей проверяйте по
[соглашениям](naming_conventions.md), состав DQ - по
[справочнику проверок](../reference/dq_taxonomy.md).
+5
View File
@@ -2,6 +2,11 @@
DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
класса. Один `*_dq.sql` может содержать проверки нескольких классов.
Сверяйте их с полями и правилами своего объекта в [ТЗ](../assignment/analyst_spec.md).
Как запустить DQ и проверить задание, описано в
[инструкции самопроверки](../assignment/README.md#запуск-и-проверка).
На `main` учебные DQ содержат `SELECT 1;` и начнут проверять данные
после вашей реализации.
## 1. Универсальные структурные проверки
+15 -16
View File
@@ -1,20 +1,19 @@
# План отладки эталонного пайплайна
# История QA: план отладки полной цепочки
> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает
> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём
> готовить ветку main для студентов.
>
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
>
> **Ветка `main`:** на main часть загрузок — заглушки (`SELECT 1;`).
> Пустые таблицы на main: `ods.airplanes`, `ods.seats`,
> `dds.dim_airplanes`, `dds.dim_passengers`, `dds.dim_routes`,
> `dm.route_performance`, `dm.passenger_loyalty`, `dm.airport_traffic`, `dm.monthly_overview`.
> Ожидания ниже написаны для ветки `solution` (полная реализация).
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
> Классы проверок для разбора ошибок: [`dq_taxonomy.md`](dq_taxonomy.md).
Этот план сохраняет сценарии первоначальной проверки полной реализации:
повторную загрузку, несколько учебных дней и граничные случаи.
Он предназначен для подготовки углубленной диагностики на отдельном тестовом
стенде. Ожидания и SQL ниже нужно сверять с текущей веткой; учебная `main`
содержит заглушки и не обязана проходить проверки полной реализации.
Для своего задания используйте [самопроверку](../assignment/README.md#запуск-и-проверка).
Действующие команды разработки и границы автоматических проверок описаны
в [TESTING.md](../../TESTING.md), устройство DQ - в [справочнике](dq_taxonomy.md).
Сценарии ниже очищают DWH и изменяют данные. `make bookings-init`
пересоздает базу Bookings, а `make dwh-truncate` удаляет строки хранилища.
Выполняйте их только на стенде, где данные можно потерять.
Параметры доступа к Airflow берите из `.env`.
---
+158
View File
@@ -0,0 +1,158 @@
"""Независимая сверка лабораторной PXF: Postgres → сохраненный файл → STG/ODS."""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIELDS = ("airport_code", "airport_name", "city", "country", "coordinates", "timezone")
JSON_FIELDS = {"airport_name", "city", "country"}
def read_rows(service: str, query: str) -> list[dict]:
"""Читает через psql в контейнере; настройки берет из окружения Compose."""
if service == "bookings-db":
command = (
'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -X -qAt '
'-v ON_ERROR_STOP=1 -h 127.0.0.1 -U "$POSTGRES_USER" -d demo'
)
else:
command = (
'PGPASSWORD="$GREENPLUM_PASSWORD" exec '
"/usr/local/greenplum-db/bin/psql -X -qAt -v ON_ERROR_STOP=1 "
'-h 127.0.0.1 -U "$GREENPLUM_USER" -d "$GREENPLUM_DATABASE_NAME"'
)
result = subprocess.run(
["docker", "compose", "exec", "-T", service, "sh", "-c", command],
input=f"SELECT row_to_json(r) FROM ({query}) r;\n",
text=True,
capture_output=True,
cwd=ROOT,
check=True,
)
return [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
def compare_snapshot(
expected: list[dict], actual: list[dict], batch_id: str, layer: str
) -> list[str]:
"""Проверяет ключи, все бизнес-поля и метки; порядок строк не важен."""
errors = []
if not expected:
return ["Контрольный источник пуст. Сначала восстановите Bookings."]
source = {row["airport_code"]: row for row in expected}
if len(source) != len(expected) or None in source or "" in source:
return ["В контрольном источнике пустые или повторяющиеся ключи."]
keys = [row["airport_code"] for row in actual]
if len(keys) != len(set(keys)):
errors.append("Повторяются airport_code внутри снимка.")
missing = source.keys() - set(keys)
extra = set(keys) - source.keys()
if missing:
errors.append(
f"Не получены ключи: {sorted(missing)[:5]} (всего {len(missing)})."
)
if extra:
errors.append(f"Лишние ключи: {sorted(map(str, extra))[:5]}.")
for row in actual:
key = row["airport_code"]
if row.get("_load_id") != batch_id:
errors.append(
f"{key}: _load_id не равен {batch_id}; проверьте выбор батча."
)
timestamps = ("_load_ts", "event_ts") if layer == "stg" else ("_load_ts",)
if any(not row.get(field) for field in timestamps):
errors.append(f"{key}: не заполнены метки времени {timestamps}.")
if key not in source:
continue
for field in FIELDS:
wanted, received = source[key][field], row[field]
if field in JSON_FIELDS:
# Сравниваем весь JSON в STG, включая языки, кроме русского.
wanted = json.loads(wanted) if wanted is not None else None
if layer == "ods":
wanted = wanted.get("ru") if wanted is not None else None
elif received is not None:
try:
received = json.loads(received)
except (ValueError, TypeError):
errors.append(f"{key}: {field} не содержит исходный JSON.")
continue
if received != wanted:
errors.append(f"{key}: отличается {field}.")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
export = actions.add_parser(
"export", help="Сохранить источник напрямую из Postgres"
)
export.add_argument(
"file", type=Path, help="Новый JSON-файл для контрольного источника"
)
check = actions.add_parser("check", help="Сверить снимок с сохраненным источником")
check.add_argument("batch_id", help="Run ID нужного запуска STG")
check.add_argument("file", type=Path, help="Контрольный JSON-файл из export")
check.add_argument("--layer", choices=("stg", "ods"), default="stg")
args = parser.parse_args()
try:
if args.action == "export":
fields = ", ".join(f"{field}::text AS {field}" for field in FIELDS)
rows = read_rows(
"bookings-db",
f"SELECT {fields} FROM bookings.airports_data ORDER BY airport_code",
)
if not rows:
raise ValueError(
"Источник пуст. Пройдите инициализацию Bookings из README."
)
rows.sort(key=lambda row: row["airport_code"])
# Не даем случайно заменить контроль A данными измененного источника B.
with args.file.open("x", encoding="utf-8") as output:
json.dump(rows, output, ensure_ascii=False, indent=2)
output.write("\n")
print(f"Источник: {len(rows)} аэропортов сохранено в {args.file}.")
return 0
expected = json.loads(args.file.read_text(encoding="utf-8"))
# Run ID передается SQL-литералом, а не частью команды shell.
batch_literal = "'" + args.batch_id.replace("'", "''") + "'"
query = f"SELECT * FROM {args.layer}.airports"
if args.layer == "stg":
query += f" WHERE _load_id = {batch_literal}"
actual = read_rows("greenplum", query)
errors = compare_snapshot(expected, actual, args.batch_id, args.layer)
if errors:
print("Сверка не прошла. Проверьте SQL загрузки и выбранный Run ID:")
for error in errors[:10]:
print(f"- {error}")
return 1
print(
f"Сверка пройдена: {args.layer}.airports, _load_id={args.batch_id}, "
f"{len(actual)} ключей; все бизнес-поля и метки совпадают с требованиями."
)
return 0
except subprocess.CalledProcessError as error:
print("Не удалось прочитать БД. Проверьте docker compose ps и настройки .env.")
print(error.stderr.strip())
return 1
except FileExistsError:
print(
"Контрольный файл уже существует. Сохраните его; для нового опыта выберите другой путь."
)
return 1
except (OSError, ValueError, KeyError, TypeError) as error:
print(
f"Не удалось выполнить сверку: {error}. Проверьте путь и формат контрольного файла."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
"""Проверка должна отвергать ошибки, которые не видны по количеству строк."""
import importlib.util
from copy import deepcopy
from pathlib import Path
import pytest
spec = importlib.util.spec_from_file_location(
"check_airports_snapshot",
Path(__file__).resolve().parents[1] / "scripts/check_airports_snapshot.py",
)
checker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(checker)
SOURCE = [
{
"airport_code": "AAA",
"airport_name": '{"en": "Airport", "ru": "Аэропорт"}',
"city": '{"ru": "Город"}',
"country": '{"ru": "Страна"}',
"coordinates": "(1,2)",
"timezone": "Europe/Moscow",
}
]
def snapshot():
"""Снимок с одним ключом и обязательными метками загрузки."""
rows = deepcopy(SOURCE)
rows[0].update(
_load_id="B", _load_ts="2026-09-22 12:00:00", event_ts="2026-09-22 12:00:00"
)
return rows
def test_accepts_complete_snapshot_with_json_key_order_difference():
rows = snapshot()
rows[0]["airport_name"] = '{"ru":"Аэропорт","en":"Airport"}'
assert checker.compare_snapshot(SOURCE, rows, "B", "stg") == []
def test_accepts_different_row_order_from_segments():
"""Порядок выдачи строк Greenplum не меняет содержимое снимка."""
expected = deepcopy(SOURCE) + deepcopy(SOURCE)
expected[1]["airport_code"] = "BBB"
rows = snapshot() + snapshot()
rows[1]["airport_code"] = "BBB"
assert checker.compare_snapshot(expected, rows[::-1], "B", "stg") == []
@pytest.mark.parametrize(
("field", "value"),
[
("airport_code", "BBB"),
("airport_name", '{"ru":"Старое имя","en":"Airport"}'),
("country", '{"ru":"Другая страна"}'),
("coordinates", "(2,1)"),
("_load_id", "A"),
("event_ts", None),
],
)
def test_rejects_wrong_values_despite_equal_row_count(field, value):
rows = snapshot()
rows[0][field] = value
assert checker.compare_snapshot(SOURCE, rows, "B", "stg")
def test_rejects_duplicate_and_empty_snapshot():
rows = snapshot()
assert checker.compare_snapshot(SOURCE, rows + rows, "B", "stg")
assert checker.compare_snapshot(SOURCE, [], "B", "stg")
def test_ods_requires_both_transformation_and_selected_batch():
rows = snapshot()
rows[0].update(airport_name="Аэропорт", city="Город", country="Страна")
assert checker.compare_snapshot(SOURCE, rows, "B", "ods") == []
rows[0]["_load_id"] = "A"
assert checker.compare_snapshot(SOURCE, rows, "B", "ods")
+16
View File
@@ -66,6 +66,22 @@ def test_bookings_stg_ddl_dag_structure():
_assert_reachable(dag, "apply_stg_bookings_ddl", task_id)
def test_lab_pxf_airports_uses_snapshot_sql_and_reference_dependencies():
"""Опыт читает четыре справочника; генератор и транзакции в него не входят."""
dag = _load_dag("airflow.dags.lab_pxf_airports")
assert len(dag.tasks) == 8
for entity in ("airports", "airplanes", "routes", "seats"):
load = dag.get_task(f"load_{entity}_to_stg")
dq = dag.get_task(f"check_{entity}_dq")
assert load.sql == f"stg/{entity}_load.sql"
assert dq.sql == f"stg/{entity}_dq.sql"
assert load.postgres_conn_id == dq.postgres_conn_id == "greenplum_conn"
_assert_direct_edge(dag, load.task_id, dq.task_id)
_assert_direct_edge(dag, "check_airports_dq", "load_routes_to_stg")
_assert_direct_edge(dag, "check_airplanes_dq", "load_routes_to_stg")
_assert_direct_edge(dag, "check_airplanes_dq", "load_seats_to_stg")
def test_bookings_to_gp_stage_dag_structure():
"""Проверка структуры DAG bookings_to_gp_stage."""
dag = _load_dag("airflow.dags.bookings_to_gp_stage")