docs: согласованы руководства загрузок и учебный маршрут

Руководства STG/ODS/DDS/DM сверены с действующими SQL и DAG,
ожидания учебных заглушек связаны с ТЗ. Самопроверка заданий
отделена от проверок разработки и исторических планов.

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

Проверки: make lint; make test (4 passed, 14 skipped); карта;
292 локальные ссылки; маршрут чтения и карта в Chromium.
Живой ETL не запускался.
This commit is contained in:
ddadmin committed 2026-09-20 15:53:39 -04:00
1 parent a6fdd43e15
commit 35144e49f6
17 files changed
+699 -755

No files matched your search

+86 -154
View File
@@ -1,181 +1,113 @@
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
# Загрузка DDS → DM: `bookings_to_gp_dm`
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
Эталон: витрина `sales_report`. Остальные 4 витрины — задания (заглушки `SELECT 1;` на ветке `main`).
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
это ключевая учебная ценность данного DAG.
DAG собирает витрины из факта и измерений DDS. На `main` полностью работает
`dm.sales_report`; остальные четыре загрузки и их DQ содержат `SELECT 1;`.
Успешный запуск с заглушками не означает, что все витрины наполнены.
## Что делает DAG
На [карте sales_report](design/architecture-map.html#node=dm.sales_report)
можно проследить источники полей. Как сегменты билетов превращаются
в строки витрины, объясняет [руководство о структуре](design/db_schema.md#агрегация-фактов-в-витрине-продаж).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
## Перед запуском
| Витрина | Зерно (grain) | Паттерн загрузки | Статус на main |
|---------|---------------|------------------|----------------|
| `dm.sales_report` | (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk) | Инкрементальный UPSERT (HWM по датам) | Эталон |
| `dm.route_performance` | route_bk | Full Rebuild (TRUNCATE + INSERT) | Задание (заглушка) |
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) | Задание (заглушка) |
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) | Задание (заглушка) |
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) | Задание (заглушка) |
Дождитесь успешного `bookings_to_gp_dds`. Создайте таблицы через
`bookings_dm_ddl`, если еще не запускали его или общий `make ddl-gp`.
Затем запустите `bookings_to_gp_dm` в Airflow UI.
Общий порядок находится в [инструкции запуска DAG](dag_execution_order.md).
## Что должно быть готово перед запуском
Для готовой `sales_report` достаточно готового факта, календаря, аэропортов
и тарифов. Для своих витрин сначала заполните нужные измерения и выполните
[пересчет факта после заданий DDS](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
1) Стенд поднят:
## Что делает sales_report
```bash
make up
```
Зерно витрины: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)`.
Одна строка содержит продажи сегментов за дату вылета, пару аэропортов и тариф.
`tickets_sold = COUNT(*)` считает строки факта в этой группе.
2) STG, ODS и DDS уже загружены:
[sales_report_load.sql](../sql/dm/sales_report_load.sql) работает в три шага:
- выполнены DAG-и `bookings_to_gp_stage`, `bookings_to_gp_ods`, `bookings_to_gp_dds`;
- DDL-объекты созданы (`bookings_dm_ddl` или `make ddl-gp`).
1. Находит даты вылета фактов с `_load_ts > MAX(_load_ts)` витрины.
Для пустой витрины нижняя граница равна `1900-01-01`.
2. Для этих дат пересчитывает агрегаты по всем их фактам во временную таблицу.
В расчет входят и строки, загруженные ранее: например, новая посадка
меняет итог дня, сохраняя остальные продажи этого дня.
3. Обновляет существующие группы при изменении проверяемых полей и вставляет
новые. `_load_id` записанных строк равен текущему `run_id` DM.
## Как запустить
Если после пропуска запуска в DDS накопились факты с более новой `_load_ts`,
следующий запуск включит их даты в пересчет. Изменение только описания
измерения само по себе не выбирает дату: отбор привязан к метке факта.
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DM впервые — выполните `bookings_dm_ddl`.
3) Запустите `bookings_to_gp_dm`.
[sales_report_dq.sql](../sql/dm/sales_report_dq.sql) берет даты из строк витрины,
записанных текущим запуском. Для них проверяет непустоту при наличии фактов,
дубли ключа, обязательные поля, неотрицательную выручку и долю посадок от 0 до 1.
Если витрина в этом запуске не менялась, DQ выводит `NOTICE` и завершает работу.
## Граф зависимостей
## Учебные витрины
```
start_dm
├── load_dm_sales_report → dq_dm_sales_report ─┐
├── load_dm_route_performance → dq_dm_route_performance ─┤
├── load_dm_passenger_loyalty → dq_dm_passenger_loyalty ─┼─ finish_dm_summary
├── load_dm_airport_traffic → dq_dm_airport_traffic ─┤
└── load_dm_monthly_overview → dq_dm_monthly_overview ─┘
```
Ниже указано ожидаемое поведение заданий. Поля и формулы находятся в ТЗ;
загрузку и DQ для каждого объекта нужно написать самостоятельно.
Все 5 веток полностью независимы и работают параллельно.
| Задание | Одна строка | Что нужно пересчитывать |
|---|---|---|
| [airport_traffic](assignment/analyst_spec.md#31-dmairport_traffic) | Дата и аэропорт | Полные итоги затронутых дат, с учетом ролей вылета и прилета |
| [route_performance](assignment/analyst_spec.md#32-dmroute_performance) | Маршрут по `route_bk` | Всю историю при каждом запуске, объединяя версии маршрута |
| [monthly_overview](assignment/analyst_spec.md#33-dmmonthly_overview) | Год, месяц и модель по `airplane_sk` | Затронутые месяцы; сначала метрики рейса, затем месяца |
| [passenger_loyalty](assignment/analyst_spec.md#34-dmpassenger_loyalty) | Пассажир по `passenger_sk` | Всю историю пассажиров, чьи факты изменились |
## Как это работает внутри (по шагам)
Готовый пример отбора затронутых дат находится в `sales_report_load.sql`.
В заданиях меняется единица пересчета: дата, месяц, пассажир или вся таблица.
Следуйте [порядку заданий](assignment/README.md#следующие-этапы), чтобы каждый
следующий расчет опирался на уже освоенный прием.
### 1) `load_dm_sales_report` → `dq_dm_sales_report`
## Порядок задач
- **SQL:** `sql/dm/sales_report_load.sql`, `sql/dm/sales_report_dq.sql`
- **Паттерн:** инкрементальный UPSERT (HWM по датам).
В [DAG](../airflow/dags/bookings_to_gp_dm.py) `start_dm` запускает пять
независимых веток. Каждая имеет вид
`load_dm_<витрина> → dq_dm_<витрина> → finish_dm_summary`.
Финальная задача ждет все пять DQ. Витрины читают DDS и не читают друг друга,
поэтому между их задачами загрузки нет зависимостей.
Витрина агрегирует продажи билетов по дате, аэропортам вылета/прилёта и тарифу.
Инкрементальность работает через HWM: витрина сравнивает свой `MAX(_load_ts)` с `_load_ts`
фактов в DDS и пересчитывает агрегаты только для **затронутых дат**.
## Проверка результата
> Если витрина пуста — `1900-01-01` заберёт всю историю (первичная загрузка).
> Если DAG не запускался несколько дней — при следующем запуске витрина автоматически
> «догонит» всю накопленную дельту.
Учебные приёмы:
- **TEMP TABLE** для однократной агрегации (канон для MPP);
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
- **Паттерн:** Full Rebuild (TRUNCATE + INSERT).
Витрина агрегирует эффективность маршрутов за всю историю. Таблица маленькая (~1000 строк),
поэтому пересоздать её с нуля дешевле, чем вычислять дельту. Дополнительная причина:
таблица хранится в формате **AO Column Store**, который не поддерживает эффективный UPDATE/DELETE.
Трёхшаговый паттерн:
1. **TRUNCATE** — очистка (единственный эффективный способ для AO).
2. **Агрегация** по `route_bk` — факты суммируются через **все исторические версии** маршрута
(route_sk из SCD2), чтобы не терять данные при версионировании.
3. **JOIN** с текущей (актуальной, `valid_to IS NULL`) версией `dim_routes` для денормализации.
> Благодаря денормализации `dim_routes` — один JOIN вместо четырёх
> (аэропорты вылета/прилёта, самолёт уже хранятся в `dim_routes`).
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
`avg_ticket_price = total_revenue / total_tickets`.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
- **Паттерн:** инкрементальный UPSERT по «затронутым ключам».
В отличие от `sales_report` (где инкремент по датам), здесь HWM находит **конкретных пассажиров**
с новыми фактами, а затем пересчитывает для них всю историю. Это гарантирует точность
накопительных агрегатов (`total_spent`, `first/last_flight_date`).
Учебные приёмы:
- **DISTINCT ON** (PostgreSQL-специфика) для нахождения моды (самый частый тариф пассажира);
- **Агрегация SCD2 по BK**: при подсчёте уникальных маршрутов используем `route_bk`,
а не `route_sk`, т.к. один маршрут может иметь несколько версий;
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
при data quality аномалиях в измерениях).
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
- **Паттерн:** инкрементальный UPSERT по датам.
Витрина показывает пассажиропоток аэропортов по дням (вылеты + прилёты).
Учебный приём — **Dual-role dimension через UNION ALL**: один билет превращается
в два «события» (вылет из одного аэропорта и прилёт в другой).
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
- **Паттерн:** инкрементальный UPSERT по месяцам.
Витрина показывает помесячную статистику по типам самолётов.
Учебный приём — **двухуровневая агрегация**: чтобы честно посчитать `avg_load_factor`,
сначала считаем load factor для каждого рейса (`boarded / total_seats`),
затем берём среднее по месяцу. Прямая агрегация `SUM(boarded) / SUM(seats)` дала бы
искажённый результат (взвешенный по числу билетов, а не рейсов).
> **Ограничение SCD1:** `total_seats` берётся из текущего состояния `dim_airplanes`.
> Если самолёт переоборудовали в прошлом, для точного исторического расчёта
> потребовалось бы SCD2-измерение.
### 6) `start_dm` / `finish_dm_summary`
- `start_dm` — стартовый sentinel, от которого расходятся все 5 параллельных веток.
- `finish_dm_summary` — ждёт завершения всех DQ-задач и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` посмотрите готовую витрину:
```sql
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.passenger_loyalty; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.airport_traffic; -- будет непусто после реализации задания
SELECT COUNT(*) FROM dm.monthly_overview; -- будет непусто после реализации задания
-- Инвариант sales_report: посаженных не больше, чем продано
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
SELECT flight_date, departure_airport_bk, arrival_airport_bk,
fare_conditions, tickets_sold, passengers_boarded, total_revenue
FROM dm.sales_report
ORDER BY flight_date DESC, departure_airport_bk, arrival_airport_bk,
fare_conditions
LIMIT 10;
SELECT COUNT(*) AS invalid_boarding_rows
FROM dm.sales_report
WHERE tickets_sold < passengers_boarded;
```
Ожидаемо: `dm.sales_report` непуста. Остальные — после реализации заданий.
После первого успешного запуска `sales_report` непуста,
`invalid_boarding_rows = 0`. Возьмите одну строку и найдите на карте,
откуда пришли ее дата, два аэропорта и тариф.
## Типичные ошибки
После реализации своих витрин запустите `bookings_validate` и посмотрите
`validate_dm`. Проверки этой группы требуют непустые таблицы;
правильность метрик должна проверять ваша DQ по ТЗ.
Соблюдайте [правила запуска валидации](assignment/README.md#запуск-и-проверка):
в том же DAG выполняется активный SCD2-тест с восстановлением данных.
- `relation "dm..." does not exist`:
- не применён DM DDL (`bookings_dm_ddl` или `make ddl-gp`).
- DQ падает на `sales_report` по `boarding_rate`:
- проверьте, что DDS загрузился корректно (`dds.fact_flight_sales` непуста).
- DQ падает на `passenger_loyalty` с ошибкой FK:
- проверьте, что `dds.dim_passengers` содержит всех пассажиров из факта.
- `finish_dm_summary` не выполняется:
- одна из DQ-задач упала; найдите в логах Airflow задачу с ошибкой и исправьте.
## Если загрузка не прошла
- Нет `dm.*`: выполните `bookings_dm_ddl`.
- `sales_report` пуста: проверьте наличие факта и соответствий его SK
календарю, аэропортам и тарифам. Витрина соединяет их через `INNER JOIN`.
- Учебная витрина пуста: проверьте замену загрузочной заглушки и наличие
необходимых SK факта после заданий DDS.
- Ошибка метрики: разберите одну группу и сравните результат со строками факта
и формулой в ТЗ. Дополнительные примеры проверок есть в
[справочнике DQ](reference/dq_taxonomy.md).
- `finish_dm_summary` не выполняется: найдите первую упавшую задачу,
откройте ее Log и исправьте причину до следующего запуска слоя.