Compare commits
9
Commits
b6e950f52c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ed7e6f43e | ||
|
|
6658a3b1aa | ||
|
|
1e72776254 | ||
|
|
1dd5838fb5 | ||
|
|
351dbbb3c1 | ||
|
|
35144e49f6 | ||
|
|
a6fdd43e15 | ||
|
|
20eb7f7b84 | ||
|
|
b7ecfb1bb7 |
No files matched your search
@@ -73,6 +73,11 @@
|
||||
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
|
||||
|
||||
## Для агента (особенности аудитории)
|
||||
|
||||
При создании и пересмотре лабораторных сначала прочитайте
|
||||
[развитие лабораторных в ветке solution](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/solution/docs/design/labs_development.md):
|
||||
аудитория, принятые решения и критерии заданий.
|
||||
|
||||
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
|
||||
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
|
||||
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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 или интерфейс не проверялись, укажите это прямо.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
требуют сверки с действующими файлами перед использованием.
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
требуют сверки с действующими файлами перед использованием.
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
|
||||
@@ -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
@@ -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) ведет к справочникам, проверкам разработки
|
||||
и истории проектирования.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
|
||||
класса. Один `*_dq.sql` может содержать проверки нескольких классов.
|
||||
Сверяйте их с полями и правилами своего объекта в [ТЗ](../assignment/analyst_spec.md).
|
||||
Как запустить DQ и проверить задание, описано в
|
||||
[инструкции самопроверки](../assignment/README.md#запуск-и-проверка).
|
||||
На `main` учебные DQ содержат `SELECT 1;` и начнут проверять данные
|
||||
после вашей реализации.
|
||||
|
||||
## 1. Универсальные структурные проверки
|
||||
|
||||
|
||||
+15
-16
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user