Compare commits
2
Commits
main
..
999a8d08bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999a8d08bf | ||
|
|
d3f2f42e38 |
@@ -12,9 +12,9 @@
|
|||||||
- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
|
- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
|
||||||
- `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
|
- `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
|
||||||
- *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
|
- *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
|
||||||
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `assignment/` (задание для студента).
|
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `plans/` (активные планы), `archive/` (выполненные планы), `assignment/` (задание для студента).
|
||||||
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
|
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
|
||||||
- Полные дизайн-документы (PRD, assignment_design, планы, архив) — в ветке `solution`.
|
- Нейминг файлов планов: `YYYY-MM-DD_краткое-описание.md` (дата — ISO 8601, `_` отделяет дату от описания, описание в kebab-case, без суффикса `-plan`).
|
||||||
- `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
|
- `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
|
||||||
- `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
|
- `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
- Есть smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
- Есть smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
||||||
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
|
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
|
||||||
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
|
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
|
||||||
|
- Для программной проверки DAG (без браузера) — см. `docs/agent-dag-testing.md`: CLI, REST API, проверка параллельности, запросы в Greenplum.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
- Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`.
|
- Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`.
|
||||||
|
|||||||
@@ -87,20 +87,23 @@ make bookings-init # быстрое восстановление из s
|
|||||||
make gp-psql
|
make gp-psql
|
||||||
-- внутри psql:
|
-- внутри psql:
|
||||||
SELECT COUNT(*) FROM stg.bookings;
|
SELECT COUNT(*) FROM stg.bookings;
|
||||||
|
SELECT COUNT(*) FROM stg.tickets;
|
||||||
|
SELECT * FROM stg.bookings ORDER BY event_ts DESC LIMIT 10;
|
||||||
SELECT COUNT(*) FROM ods.bookings;
|
SELECT COUNT(*) FROM ods.bookings;
|
||||||
SELECT COUNT(*) FROM dds.dim_airports;
|
SELECT COUNT(*) FROM ods.tickets;
|
||||||
|
SELECT COUNT(*) FROM dds.dim_routes;
|
||||||
SELECT COUNT(*) FROM dds.fact_flight_sales;
|
SELECT COUNT(*) FROM dds.fact_flight_sales;
|
||||||
SELECT COUNT(*) FROM dm.sales_report;
|
SELECT COUNT(*) FROM dm.sales_report;
|
||||||
-- Остальные DDS/DM-таблицы пока пусты — вы реализуете их в задании.
|
SELECT COUNT(*) FROM dm.route_performance;
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
|
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
|
||||||
|
|
||||||
7) Приступайте к заданию:
|
7) Изучите готовую реализацию:
|
||||||
|
|
||||||
Стенд работает, данные загружены. Переходите к
|
Весь пайплайн `STG → ODS → DDS → DM` на этой ветке полностью реализован.
|
||||||
[учебным заданиям](docs/assignment/README.md) — там описан путь
|
Переходите к [навигатору по реализации](docs/assignment/README.md) —
|
||||||
от изучения эталона до полной реализации DWH.
|
там описана структура, ключевые паттерны и рекомендуемый порядок изучения.
|
||||||
|
|
||||||
## DAG-и в стенде
|
## DAG-и в стенде
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# TODO (maintainers / mentors)
|
||||||
|
|
||||||
|
Этот файл собирает задачи по подготовке стенда к курсовой работе
|
||||||
|
и идеи по доработке, которые не критичны для текущих задач менти.
|
||||||
|
|
||||||
|
Контекст и стратегия: [docs/design/PRD.md](docs/design/PRD.md).
|
||||||
|
Дизайн задания: [docs/design/assignment_design.md](docs/design/assignment_design.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подготовка курсовой
|
||||||
|
|
||||||
|
> Дедлайн: ~2-3 недели (первый студент может подойти к курсовой).
|
||||||
|
|
||||||
|
### Этап 1. Вынос CSV-пайплайна
|
||||||
|
|
||||||
|
**Инструмент:** Sonnet / Gemini / ChatGPT — механическая работа, перенос файлов.
|
||||||
|
|
||||||
|
- [x] Перенести в [airflow-manual](https://github.com/dementev-dev/airflow-manual):
|
||||||
|
`csv_to_greenplum.py`, `csv_to_greenplum_dq.py`, `ddl_greenplum_base.py`,
|
||||||
|
`helpers/greenplum.py`, `sql/base/orders_ddl.sql`, связанные тесты
|
||||||
|
- [x] Убрать CSV-зависимости из docker-compose / .env (`CSV_DIR`, `CSV_ROWS`)
|
||||||
|
- [x] Обновить README (убрать упоминания CSV-пайплайна)
|
||||||
|
|
||||||
|
### Этап 1.5. Полировка эталона
|
||||||
|
|
||||||
|
**Инструмент:** Opus (глубокий анализ кода и контекста проекта)
|
||||||
|
+ ручное тестирование (make up, запуск DAG'ов, проверка данных).
|
||||||
|
|
||||||
|
- [x] Протестировать полный ETL-цикл с нуля
|
||||||
|
(make up → bookings-init (восстановление из дампа) → STG → ODS → DDS → DM)
|
||||||
|
- [x] Прогнать инкремент (bookings-generate-day → повторный запуск DAG'ов)
|
||||||
|
- [x] Почистить код эталонного среза
|
||||||
|
- [x] Актуализировать README и документацию
|
||||||
|
- [x] Убедиться, что `make test` и `make lint` проходят
|
||||||
|
- [x] Проверить, что стенд поднимается на чистой машине
|
||||||
|
(проверено 2026-03-09: `cp .env.example .env` → `make up` → `make bookings-init` →
|
||||||
|
`make ddl-gp` → STG → ODS → DDS → DM — всё success)
|
||||||
|
|
||||||
|
### Этап 2. ТЗ от аналитика
|
||||||
|
|
||||||
|
**Инструмент:** Opus — нужно глубокое понимание предметной области, маппингов
|
||||||
|
между слоями, SCD-паттернов и педагогического контекста.
|
||||||
|
|
||||||
|
> Делаем пока полный пайплайн работает — можно сверяться с реальными данными.
|
||||||
|
|
||||||
|
- [x] Создать `docs/assignment/analyst_spec.md`
|
||||||
|
- [x] Для каждой таблицы-задания: имя, описание, поля, маппинг,
|
||||||
|
бизнес-правила, тип SCD, гранулярность, distribution key
|
||||||
|
- [x] Для dim_routes (SCD2): пошаговый алгоритм текстом, формула hashdiff
|
||||||
|
- [x] Рекомендуемый порядок выполнения
|
||||||
|
|
||||||
|
### Этап 3. Валидационный DAG
|
||||||
|
|
||||||
|
**Инструмент:** Sonnet (шаблонная работа, структура в assignment_design.md)
|
||||||
|
+ Opus для финальной вычитки.
|
||||||
|
|
||||||
|
> Делаем и тестируем на полных данных, пока ничего не удалено.
|
||||||
|
|
||||||
|
- [x] Создать `airflow/dags/bookings_validate.py`
|
||||||
|
- [x] Создать SQL-скрипты в `sql/validate/`
|
||||||
|
- [x] Таски по слоям: STG, ODS, DDS, DM
|
||||||
|
- [x] Дружелюбные сообщения об ошибках с подсказками
|
||||||
|
- [x] Протестировать на работающем стенде
|
||||||
|
|
||||||
|
### Этап 4. Подготовка main и ветка solution
|
||||||
|
|
||||||
|
**Инструмент:** Opus — раскладка по веткам, заглушки, ослабление DQ.
|
||||||
|
|
||||||
|
> Финальный этап: всё готово и протестировано, теперь раскладываем по веткам.
|
||||||
|
> План: `docs/plans/2026-03-12_main-solution-split.md`
|
||||||
|
|
||||||
|
- [x] Смержить chore/bookings-etl → main
|
||||||
|
- [x] Общие правки на main (документация, TODO.md)
|
||||||
|
- [x] Создать ветку solution (снимок полного эталона)
|
||||||
|
- [x] Main-only правки: заглушки, ослабление DQ, адаптация тестов
|
||||||
|
- [x] Очистка docs на main (удалить внутренние документы)
|
||||||
|
- [x] Верификация main (`make test`, `make lint`)
|
||||||
|
- [x] Верификация solution (`make test`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Прочее (бэклог)
|
||||||
|
|
||||||
|
- [ ] Протестировать устойчивость `bookings-db` после остановки контейнеров:
|
||||||
|
- прогнать сценарии `make stop` -> `make up` и `make down` -> `make up`;
|
||||||
|
- зафиксировать, ломается ли генератор/данные в `bookings-db`;
|
||||||
|
- при необходимости добавить шаги восстановления и обновить документацию.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Выполнено
|
||||||
|
|
||||||
|
- [x] Сделать REST API Airflow основным способом тестирования ETL вместо CLI-вызовов
|
||||||
|
через `docker compose exec ... airflow ...`:
|
||||||
|
- обновить `TESTING.md`, сместив фокус на REST API сценарии;
|
||||||
|
- оставить CLI как резервный вариант для локальной отладки;
|
||||||
|
- проверить, что шаги тестирования воспроизводимы без входа в контейнер Airflow.
|
||||||
|
|
||||||
|
- [x] Собрать свой образ Airflow поверх `apache/airflow:2.9.2`:
|
||||||
|
- вынести установку Python‑зависимостей из runtime (`pip install ...` при старте контейнеров)
|
||||||
|
в отдельный `Dockerfile`;
|
||||||
|
- переключить `docker-compose.yml` на использование этого образа для `airflow-webserver`,
|
||||||
|
`airflow-scheduler` и `airflow-init`;
|
||||||
|
- обновить документацию (README/TESTING) под новую схему сборки.
|
||||||
|
|
||||||
|
- [x] Переключить Airflow с `SequentialExecutor` (SequentialScheduler) на `LocalExecutor`
|
||||||
|
для docker‑стенда:
|
||||||
|
- проверить, какие параметры достаточно поменять в env/конфиге (`AIRFLOW__CORE__EXECUTOR`)
|
||||||
|
для образа `apache/airflow:2.9.2`;
|
||||||
|
- убедиться, что примерные DAG'и (`csv_to_greenplum`, `bookings_to_gp_stage`) ведут себя
|
||||||
|
предсказуемо в режиме параллельного исполнения;
|
||||||
|
- при необходимости скорректировать тесты и документацию (README/TESTING) с учётом нового executor'а.
|
||||||
|
|
||||||
|
- [x] Разобрать и стабилизировать интеграцию с Greenplum/PXF:
|
||||||
|
- убедиться, что PXF в контейнере `greenplum` всегда корректно инициализируется
|
||||||
|
(нет ошибок вида `protocol "pxf" does not exist` при первом запуске `make ddl-gp`);
|
||||||
|
- при необходимости доработать init‑скрипты в `pxf/init/` и/или документацию,
|
||||||
|
чтобы порядок действий для ментей был однозначным и воспроизводимым;
|
||||||
|
- добавить краткий раздел в README/TESTING о типичных ошибках PXF/Greenplum и шагах по их устранению.
|
||||||
|
- диагностика текущего кейса: `docs/reference/pxf_bookings.md` (раздел «Известная проблема»).
|
||||||
|
|
||||||
|
- [x] Разобраться с генератором demodb:
|
||||||
|
- после `make bookings-generate` таблица `bookings.bookings` остаётся пустой;
|
||||||
|
- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch`
|
||||||
|
падают при применении (hunk failed / garbage in patch);
|
||||||
|
- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой).
|
||||||
|
- детали: `docs/reference/bookings_db_issues.md`
|
||||||
|
|
||||||
|
- [x] Добавить раздел «Благодарности» в `README.md`:
|
||||||
|
- явно поблагодарить Postgres Pro за демо‑БД bookings (репозиторий `postgrespro/demodb`);
|
||||||
|
- указать автора Docker‑сборки Greenplum (`woblerr/docker-greenplum`, образ `woblerr/greenplum`);
|
||||||
|
- при необходимости сослаться на соответствующие лицензии/README исходных проектов.
|
||||||
|
|
||||||
|
- [x] Добавить в образ Airflow установку `psql`, чтобы тестировать загрузку CSV из CLI внутри контейнера (без root и дополнительных зависимостей на хосте).
|
||||||
|
|
||||||
|
- [x] Денормализовать `dds.dim_routes` (добавить departure_city, arrival_city, airplane_model, total_seats):
|
||||||
|
- привести измерение в соответствие с принципом Кимбалла («самодостаточное измерение»);
|
||||||
|
- упростить `dm.route_performance` с 4-JOIN до 1-JOIN;
|
||||||
|
- обновить DAG-зависимости: airports+airplanes DQ → routes load;
|
||||||
|
- подробный план: `docs/archive/dim_routes_denormalization_plan.md`.
|
||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty,
|
Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty,
|
||||||
airport_traffic, monthly_overview.
|
airport_traffic, monthly_overview.
|
||||||
|
|
||||||
DDL создаёт все 5 витрин. Эталон: sales_report. Остальные — задания.
|
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ from __future__ import annotations
|
|||||||
- для каждой сущности выполняем пару задач load -> dq;
|
- для каждой сущности выполняем пару задач load -> dq;
|
||||||
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
|
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
|
||||||
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
|
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
|
||||||
|
|
||||||
Эталон: dim_airports, dim_tariffs, dim_calendar, fact_flight_sales. dim_routes, dim_passengers, dim_airplanes — задания (заглушки).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
- все витрины загружаются параллельно (не зависят друг от друга);
|
- все витрины загружаются параллельно (не зависят друг от друга);
|
||||||
- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column).
|
- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column).
|
||||||
|
|
||||||
Эталон: sales_report. Остальные 4 витрины — задания (заглушки SELECT 1;).
|
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ from __future__ import annotations
|
|||||||
- весь запуск ODS работает с одним stg_batch_id;
|
- весь запуск ODS работает с одним stg_batch_id;
|
||||||
- для каждой сущности выполняем пару задач load -> dq;
|
- для каждой сущности выполняем пару задач load -> dq;
|
||||||
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
||||||
|
|
||||||
Эталон: airports, routes и 5 инкрементальных сущностей. airplanes, seats — задания (заглушки).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -265,9 +263,7 @@ with DAG(
|
|||||||
resolve_stg_batch_id >> load_ods_airports >> dq_ods_airports
|
resolve_stg_batch_id >> load_ods_airports >> dq_ods_airports
|
||||||
resolve_stg_batch_id >> load_ods_airplanes >> dq_ods_airplanes
|
resolve_stg_batch_id >> load_ods_airplanes >> dq_ods_airplanes
|
||||||
|
|
||||||
# На main routes не зависит от airplanes (ods.airplanes — студенческая заглушка).
|
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes >> dq_ods_routes
|
||||||
# На ветке solution: [dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
|
|
||||||
dq_ods_airports >> load_ods_routes >> dq_ods_routes
|
|
||||||
dq_ods_airplanes >> load_ods_seats >> dq_ods_seats
|
dq_ods_airplanes >> load_ods_seats >> dq_ods_seats
|
||||||
|
|
||||||
dq_ods_routes >> load_ods_flights >> dq_ods_flights
|
dq_ods_routes >> load_ods_flights >> dq_ods_flights
|
||||||
|
|||||||
+16
-2
@@ -20,12 +20,26 @@
|
|||||||
- [Дизайн-документ ODS](design/bookings_ods_design.md)
|
- [Дизайн-документ ODS](design/bookings_ods_design.md)
|
||||||
- [Дизайн-документ DDS](design/bookings_dds_design.md)
|
- [Дизайн-документ DDS](design/bookings_dds_design.md)
|
||||||
- [Дизайн-документ DM](design/bookings_dm_design.md)
|
- [Дизайн-документ DM](design/bookings_dm_design.md)
|
||||||
|
- [Архитектурные решения (ADR)](design/architecture_review.md)
|
||||||
> Полные дизайн-документы (PRD, assignment_design, архитектурные решения) — в ветке `solution`.
|
- [PRD: стратегия курсовой](design/PRD.md)
|
||||||
|
- [Дизайн задания](design/assignment_design.md)
|
||||||
|
|
||||||
## Справочники (`reference/`)
|
## Справочники (`reference/`)
|
||||||
|
|
||||||
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
|
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
|
||||||
|
- [PXF в этом проекте (проектная реализация)](reference/pxf_bookings.md)
|
||||||
|
- [Про время/UTC в bookings](reference/bookings_tz.md)
|
||||||
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
|
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
|
||||||
|
- [Бенчмарк генерации данных](reference/bookings_generation_benchmark.md)
|
||||||
- [QA-план отладки пайплайна](reference/qa-plan.md)
|
- [QA-план отладки пайплайна](reference/qa-plan.md)
|
||||||
- [Порядок запуска DAG-ов](dag_execution_order.md)
|
- [Порядок запуска DAG-ов](dag_execution_order.md)
|
||||||
|
- [End-to-end протокол тестирования](e2e-etl-test-protocol.md)
|
||||||
|
- [Тестирование DAG-ов через API](agent-dag-testing.md)
|
||||||
|
|
||||||
|
## Планы (`plans/`)
|
||||||
|
|
||||||
|
Активные планы работ. После выполнения переносятся в `archive/`.
|
||||||
|
|
||||||
|
## Архив (`archive/`)
|
||||||
|
|
||||||
|
Выполненные планы, закрытые ревью. Ссылки внутри файлов могут быть устаревшими.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Тестирование DAG (гайд для AI-агентов)
|
||||||
|
|
||||||
|
Этот документ описывает, как агенту взаимодействовать с Airflow (без UI) для точечной проверки и отладки DAG'ов в процессе разработки.
|
||||||
|
|
||||||
|
## 0. Состояние среды (Предварительная проверка)
|
||||||
|
|
||||||
|
Прежде чем тестировать DAG, убедитесь, что стек работает:
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
Если контейнеров нет, поднимите стек: `make up`.
|
||||||
|
Если исходная база данных пуста (например, после `make clean`), проинициализируйте её: `make bookings-init`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Быстрая проверка структуры графа (Локально)
|
||||||
|
|
||||||
|
При любом изменении Python-кода DAG'а сначала проверьте, что он компилируется и структура графа корректна:
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
Это запустит smoke-тесты (`tests/test_dags_smoke.py`), которые проверят целостность всех DAG'ов без обращения к базе данных.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Запуск конкретного DAG'а (REST API)
|
||||||
|
|
||||||
|
Для тестирования загрузки данных запустите измененный DAG через REST API (базовый URL `http://localhost:8080/api/v1`, креды взять из `.venv`).
|
||||||
|
|
||||||
|
**Шаг 2.1. Снять DAG с паузы (при старте стенда все DAG'и на паузе — этот шаг обязателен!):**
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://localhost:8080/api/v1/dags/<dag_id>" \
|
||||||
|
-u admin:admin -H "Content-Type: application/json" -d '{"is_paused": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 2.2. Запустить DAG:**
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns" \
|
||||||
|
-u admin:admin -H "Content-Type: application/json" -d '{}' | jq '{dag_run_id}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 2.3. Проверить статус выполнения:**
|
||||||
|
Используйте `dag_run_id` из предыдущего шага:
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>" \
|
||||||
|
-u admin:admin | jq '{state}'
|
||||||
|
```
|
||||||
|
Повторяйте запрос, пока `state` не станет `success` или `failed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Отладка упавших задач
|
||||||
|
|
||||||
|
Если DAG перешел в статус `failed`, найдите упавшую задачу:
|
||||||
|
|
||||||
|
**Шаг 3.1. Получить статусы всех задач:**
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/dags/<dag_id>/dagRuns/<dag_run_id>/taskInstances" \
|
||||||
|
-u admin:admin | jq '.task_instances[] | {task_id, state}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 3.2. Посмотреть логи упавшей задачи (через CLI Airflow):**
|
||||||
|
REST API отдает логи сложно, поэтому для логов проще использовать `docker compose exec`:
|
||||||
|
```bash
|
||||||
|
docker compose exec airflow-webserver airflow tasks logs <dag_id> <task_id> <run_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Совет: ищите в логах слова `ERROR`, `Exception` или вывод SQL-ошибок от PostgresOperator).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Проверка результата в DWH (Greenplum)
|
||||||
|
|
||||||
|
Успешное выполнение DAG'а (зеленый статус) не гарантирует, что данные загрузились правильно (например, если источник был пуст). Проверьте целевые таблицы напрямую:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM <схема>.<таблица>;'\""
|
||||||
|
```
|
||||||
|
Убедитесь, что таблица содержит ожидаемое количество строк.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Полный сквозной тест (E2E)
|
||||||
|
|
||||||
|
Если вы вносили масштабные изменения, затрагивающие несколько слоев DWH, или меняли DDL таблиц, рекомендуется прогнать полный конвейер (STG -> ODS -> DDS -> DM):
|
||||||
|
```bash
|
||||||
|
make e2e-etl
|
||||||
|
```
|
||||||
|
Этот скрипт сам запустит все нужные DAG'и в правильном порядке и проверит результаты.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Ревью решения (образец для студентов): `bookings-db` → `stg` в Greenplum
|
||||||
|
|
||||||
|
Этот документ фиксирует рекомендации по улучшению учебного решения ETL (Airflow + Greenplum + PXF)
|
||||||
|
на основе ревью изменений ветки `chore/bookings-etl` (добавление полного STG слоя и пайплайна загрузки).
|
||||||
|
|
||||||
|
Цель ревью — сделать решение **безоговорочно рекомендуемым** к изучению начинающими:
|
||||||
|
понятным, предсказуемым, с корректной терминологией и честными инженерными компромиссами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Сильные стороны решения (что уже хорошо и стоит сохранить)
|
||||||
|
|
||||||
|
1. **Единый “шаблон” по таблицам** в `sql/stg/`:
|
||||||
|
- `{table}_ddl.sql` — создаёт `*_ext` и внутреннюю таблицу;
|
||||||
|
- `{table}_load.sql` — загружает данные;
|
||||||
|
- `{table}_dq.sql` — валидирует качество и останавливает пайплайн при проблемах.
|
||||||
|
|
||||||
|
Это отличная учебная структура: студент быстро понимает, “где что лежит” и как добавлять новые таблицы.
|
||||||
|
|
||||||
|
2. **DAG как оркестратор, SQL как логика**:
|
||||||
|
- `airflow/dags/bookings_to_gp_stage.py` и `airflow/dags/bookings_stg_ddl.py` используют `PostgresOperator`
|
||||||
|
и читают SQL с диска через `template_searchpath="/sql"`.
|
||||||
|
Это соответствует “канонической” модели: Airflow управляет шагами, а трансформации живут в SQL.
|
||||||
|
|
||||||
|
3. **Понятные сообщения при падении DQ** (в большинстве скриптов): студенту легче дебажить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Критичные замечания (статус на текущий момент)
|
||||||
|
|
||||||
|
### 2.1. Некорректные утверждения про MPP и co-location (статус: исправлено)
|
||||||
|
|
||||||
|
В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам.
|
||||||
|
Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion).
|
||||||
|
|
||||||
|
Раньше в некоторых DDL-комментариях обещалась co-location там, где её не будет.
|
||||||
|
Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные.
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- DDL-комментарии приведены к честной формулировке “ключ выбран так-то, но JOIN по другим ключам может требовать motion”.
|
||||||
|
- Исправлены места, где co-location заявлялась ошибочно (в т.ч. `routes`, `flights`, `segments`, `boarding_passes`).
|
||||||
|
|
||||||
|
Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`.
|
||||||
|
|
||||||
|
### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: исправлено)
|
||||||
|
|
||||||
|
Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `_load_id`**.
|
||||||
|
При append-only истории это может скрыть проблемы текущей загрузки:
|
||||||
|
родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил.
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`_load_id = текущий батч`).
|
||||||
|
- `seats_dq.sql`: проверка airplanes стала батч-строгой (`_load_id = текущий батч`).
|
||||||
|
- `flights_dq.sql`: проверка routes стала батч-строгой (`_load_id = текущий батч`).
|
||||||
|
|
||||||
|
Примечание (почему не везде `_load_id = текущий батч`):
|
||||||
|
- Если дочерняя таблица грузится инкрементом, то ссылки могут указывать на “исторические” записи,
|
||||||
|
загруженные в предыдущих батчах → для таких связей корректнее проверять “существует в STG вообще”.
|
||||||
|
- Для `boarding_passes` (full snapshot) ссылки на `tickets/segments` также проверяются по STG-истории,
|
||||||
|
потому что `tickets/segments` не перезагружаются полным снэпшотом каждый запуск.
|
||||||
|
|
||||||
|
### 2.3. Smoke-тесты DAG’ов (статус: исправлено)
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives(upstream=False)` и “барьеры” через `get_flat_relatives(upstream=False)`.
|
||||||
|
|
||||||
|
### 2.4. Документация по DAG (статус: синхронизировано)
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG)
|
||||||
|
|
||||||
|
### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) (статус: исправлено)
|
||||||
|
|
||||||
|
Раньше поведение было разным:
|
||||||
|
- часть DQ-скриптов падала, если в окне инкремента 0 строк;
|
||||||
|
- `boarding_passes_dq.sql` делал `RAISE NOTICE` и `RETURN`.
|
||||||
|
|
||||||
|
Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить:
|
||||||
|
- **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день);
|
||||||
|
- **Skip** полезен, когда “окно может быть пустым и это нормально”.
|
||||||
|
|
||||||
|
Выбранная политика для учебного стенда:
|
||||||
|
- Для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) “пустое окно” **допустимо**:
|
||||||
|
DQ логирует `NOTICE` и завершает проверку, не падая.
|
||||||
|
- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой:
|
||||||
|
это почти всегда признак проблем с PXF/источником.
|
||||||
|
|
||||||
|
### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” (статус: исправлено)
|
||||||
|
|
||||||
|
Типовой паттерн:
|
||||||
|
```sql
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM stg.table WHERE _load_id = '{{ run_id }}' AND key = ext.key
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Это в первую очередь защита от повторного запуска того же таска в рамках одного `_load_id` (retry),
|
||||||
|
а не “лечение” дублей в источнике.
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”.
|
||||||
|
|
||||||
|
### 3.3. Проверка составных ключей: избегать склейки строк (статус: исправлено)
|
||||||
|
|
||||||
|
Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель).
|
||||||
|
В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход.
|
||||||
|
|
||||||
|
Что сделано:
|
||||||
|
- Заменили склейку строк на `COUNT(DISTINCT md5(ROW(col1, col2)::text))` в DQ‑скриптах для составных ключей.
|
||||||
|
Такой подход сохраняет DV‑стиль и убирает неоднозначность разделителей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Практические примеры “как улучшить”
|
||||||
|
|
||||||
|
### 4.1. Батч-строгая ссылочная целостность (пример подхода)
|
||||||
|
|
||||||
|
Если таблицы грузятся как snapshot в рамках батча, проверки можно сделать батч-строгими:
|
||||||
|
“в текущем батче ссылки указывают на строки текущего батча”.
|
||||||
|
|
||||||
|
Идея (пример для routes → airports):
|
||||||
|
```sql
|
||||||
|
LEFT JOIN stg.airports AS a
|
||||||
|
ON r.departure_airport = a.airport_code
|
||||||
|
AND a._load_id = v_batch_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Smoke-тест реального графа (минимальный полезный уровень)
|
||||||
|
|
||||||
|
Вместо “таски существуют” лучше проверять ключевые зависимости:
|
||||||
|
```python
|
||||||
|
tickets_dq = dag.get_task("check_tickets_dq")
|
||||||
|
airports_load = dag.get_task("load_airports_to_stg")
|
||||||
|
assert airports_load in tickets_dq.get_direct_relatives(upstream=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Чек-лист “готово как эталон”
|
||||||
|
|
||||||
|
- [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей.
|
||||||
|
- [x] Для DQ определена и описана политика “0 строк”: где fail, где skip.
|
||||||
|
- [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно).
|
||||||
|
- [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG.
|
||||||
|
- [x] Smoke-тесты проверяют хотя бы критические зависимости графа.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# План: Денормализация dim_routes + упрощение route_performance
|
||||||
|
|
||||||
|
> **Статус:** реализовано.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
`dds.dim_routes` хранит только FK-ссылки (`departure_airport`, `arrival_airport`, `airplane_code`) без атрибутов (город, модель самолёта, кол-во мест). Из-за этого витрина `dm.route_performance` вынуждена делать 4-JOIN цепочку вместо одного JOIN к измерению. Это противоречит принципу Кимбалла (измерение должно быть «самодостаточным») и усложняет учебный материал.
|
||||||
|
|
||||||
|
**Цель:** добавить в dim_routes 4 денормализованных колонки, упростить route_performance до 1 JOIN, показать студентам правильную Star Schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
| # | Файл | Суть |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `sql/dds/dim_routes_ddl.sql` | +4 колонки через ALTER TABLE |
|
||||||
|
| 2 | `sql/dds/dim_routes_load.sql` | JOIN к airports/airplanes при INSERT + refresh-шаг |
|
||||||
|
| 3 | `sql/dds/dim_routes_dq.sql` | NOT NULL проверка для новых колонок (current-срез) |
|
||||||
|
| 4 | `airflow/dags/bookings_to_gp_dds.py` | Зависимость: airports+airplanes DQ → routes load |
|
||||||
|
| 5 | `sql/dm/route_performance_load.sql` | Упрощение 4-JOIN → 1-JOIN |
|
||||||
|
| 6 | `tests/test_dags_smoke.py` | Новые assert для зависимости routes от airports/airplanes |
|
||||||
|
| 7 | `docs/internal/db_schema.md` | Обновить схему dim_routes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 1. DDL — `sql/dds/dim_routes_ddl.sql`
|
||||||
|
|
||||||
|
После существующего `COMMENT ON TABLE` добавить учебный комментарий и 4 ALTER TABLE:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Денормализация атрибутов из SCD1-измерений (dim_airports, dim_airplanes).
|
||||||
|
--
|
||||||
|
-- Учебный комментарий (Kimball Star Schema):
|
||||||
|
-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes —
|
||||||
|
-- и аналитик видит маршрут, города, модель самолёта и кол-во мест.
|
||||||
|
--
|
||||||
|
-- Эти колонки НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения
|
||||||
|
-- атрибутов маршрута (аэропорт, самолёт, расписание). Если изменится
|
||||||
|
-- название города — обновим отдельным refresh-шагом, не создавая новую версию.
|
||||||
|
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS departure_city TEXT;
|
||||||
|
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS arrival_city TEXT;
|
||||||
|
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS airplane_model TEXT;
|
||||||
|
ALTER TABLE dds.dim_routes ADD COLUMN IF NOT EXISTS total_seats INTEGER;
|
||||||
|
```
|
||||||
|
|
||||||
|
Колонки nullable — `ALTER TABLE ADD COLUMN ... NOT NULL` без DEFAULT упадёт на существующих строках. DQ проверит NOT NULL для current-среза.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 2. Load — `sql/dds/dim_routes_load.sql`
|
||||||
|
|
||||||
|
Структура: 3 фазы вместо 2.
|
||||||
|
|
||||||
|
**Фаза 1 (без изменений):** temp table + hashdiff из ods.routes, UPDATE закрытие версий. Hashdiff **не включает** денормализованные атрибуты.
|
||||||
|
|
||||||
|
**Фаза 2 (изменён INSERT):** при вставке новой версии — LEFT JOIN к dim_airports (×2) и dim_airplanes для заполнения departure_city, arrival_city, airplane_model, total_seats.
|
||||||
|
|
||||||
|
Конкретно: в INSERT (Statement 2, строки 59-107) добавить:
|
||||||
|
- В список колонок: `departure_city, arrival_city, airplane_model, total_seats`
|
||||||
|
- В SELECT: `dep.city, arr.city, air.model, air.total_seats`
|
||||||
|
- LEFT JOIN к dim_airports и dim_airplanes (LEFT — чтобы не терять маршруты при отсутствии аэропорта; DQ поймает)
|
||||||
|
|
||||||
|
**Фаза 3 (новая):** refresh денормализованных атрибутов для всех current-версий:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Фаза 3: Обновление денормализованных атрибутов (refresh).
|
||||||
|
-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города).
|
||||||
|
-- Обновляем ТОЛЬКО current-версии (valid_to IS NULL).
|
||||||
|
UPDATE dds.dim_routes AS d
|
||||||
|
SET departure_city = dep.city,
|
||||||
|
arrival_city = arr.city,
|
||||||
|
airplane_model = air.model,
|
||||||
|
total_seats = air.total_seats,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM dds.dim_airports AS dep,
|
||||||
|
dds.dim_airports AS arr,
|
||||||
|
dds.dim_airplanes AS air
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
AND dep.airport_bk = d.departure_airport
|
||||||
|
AND arr.airport_bk = d.arrival_airport
|
||||||
|
AND air.airplane_bk = d.airplane_code
|
||||||
|
AND (
|
||||||
|
d.departure_city IS DISTINCT FROM dep.city
|
||||||
|
OR d.arrival_city IS DISTINCT FROM arr.city
|
||||||
|
OR d.airplane_model IS DISTINCT FROM air.model
|
||||||
|
OR d.total_seats IS DISTINCT FROM air.total_seats
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот же шаг при первом запуске заполнит колонки для существующих данных (backfill).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 3. DQ — `sql/dds/dim_routes_dq.sql`
|
||||||
|
|
||||||
|
Перед финальным `RAISE NOTICE` добавить проверку:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Денормализованные поля не пустые в текущих (актуальных) версиях.
|
||||||
|
-- Исторические версии могли быть загружены ДО добавления колонок — пропускаем.
|
||||||
|
SELECT COUNT(*) INTO v_null_count
|
||||||
|
FROM dds.dim_routes
|
||||||
|
WHERE valid_to IS NULL
|
||||||
|
AND (departure_city IS NULL OR arrival_city IS NULL
|
||||||
|
OR airplane_model IS NULL OR total_seats IS NULL);
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в current-срезе dim_routes найдены NULL денормализованные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 4. DAG — `airflow/dags/bookings_to_gp_dds.py`
|
||||||
|
|
||||||
|
Убрать `load_dds_dim_routes` из параллельного fan-out, добавить зависимость от DQ airports и airplanes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Было:
|
||||||
|
dq_dds_dim_calendar >> [
|
||||||
|
load_dds_dim_airports,
|
||||||
|
load_dds_dim_airplanes,
|
||||||
|
load_dds_dim_tariffs,
|
||||||
|
load_dds_dim_passengers,
|
||||||
|
load_dds_dim_routes, # параллельно
|
||||||
|
]
|
||||||
|
|
||||||
|
# Стало:
|
||||||
|
dq_dds_dim_calendar >> [
|
||||||
|
load_dds_dim_airports,
|
||||||
|
load_dds_dim_airplanes,
|
||||||
|
load_dds_dim_tariffs,
|
||||||
|
load_dds_dim_passengers,
|
||||||
|
]
|
||||||
|
|
||||||
|
# dim_routes зависит от airports и airplanes (денормализация).
|
||||||
|
[dq_dds_dim_airports, dq_dds_dim_airplanes] >> load_dds_dim_routes
|
||||||
|
```
|
||||||
|
|
||||||
|
Транзитивная зависимость от calendar сохраняется через airports/airplanes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 5. Витрина — `sql/dm/route_performance_load.sql`
|
||||||
|
|
||||||
|
Упростить Шаг 3. Было 4 JOIN → станет 1 JOIN:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
FROM tmp_route_metrics m
|
||||||
|
JOIN dds.dim_routes r_curr
|
||||||
|
ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
SELECT использует `r_curr.departure_airport AS departure_airport_bk`, `r_curr.departure_city`, `r_curr.airplane_code AS airplane_bk`, `r_curr.airplane_model`, `r_curr.total_seats`. Добавить учебный комментарий: «Благодаря денормализации dim_routes — один JOIN вместо четырёх.»
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 6. Тесты — `tests/test_dags_smoke.py`
|
||||||
|
|
||||||
|
Добавить в `test_bookings_to_gp_dds_dag_structure`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dim_routes зависит от airports и airplanes (денормализация).
|
||||||
|
_assert_reachable(dag, "dq_dds_dim_airports", "load_dds_dim_routes")
|
||||||
|
_assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_dim_routes")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 7. Документация — `docs/internal/db_schema.md`
|
||||||
|
|
||||||
|
Обновить описание dim_routes: добавить 4 новые колонки, пометить «денормализовано из dim_airports/dim_airplanes». Обновить граф зависимостей DAG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ меняется
|
||||||
|
|
||||||
|
- **fact_flight_sales_load.sql** — факт резолвит SK через свои JOIN-ы. Без изменений.
|
||||||
|
- **passenger_loyalty, monthly_overview** — используют только `route_bk` из dim_routes.
|
||||||
|
- **airport_traffic** — вообще не джойнит dim_routes.
|
||||||
|
- **hashdiff** — остаётся прежним (только атрибуты из ods.routes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
1. `make ddl-gp` — накатить DDL (ALTER TABLE добавит колонки)
|
||||||
|
2. Запустить DAG `bookings_to_gp_dds` — проверить:
|
||||||
|
- airports/airplanes грузятся ДО routes (Graph view)
|
||||||
|
- Фаза 3 (refresh) заполняет departure_city, arrival_city, airplane_model, total_seats
|
||||||
|
- DQ проходит без ошибок
|
||||||
|
3. Запустить DAG `bookings_to_gp_dm` — проверить:
|
||||||
|
- route_performance загружается с 1 JOIN
|
||||||
|
- Витрина содержит корректные города и модели самолётов
|
||||||
|
4. `make test` — smoke-тесты DAG проходят (включая новые assert'ы)
|
||||||
|
5. SQL-проверка:
|
||||||
|
```sql
|
||||||
|
SELECT route_bk, departure_city, arrival_city, airplane_model, total_seats
|
||||||
|
FROM dds.dim_routes WHERE valid_to IS NULL LIMIT 5;
|
||||||
|
```
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# План ревизии документации
|
||||||
|
|
||||||
|
> Статус: **ЧЕРНОВИК v3** | Дата: 2026-03-10
|
||||||
|
> Контекст: перед Этапом 2 (подготовка main) нужно навести порядок в docs/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблемы
|
||||||
|
|
||||||
|
- `docs/internal/` — свалка: дизайн-документы, планы, ревью, баг-трекеры, стандарты
|
||||||
|
- 3 архивных плана лежат рядом с живыми документами (неотличимы)
|
||||||
|
- 3 осиротевших документа (никто не ссылается)
|
||||||
|
- `educational-tasks.md` в корне — устарел (раздел 2.2 говорит «ODS/DDS/DM будут позже»)
|
||||||
|
- 5 документов содержат устаревшие фрагменты
|
||||||
|
- `docs/README.md` не знает про несколько живых документов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- **Архив замораживается.** Файлы в `docs/archive/` не правим — ссылки внутри них
|
||||||
|
могут быть битыми, это ожидаемо. Они сохраняются как исторические артефакты.
|
||||||
|
- **Активные планы** живут в `docs/plans/`, после выполнения переезжают в `docs/archive/`.
|
||||||
|
- **Студенческий entry point** не должен исчезать: пока `analyst_spec.md` (Этап 3)
|
||||||
|
не создан, в `docs/assignment/` будет заглушка `README.md` со ссылкой на эталонный
|
||||||
|
срез для самостоятельного изучения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 1. Структура каталогов
|
||||||
|
|
||||||
|
Создать новые каталоги:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/design/ — дизайн-документы, стандарты, архитектура
|
||||||
|
docs/reference/ — техническая справка, баг-трекеры, бенчмарки
|
||||||
|
docs/plans/ — активные планы работ
|
||||||
|
docs/archive/ — выполненные планы, закрытые ревью
|
||||||
|
docs/assignment/ — заглушка README.md (подготовка для Этапа 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 2. Перемещение файлов
|
||||||
|
|
||||||
|
### В `docs/archive/` (4 файла)
|
||||||
|
|
||||||
|
| Откуда | Файл | Причина |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| корень | `educational-tasks.md` | Устарел, заменён `assignment_design.md` |
|
||||||
|
| `docs/internal/` | `stg_naming_unification_plan.md` | Выполнен |
|
||||||
|
| `docs/internal/` | `dim_routes_denormalization_plan.md` | Выполнен |
|
||||||
|
| `docs/internal/` | `bookings_stg_code_review.md` | Все замечания закрыты |
|
||||||
|
|
||||||
|
### В `docs/plans/` (1 файл)
|
||||||
|
|
||||||
|
| Откуда | Файл | Примечание |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| `docs/internal/` | `docs_restructuring_plan.md` | Этот план — активный; после выполнения → `docs/archive/` |
|
||||||
|
|
||||||
|
### В `docs/design/` (9 файлов из `docs/internal/`)
|
||||||
|
|
||||||
|
| Файл | Роль |
|
||||||
|
|------|------|
|
||||||
|
| `PRD.md` | Стратегия продукта |
|
||||||
|
| `assignment_design.md` | Дизайн курсового задания |
|
||||||
|
| `naming_conventions.md` | Стандарт нейминга (единый источник) |
|
||||||
|
| `db_schema.md` | Схема БД всех слоёв DWH |
|
||||||
|
| `bookings_stg_design.md` | Дизайн STG-слоя |
|
||||||
|
| `bookings_ods_design.md` | Дизайн ODS-слоя |
|
||||||
|
| `bookings_dds_design.md` | Дизайн DDS-слоя |
|
||||||
|
| `bookings_dm_design.md` | Дизайн DM-слоя |
|
||||||
|
| `architecture_review.md` | Архитектурные решения (ADR) |
|
||||||
|
|
||||||
|
### В `docs/reference/` (5 файлов из `docs/internal/`)
|
||||||
|
|
||||||
|
| Файл | Роль |
|
||||||
|
|------|------|
|
||||||
|
| `pxf_bookings.md` | PXF: настройка, проблемы |
|
||||||
|
| `bookings_tz.md` | Источник bookings-db |
|
||||||
|
| `bookings_db_issues.md` | Известные проблемы bookings-db |
|
||||||
|
| `bookings_generation_benchmark.md` | Бенчмарк генерации |
|
||||||
|
| `qa-plan.md` | План отладки пайплайна |
|
||||||
|
|
||||||
|
**Итог:** `docs/internal/` опустеет → удалить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 3. Обновление перекрёстных ссылок
|
||||||
|
|
||||||
|
### Категория A. Корневые и публичные файлы (ссылаются на `docs/internal/`)
|
||||||
|
|
||||||
|
| Файл | Ссылок | Детали замен |
|
||||||
|
|------|--------|------|
|
||||||
|
| `AGENTS.md` | 2 | строки 15, 45: `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `TODO.md` | 6 | строки 6, 7, 43: → `docs/design/`; строка 121: → `docs/reference/pxf_bookings.md`; строка 128: → `docs/reference/bookings_db_issues.md`; строка 141: → `docs/archive/dim_routes_denormalization_plan.md` |
|
||||||
|
| `docs/README.md` | 7 | строки 18-24: все `internal/*` → `design/*` или `reference/*` |
|
||||||
|
| `docs/bookings_to_gp_stage.md` | 2 | строка 151: → `reference/pxf_bookings.md`; строка 156: → `archive/bookings_stg_code_review.md` |
|
||||||
|
| `docs/stack.md` | 1 | строка 85: → `reference/pxf_bookings.md` |
|
||||||
|
|
||||||
|
### Категория B. Файлы внутри `docs/design/` и `docs/reference/`
|
||||||
|
|
||||||
|
Полный реестр ссылок с `docs/internal/` в файлах, которые переедут в `design/` или
|
||||||
|
`reference/`. Все требуют обновления — либо кросс-каталожные пути, либо display-тексты.
|
||||||
|
|
||||||
|
**B1. Кросс-каталожные ссылки (ссылка ведёт в другой каталог — путь сломается):**
|
||||||
|
|
||||||
|
| Файл (→ design/) | Строка | Ссылка | Новый путь |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| `db_schema.md` | 426 | `bookings_tz.md` (отн.) | `../reference/bookings_tz.md` |
|
||||||
|
| `db_schema.md` | 427 | `pxf_bookings.md` (отн.) | `../reference/pxf_bookings.md` |
|
||||||
|
| `db_schema.md` | 425 | `bookings_stg_code_review.md` (отн.) | `../archive/bookings_stg_code_review.md` |
|
||||||
|
| `bookings_stg_design.md` | 5 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
|
||||||
|
| `bookings_stg_design.md` | 130 | `docs/internal/bookings_tz.md` | `../reference/bookings_tz.md` |
|
||||||
|
| `bookings_stg_design.md` | 131 | `docs/internal/pxf_bookings.md` | `../reference/pxf_bookings.md` |
|
||||||
|
|
||||||
|
| Файл (→ reference/) | Строка | Ссылка | Новый путь |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| `qa-plan.md` | 10 | `docs/internal/bookings_db_issues.md` | `bookings_db_issues.md` (тот же каталог) |
|
||||||
|
|
||||||
|
**B2. Внутрикаталожные, но с полным путём `docs/internal/...` (путь не сломается
|
||||||
|
для ссылок с относительным target, но display-текст устареет):**
|
||||||
|
|
||||||
|
| Файл (→ design/) | Строки | Что обновить |
|
||||||
|
|------|--------|------|
|
||||||
|
| `PRD.md` | 209 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `bookings_ods_design.md` | 48 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `bookings_dds_design.md` | 8 | `docs/internal/bookings_ods_design.md` → `docs/design/bookings_ods_design.md` |
|
||||||
|
| `bookings_dds_design.md` | 185 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `bookings_dds_design.md` | 828-829 | `docs/internal/bookings_dds_design.md`, `docs/internal/db_schema.md` → `docs/design/...` |
|
||||||
|
| `bookings_dds_design.md` | 945 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
|
||||||
|
| `bookings_dm_design.md` | 279 | `docs/internal/db_schema.md` → `docs/design/db_schema.md` |
|
||||||
|
| `bookings_dm_design.md` | 283, 291 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
|
||||||
|
| `bookings_dm_design.md` | 395 | `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `db_schema.md` | 26 | display-текст `docs/internal/naming_conventions.md` → `docs/design/naming_conventions.md` |
|
||||||
|
| `db_schema.md` | 421-423 | display-тексты `docs/internal/bookings_*_design.md` → `docs/design/...` |
|
||||||
|
| `architecture_review.md` | 70 | `docs/internal/bookings_dm_design.md` → `docs/design/bookings_dm_design.md` |
|
||||||
|
| `architecture_review.md` | 146 | `docs/internal/distribution_strategy.md` → **удалить путь** (файл не существует, оставить как текстовый backlog-пункт без ссылки) |
|
||||||
|
|
||||||
|
### Категория C. Ссылки на `educational-tasks.md`
|
||||||
|
|
||||||
|
| Файл | Действие |
|
||||||
|
|------|----------|
|
||||||
|
| `README.md` (корень) | Заменить ссылку на `educational-tasks.md` → `docs/assignment/` |
|
||||||
|
| `docs/README.md` | Заменить ссылку на `educational-tasks.md` → `assignment/` |
|
||||||
|
|
||||||
|
### Категория D. Файлы в `docs/archive/` — НЕ ТРОГАЕМ
|
||||||
|
|
||||||
|
Архивные файлы замораживаются. Ссылки внутри них могут быть битыми — это ожидаемо.
|
||||||
|
|
||||||
|
### Комментарий в SQL
|
||||||
|
|
||||||
|
`sql/dm/sales_report_ddl.sql` строка 52: `naming_conventions.md` (без пути) —
|
||||||
|
оставить как есть (комментарий, не ссылка; путь и так неточный).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 4. Актуализация содержания
|
||||||
|
|
||||||
|
| Файл (новый путь) | Что сделать |
|
||||||
|
|------|-------------|
|
||||||
|
| `docs/design/architecture_review.md` | DM завершён (5/5 витрин), пометить выполненные P2; строка 146 — убрать путь к несуществующему `distribution_strategy.md`, оставить как текстовый backlog-пункт |
|
||||||
|
| `docs/design/db_schema.md` | Добавить DM-слой, убрать выполненный TODO |
|
||||||
|
| `TESTING.md` | Убрать артефакт «Docker-стенд не запускался» |
|
||||||
|
| `docs/dag_execution_order.md` | Добавить все 4 DDL DAG-а |
|
||||||
|
| `docs/reference/pxf_bookings.md` | Исправить нумерацию разделов (7→9→8→10 → последовательную) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 5. Обновление индексов и заглушка assignment
|
||||||
|
|
||||||
|
### `docs/README.md`
|
||||||
|
|
||||||
|
Переписать структуру: разделы по каталогам (`design/`, `reference/`, `plans/`,
|
||||||
|
`archive/`, `assignment/`). Включить ранее пропущенные документы: `db_schema.md`,
|
||||||
|
`dag_execution_order.md`, `e2e-etl-test-protocol.md`, `agent-dag-testing.md`.
|
||||||
|
|
||||||
|
### `README.md` (корень)
|
||||||
|
|
||||||
|
Заменить ссылку на `educational-tasks.md` → `docs/assignment/`, проверить остальные.
|
||||||
|
|
||||||
|
### `docs/assignment/README.md` (новый файл)
|
||||||
|
|
||||||
|
Временная заглушка:
|
||||||
|
- Указание, что курсовые задания появятся в Этапе 3
|
||||||
|
- Ссылка на эталонный срез (DAG-и STG→ODS→DDS→DM) для самостоятельного изучения
|
||||||
|
- Ссылка на `docs/design/assignment_design.md` для менторов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 6. Финальная проверка
|
||||||
|
|
||||||
|
Шаги выполняются строго по порядку:
|
||||||
|
|
||||||
|
1. [ ] `make test` — тесты проходят
|
||||||
|
2. [ ] `make lint` — стиль кода
|
||||||
|
3. [ ] Удалить пустой каталог `docs/internal/`
|
||||||
|
4. [ ] Перенести план из `docs/plans/` в `docs/archive/docs_restructuring_plan.md`
|
||||||
|
5. [ ] grep по `internal/` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
|
||||||
|
6. [ ] grep по `educational-tasks` в живых .md файлах (`rg --glob '!docs/archive/*'`) — нет битых ссылок
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок выполнения
|
||||||
|
|
||||||
|
Фазы 1→2→3 делаются вместе (иначе ссылки будут битыми).
|
||||||
|
Фаза 4 — независима, можно параллельно.
|
||||||
|
Фаза 5 — после всех перемещений.
|
||||||
|
Фаза 6 — в конце.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что это даёт следующим этапам
|
||||||
|
|
||||||
|
| Этап | Как помогает |
|
||||||
|
|------|-------------|
|
||||||
|
| **Этап 2** (подготовка main) | Чистая структура — понятно, что удалять, что оставлять |
|
||||||
|
| **Этап 3** (ТЗ от аналитика) | Готовый каталог `docs/assignment/` с заглушкой, место для `analyst_spec.md` |
|
||||||
|
| **Этап 4** (валидационный DAG) | `docs/reference/qa-plan.md` — рядом с другими справочными |
|
||||||
|
| **Этап 5** (ветка solution) | Архив отделён — не попадёт в ветку solution |
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Учебные задания по стенду
|
||||||
|
|
||||||
|
Этот документ собирает в одном месте задания для менти.
|
||||||
|
Он разбит на блоки: от архитектуры Greenplum и демо‑БД bookings до реализации аналитических слоев DWH.
|
||||||
|
|
||||||
|
Если вы только начинаете, выполняйте задания по порядку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Greenplum и модель данных (введение)
|
||||||
|
|
||||||
|
В следующих заданиях мы будем опираться на демо‑БД bookings (Postgres) и слой STG в Greenplum.
|
||||||
|
На этом этапе достаточно бегло посмотреть на структуру и понять общую идею, детальная проработка пойдёт позже.
|
||||||
|
|
||||||
|
### 1.1. Знакомство с демо‑БД bookings
|
||||||
|
|
||||||
|
1. Прочитайте `bookings/README.md` — какие сервисы и команды относятся к демобазе.
|
||||||
|
2. Поднимите стенд и выполните:
|
||||||
|
- `make up`
|
||||||
|
- `make bookings-init`
|
||||||
|
3. Подключитесь к демобазе:
|
||||||
|
- `make bookings-psql`
|
||||||
|
- посмотрите таблицы в схеме `bookings` (например, `\dt bookings.*`).
|
||||||
|
4. Найдите таблицу `bookings.bookings` и посмотрите на её структуру:
|
||||||
|
- какие типы колонок используются;
|
||||||
|
- какие поля выглядят как ключи, даты, суммы.
|
||||||
|
|
||||||
|
### 1.2. Знакомство с STG в Greenplum
|
||||||
|
|
||||||
|
1. Прочитайте `sql/stg/bookings_ddl.sql` и краткое описание потока `docs/bookings_to_gp_stage.md` (если интересно — `docs/internal/bookings_stg_design.md`).
|
||||||
|
2. Ответьте себе на вопросы:
|
||||||
|
- чем внешняя таблица `stg.bookings_ext` отличается от внутренней `stg.bookings`;
|
||||||
|
- зачем нужны тех.колонки `event_ts`, `_load_ts`, `_load_id`;
|
||||||
|
- чем слой STG отличается от итоговых витрин (DDS/DM) с точки зрения моделирования.
|
||||||
|
3. Выполните `make ddl-gp`, затем зайдите в Greenplum (`make gp-psql`) и проверьте наличие схемы и таблиц:
|
||||||
|
- `\dn` и `\dt stg.*`
|
||||||
|
- `SELECT * FROM stg.bookings LIMIT 5;` (после запуска соответствующего DAG).
|
||||||
|
|
||||||
|
### 1.3. Как генерируются учебные данные bookings
|
||||||
|
|
||||||
|
1. Откройте файл `bookings/generate_next_day.sql` и ответьте себе на вопросы:
|
||||||
|
- с какой даты начинается генерация данных (посмотрите на GUC `bookings.start_date` и переменную `v_start_cfg`);
|
||||||
|
- сколько дней генерируется при первой установке (переменная `bookings.init_days`);
|
||||||
|
- что происходит, если таблица `bookings.bookings` уже не пустая.
|
||||||
|
2. В демобазе (`make bookings-psql`) выполните:
|
||||||
|
- `SELECT min(book_date), max(book_date) FROM bookings.bookings;`
|
||||||
|
- затем запустите `make bookings-generate-day` и повторите запрос — как изменился максимальный день?
|
||||||
|
3. Откройте `sql/src/bookings_generate_day_if_missing.sql` и обратите внимание, что:
|
||||||
|
- логическая дата запуска DAG (`{{ ds }}`) не влияет на выбор дня генерации;
|
||||||
|
- скрипт всегда смотрит на `max(book_date)` и добавляет **следующий** день (или несколько стартовых дней, если база пуста).
|
||||||
|
4. Сделайте вывод: генератор всегда «шагает» по датам вперёд от максимальной даты, поэтому:
|
||||||
|
- при `make bookings-init` вы получаете готовые данные из seed-дампа (при `make bookings-generate` генератор создаст `BOOKINGS_INIT_DAYS` дней начиная с `BOOKINGS_START_DATE`);
|
||||||
|
- при последующих вызовах (`make bookings-generate-day` или DAG) добавляется ровно один новый день.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DAG bookings_to_gp_stage (заготовка заданий)
|
||||||
|
|
||||||
|
Этот DAG показывает путь данных от демо‑БД bookings в Postgres до сырого слоя STG в Greenplum.
|
||||||
|
Сейчас он уже реализован как учебный пример, а в будущем вокруг него появятся отдельные задания по моделированию DWH.
|
||||||
|
|
||||||
|
### 2.1. Что есть сейчас
|
||||||
|
|
||||||
|
1. Откройте `airflow/dags/bookings_to_gp_stage.py`.
|
||||||
|
2. Найдите в коде ссылки на SQL‑файлы:
|
||||||
|
- `sql/src/bookings_generate_day_if_missing.sql`
|
||||||
|
- `sql/stg/bookings_load.sql`
|
||||||
|
- `sql/stg/bookings_dq.sql`
|
||||||
|
3. Соотнесите шаги DAG с документом `docs/bookings_to_gp_stage.md`:
|
||||||
|
- генерация учебного дня в `bookings.bookings`;
|
||||||
|
- загрузка инкремента в `stg.bookings`;
|
||||||
|
- проверка количества строк между источником и STG.
|
||||||
|
4. Обратите внимание, как в DAG используется логическая дата запуска:
|
||||||
|
- `{{ run_id }}` используется как `_load_id` — метка загрузки в таблице `stg.bookings` для конкретного запуска;
|
||||||
|
- сами даты данных (какие дни есть в `bookings.bookings`) определяются генератором по `max(book_date)`, а не по `ds`.
|
||||||
|
|
||||||
|
На этом этапе достаточно понять общую цепочку. Детальные задания по переработке модели данных и построению ODS/DDS/DM слоёв будут добавлены позже.
|
||||||
|
|
||||||
|
### 2.2. Идеи для будущих заданий (черновик)
|
||||||
|
|
||||||
|
> Ниже — набросок задач, к которым мы вернёмся, когда базовые темы по Airflow будут освоены.
|
||||||
|
|
||||||
|
Планируемые направления:
|
||||||
|
|
||||||
|
- Спроектировать модель данных для основных сущностей демобазы bookings (рейсы, билеты, перелёты) в слоях ODS/DDS/DM.
|
||||||
|
- Реализовать слой ODS поверх STG, аккуратно работая с временными атрибутами и ключами.
|
||||||
|
- Построить витрины (DM) для типичных аналитических вопросов: загрузка рейсов, выручка по направлениям, динамика бронирований.
|
||||||
|
- Добавить DAG’и, которые используют `stg.bookings` как источник и строят следующие слои DWH.
|
||||||
|
- Расширить проверки качества данных для потоков bookings → STG → витрины.
|
||||||
|
|
||||||
|
Когда будете готовы к этим темам, вернитесь к этому разделу — он станет основой для следующего «модуля» лабораторных заданий.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Унификация нейминга служебных полей в STG
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
В STG-слое используются legacy-имена служебных полей (`batch_id`, `load_dttm`, `src_created_at_ts`), а начиная с ODS — каноничные (`_load_id`, `_load_ts`, `event_ts`). Студент видит разные имена для одного понятия. Цель — привести STG к канону, убрав расхождение.
|
||||||
|
|
||||||
|
> **Breaking change (dev-only).** Это ломающее переименование колонок. Миграционный шаг (ALTER TABLE … RENAME COLUMN) не предусмотрен. DDL-файлы используют `CREATE TABLE IF NOT EXISTS`, поэтому сами по себе они не пересоздадут существующие таблицы с новыми именами колонок. План предполагает заранее пересозданную среду (например, `make down && make up`) или ручной `DROP TABLE` / `DROP SCHEMA` перед `make ddl-gp`. Обратная совместимость не обеспечивается.
|
||||||
|
|
||||||
|
## Маппинг
|
||||||
|
|
||||||
|
| Legacy (STG сейчас) | Канон (ODS/DDS/DM) |
|
||||||
|
|---|---|
|
||||||
|
| `batch_id` | `_load_id` |
|
||||||
|
| `load_dttm` | `_load_ts` |
|
||||||
|
| `src_created_at_ts` | `event_ts` |
|
||||||
|
|
||||||
|
### Оговорка про `event_ts` в snapshot-справочниках
|
||||||
|
|
||||||
|
В транзакционных STG-таблицах (bookings, tickets, flights, segments, boarding_passes) поле `src_created_at_ts` действительно хранит время события из источника — переименование в `event_ts` семантически точно.
|
||||||
|
|
||||||
|
В snapshot-справочниках (airports, airplanes, routes, seats) это поле заполняется `now()` при загрузке, т.е. по факту это ещё одно load-time, а не время события. Тем не менее мы сохраняем единое имя `event_ts` как **учебное упрощение** — ради консистентной структуры STG-таблиц. Это зафиксировано как осознанный trade-off: единообразие важнее семантической точности в справочниках. В `naming_conventions.md` нужно добавить соответствующую оговорку (раздел 4, «Time Rule»).
|
||||||
|
|
||||||
|
## Что НЕ переименовываем
|
||||||
|
|
||||||
|
- PL/pgSQL переменная `v_batch_id` — это локальная переменная, не колонка
|
||||||
|
- Python-функция `_resolve_stg_batch_id`, переменная `stg_batch_id`, task_id `resolve_stg_batch_id` — это Python/Airflow-идентификаторы
|
||||||
|
- XCom-ключи, ссылающиеся на task_id
|
||||||
|
|
||||||
|
## Порядок выполнения
|
||||||
|
|
||||||
|
### Шаг 1: STG DDL (9 файлов)
|
||||||
|
|
||||||
|
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_ddl.sql`
|
||||||
|
|
||||||
|
В каждом: `batch_id` → `_load_id`, `load_dttm` → `_load_ts`, `src_created_at_ts` → `event_ts`.
|
||||||
|
|
||||||
|
### Шаг 2: STG Load (9 файлов)
|
||||||
|
|
||||||
|
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_load.sql`
|
||||||
|
|
||||||
|
INSERT-списки, SELECT, WHERE, комментарии — те же 3 замены.
|
||||||
|
|
||||||
|
### Шаг 3: STG DQ (9 файлов)
|
||||||
|
|
||||||
|
`sql/stg/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
|
||||||
|
|
||||||
|
WHERE-условия (`batch_id = v_batch_id` → `_load_id = v_batch_id`), RAISE-сообщения, комментарии.
|
||||||
|
|
||||||
|
### Шаг 4: ODS Load (9 файлов)
|
||||||
|
|
||||||
|
Два подтипа — обрабатывать по-разному.
|
||||||
|
|
||||||
|
#### 4a: Транзакционные таблицы (5 файлов)
|
||||||
|
|
||||||
|
`sql/ods/{bookings,tickets,flights,segments,boarding_passes}_load.sql`
|
||||||
|
|
||||||
|
SELECT из STG: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts`. Убрать лишние алиасы (`s.src_created_at_ts AS event_ts` → просто `s.event_ts`).
|
||||||
|
|
||||||
|
#### 4b: Snapshot-справочники (4 файла)
|
||||||
|
|
||||||
|
`sql/ods/{airports,airplanes,routes,seats}_load.sql`
|
||||||
|
|
||||||
|
Здесь `event_ts` отсутствует в целевой ODS-таблице — менять только ссылки на STG-колонки: `s.batch_id` → `s._load_id`, `s.load_dttm` → `s._load_ts`, `s.src_created_at_ts` → `s.event_ts` (только в ORDER BY / WHERE, где они читают из STG). ODS-колонка `_load_ts` по-прежнему заполняется через `now()`, это не меняется.
|
||||||
|
|
||||||
|
### Шаг 5: ODS DQ (9 файлов)
|
||||||
|
|
||||||
|
`sql/ods/{bookings,tickets,flights,segments,airports,airplanes,routes,seats,boarding_passes}_dq.sql`
|
||||||
|
|
||||||
|
`WHERE batch_id =` → `WHERE _load_id =`, RAISE-сообщения.
|
||||||
|
|
||||||
|
### Шаг 6: DAG-файлы (2 файла)
|
||||||
|
|
||||||
|
- `airflow/dags/bookings_to_gp_stage.py` — комментарий про `batch_id`
|
||||||
|
- `airflow/dags/bookings_to_gp_ods.py` — встроенный SQL-запрос резолвера: все `batch_id` как колонка → `_load_id`, `load_dttm` → `_load_ts`. Python-имена не трогаем.
|
||||||
|
|
||||||
|
### Шаг 7: Тесты (3 файла)
|
||||||
|
|
||||||
|
- `tests/test_ods_snapshot_integration.py` — inline DDL и INSERT в тестах
|
||||||
|
- `tests/test_dags_smoke.py` — комментарии
|
||||||
|
- `tests/test_ods_sql_contract.py` — docstring
|
||||||
|
|
||||||
|
### Шаг 8: Документация (~15 файлов)
|
||||||
|
|
||||||
|
- `docs/internal/naming_conventions.md` — убрать legacy-исключение (секция 5/STG), убрать переходный маппинг (секция 6), добавить оговорку про `event_ts` в snapshot-справочниках (секция 4)
|
||||||
|
- `docs/internal/db_schema.md` — описания STG-полей
|
||||||
|
- `docs/internal/bookings_stg_design.md` — дизайн STG
|
||||||
|
- `docs/internal/bookings_ods_design.md` — маппинг STG→ODS, SQL-примеры
|
||||||
|
- `docs/internal/qa-plan.md` — SQL-запросы проверок
|
||||||
|
- `docs/internal/architecture_review.md` — архитектурные заметки
|
||||||
|
- `docs/internal/bookings_stg_code_review.md` — код-ревью
|
||||||
|
- `docs/bookings_to_gp_stage.md` — описание STG DAG, примеры полей
|
||||||
|
- `docs/bookings_to_gp_ods.md` — описание ODS DAG
|
||||||
|
- `docs/dag_execution_order.md` — порядок выполнения DAG
|
||||||
|
- `educational-tasks.md` — учебные задания
|
||||||
|
- `README.md` — SQL-примеры в README
|
||||||
|
- `TESTING.md` — чек-лист тестирования
|
||||||
|
|
||||||
|
### Шаг 9: Верификация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Проверить SQL и Python — не должно быть колонок batch_id
|
||||||
|
# (допустимы только: v_batch_id, stg_batch_id, resolve_stg_batch_id)
|
||||||
|
grep -rn 'batch_id' sql/stg/ sql/ods/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||||
|
|
||||||
|
# 2. Ноль совпадений по старым именам в коде
|
||||||
|
grep -rn 'load_dttm' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||||
|
grep -rn 'src_created_at_ts' sql/ airflow/dags/ tests/ --include='*.sql' --include='*.py'
|
||||||
|
|
||||||
|
# 3. Проверить документацию — не должно быть старых имён как актуальных
|
||||||
|
# (допустимы упоминания в историческом контексте)
|
||||||
|
grep -rn 'batch_id\|load_dttm\|src_created_at_ts' docs/ educational-tasks.md README.md TESTING.md
|
||||||
|
|
||||||
|
# 4. Тесты и линтер
|
||||||
|
make test
|
||||||
|
make fmt && make lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Итого: ~55 файлов, ~3 механические замены в каждом
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# План: выравнивание качества документации ETL DAG'ов
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Документ `bookings_to_gp_stage.md` — эталон: пошаговый разбор задач, ASCII-граф,
|
||||||
|
ссылки на SQL-файлы, описание edge cases. Три остальных документа
|
||||||
|
(`_ods`, `_dds`, `_dm`) значительно беднее. Цель — подтянуть их до того же уровня.
|
||||||
|
|
||||||
|
## Единый шаблон секций (целевая структура)
|
||||||
|
|
||||||
|
Каждый документ должен содержать:
|
||||||
|
|
||||||
|
1. **Заголовок + вводный абзац** (что за DAG, какой слой, зачем)
|
||||||
|
2. **Что делает DAG** (буллеты, краткое описание)
|
||||||
|
3. **Что должно быть готово** (prerequisites)
|
||||||
|
4. **Как запустить** (UI + опциональные параметры)
|
||||||
|
5. **Граф зависимостей** (ASCII-диаграмма, не буллет-лист)
|
||||||
|
6. **Как это работает внутри (по шагам)** ← ГЛАВНОЕ ДОБАВЛЕНИЕ
|
||||||
|
- Пронумерованные шаги: task_id → SQL-файл → что делает → паттерн (SCD1/SCD2/rebuild/HWM)
|
||||||
|
- Учебные пояснения к нетривиальным паттернам
|
||||||
|
7. **Как проверить результат** (SQL-запросы)
|
||||||
|
8. **Типичные ошибки** (уже есть, оставляем)
|
||||||
|
|
||||||
|
## Что именно добавить/исправить в каждом документе
|
||||||
|
|
||||||
|
### A. `bookings_to_gp_ods.md`
|
||||||
|
|
||||||
|
**Текущее состояние:** 95 строк, нет пошагового разбора, нет ASCII-графа, нет SQL-путей.
|
||||||
|
|
||||||
|
| # | Что сделать | Детали |
|
||||||
|
|---|-------------|--------|
|
||||||
|
| A1 | ASCII-граф зависимостей | Заменить буллет-лист на диаграмму (как в stage). Показать параллельные ветки airports/airplanes, схождение на routes/seats, цепочку flights→segments→boarding_passes |
|
||||||
|
| A2 | Секция "Как это работает внутри" | 10 шагов: resolve_stg_batch_id (Python, INTERSECT-логика), затем 9 пар load→dq с указанием SQL-файлов |
|
||||||
|
| A3 | Пояснить паттерн SCD1 UPSERT | Кратко: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT. Одного абзаца достаточно, потом ссылка "паттерн одинаков для всех 9 таблиц" |
|
||||||
|
| A4 | Описать разницу snapshot vs HWM | Snapshot-справочники фильтруются по `stg_batch_id`; транзакционные таблицы — по HWM (`_load_ts`). Объяснить почему (чтобы не терять инкременты при повторных запусках STG) |
|
||||||
|
| A5 | Edge case: пустой батч | Для инкрементальных таблиц допустим; для snapshot — нет |
|
||||||
|
|
||||||
|
**Ожидаемый объём:** ~140–160 строк.
|
||||||
|
|
||||||
|
### B. `bookings_to_gp_dds.md`
|
||||||
|
|
||||||
|
**Текущее состояние:** 73 строки — самый бедный документ. Нет пошаговости, нет ASCII-графа, SCD2 не объяснён.
|
||||||
|
|
||||||
|
| # | Что сделать | Детали |
|
||||||
|
|---|-------------|--------|
|
||||||
|
| B1 | ASCII-граф зависимостей | calendar → параллельно 4 SCD1-измерения + dim_routes (после airports+airplanes) → fact (после всех dims) → summary |
|
||||||
|
| B2 | Секция "Как это работает внутри" | 8 шагов: calendar (rebuild), 4×SCD1-измерения, dim_routes (SCD2), fact_flight_sales, summary |
|
||||||
|
| B3 | Объяснить SCD2 для dim_routes | Учебный блок: hashdiff (MD5), закрытие старых версий, вставка новых, point-in-time valid_from. Это ключевой паттерн DDS — заслуживает 10–15 строк |
|
||||||
|
| B4 | Объяснить Phase 3 (денормализация) | Обновление SCD1-атрибутов (города, модель) во ВСЕХ версиях dim_routes. Зачем: чтобы не хранить устаревшие названия городов |
|
||||||
|
| B5 | Объяснить late-arriving dimensions | LEFT JOIN в fact_flight_sales: факт может прийти раньше справочника. 3–5 строк |
|
||||||
|
| B6 | Объяснить point-in-time join | Как факт привязывается к правильной версии SCD2-маршрута по дате рейса |
|
||||||
|
| B7 | Edge case: генерация SK | MAX() + ROW_NUMBER() безопасна только при concurrency=1 |
|
||||||
|
|
||||||
|
**Ожидаемый объём:** ~160–180 строк.
|
||||||
|
|
||||||
|
### C. `bookings_to_gp_dm.md`
|
||||||
|
|
||||||
|
**Текущее состояние:** 80 строк. ASCII-граф есть (хорошо!), описание стратегий загрузки есть (хорошо!), но нет пошагового разбора с SQL-файлами.
|
||||||
|
|
||||||
|
| # | Что сделать | Детали |
|
||||||
|
|---|-------------|--------|
|
||||||
|
| C1 | Секция "Как это работает внутри" | 5 шагов (по одному на витрину) + start_dm + finish_dm_summary. Для каждой витрины: task_id → SQL-файл → паттерн → зерно (grain) |
|
||||||
|
| C2 | Расширить описание sales_report | HWM-паттерн: какие даты пересчитываются, NULLIF-guard для boarding_rate, автоматическая "догонка" при первичной загрузке |
|
||||||
|
| C3 | Расширить описание route_performance | Почему Full Rebuild: AO Column (нет UPDATE/DELETE), таблица маленькая. 3-шаговый паттерн: TRUNCATE → агрегация по route_bk → JOIN с текущей версией SCD2 |
|
||||||
|
| C4 | Добавить grain для каждой витрины | sales_report: (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk); route_performance: route_bk; и т.д. |
|
||||||
|
| C5 | Добавить SQL-пути к задачам | Сейчас нигде не указаны пути к SQL-файлам |
|
||||||
|
|
||||||
|
**Ожидаемый объём:** ~140–160 строк.
|
||||||
|
|
||||||
|
### D. Мелкие правки в `bookings_to_gp_stage.md` (опционально)
|
||||||
|
|
||||||
|
| # | Что сделать | Детали |
|
||||||
|
|---|-------------|--------|
|
||||||
|
| D1 | Убедиться в консистентности шаблона | Если в ходе работы над ODS/DDS/DM выработается чуть лучшая структура — привести stage к тому же формату (только структурные правки, контент не менять) |
|
||||||
|
|
||||||
|
## Порядок работы
|
||||||
|
|
||||||
|
1. **ODS** (средняя сложность, знакомый паттерн SCD1)
|
||||||
|
2. **DDS** (наибольшая учебная ценность — SCD2, late-arriving dims)
|
||||||
|
3. **DM** (наименьший объём правок — ASCII-граф уже есть)
|
||||||
|
4. **Stage** — только если нужна косметика для консистентности
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Не раздувать: целевой объём каждого документа — 140–180 строк (stage = 157).
|
||||||
|
- Учебная ценность > полнота: объяснять «почему», а не перечислять все колонки.
|
||||||
|
- SQL-пути обязательны — это главный навигационный инструмент для студента.
|
||||||
|
- Один паттерн объясняем один раз подробно, дальше ссылаемся: "паттерн аналогичен X".
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# Plan: Перенос STG в эталон, ODS airplanes+seats — студенту
|
||||||
|
|
||||||
|
> Версия: 4 (после третьего ревью ChatGPT 5.4)
|
||||||
|
> Дата: 2026-03-11
|
||||||
|
|
||||||
|
> **Статус выполнения (2026-03-12):**
|
||||||
|
> - Секция «Сейчас (на chore/bookings-etl)» — ✅ выполнена (п.1, п.6, п.7, п.9)
|
||||||
|
> - Секция «Этап 3 (валидационный DAG)» — ✅ выполнена (см. `docs/archive/2026-03-12_validation-dag.md`)
|
||||||
|
> - Секция «Этап 4 (подготовка main + solution)» — ⬜ не выполнена.
|
||||||
|
> Детальные спецификации кода (п.2–5) и документации (п.8, п.10–11) остаются
|
||||||
|
> актуальным справочником. Порядок выполнения и стратегия веток —
|
||||||
|
> в новом плане `docs/plans/2026-03-12_main-solution-split.md`.
|
||||||
|
|
||||||
|
## Контекст и мотивация
|
||||||
|
|
||||||
|
### Обнаруженная проблема
|
||||||
|
|
||||||
|
При подготовке к Этапу 4 (раскладка по веткам main / solution) обнаружен **конфликт
|
||||||
|
между текущим дизайном БД** (`docs/design/db_schema.md`, `docs/design/bookings_dds_design.md`)
|
||||||
|
**и планом курсового задания** (`docs/design/assignment_design.md`).
|
||||||
|
|
||||||
|
Суть конфликта: эталонный пайплайн (который должен работать «из коробки» на main-ветке
|
||||||
|
для студента) **неработоспособен**, если студент ещё не реализовал ни одного задания.
|
||||||
|
Три DDS-измерения (`dim_routes`, `dim_passengers`, `dim_airplanes`) отнесены к заданию
|
||||||
|
студента, но эталонная фактовая таблица `fact_flight_sales` зависит от них через
|
||||||
|
цепочку LEFT JOIN. Без этих измерений факт загружается с массовыми NULL в ключевых FK,
|
||||||
|
а DQ-проверки блокируют pipeline. Эталонные витрины (`sales_report`, `airport_traffic`)
|
||||||
|
становятся бесполезными.
|
||||||
|
|
||||||
|
Проблема усугубляется архитектурой batch resolver ODS DAG и тестовыми контрактами,
|
||||||
|
которые требуют данных во **всех** snapshot-таблицах STG.
|
||||||
|
|
||||||
|
Ниже — детальный разбор и выбранное решение.
|
||||||
|
|
||||||
|
### Фактовая таблица: зависимость от студенческих измерений
|
||||||
|
|
||||||
|
`fact_flight_sales` зависит от **всех 7 измерений** DDS,
|
||||||
|
включая 3 студенческих: `dim_routes`, `dim_passengers`, `dim_airplanes`.
|
||||||
|
|
||||||
|
Критическая цепочка: аэропорты в факте разрешаются **через `dim_routes`**:
|
||||||
|
|
||||||
|
```
|
||||||
|
flt.route_no → dim_routes.route_bk → rte.departure_airport → dim_airports.airport_bk → airport_sk
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `dim_routes` пуст (студент ещё не реализовал), то **NULL** получают не только
|
||||||
|
`route_sk`, но и оба `airport_sk` и `airplane_sk`. Эталонная витрина `sales_report`
|
||||||
|
становится бесполезной.
|
||||||
|
|
||||||
|
Причина: `ods.flights` содержит только `route_no`, но не `departure_airport` /
|
||||||
|
`arrival_airport` / `aircraft_code`. Эти поля доступны только через таблицу `routes`.
|
||||||
|
|
||||||
|
### Почему нельзя просто использовать DDL-заглушки
|
||||||
|
|
||||||
|
Если `dim_routes` — пустая заглушка, все LEFT JOIN через неё возвращают NULL.
|
||||||
|
Факт загружается, но:
|
||||||
|
|
||||||
|
- `departure_airport_sk = NULL` → `sales_report` не может группировать по аэропортам
|
||||||
|
- `arrival_airport_sk = NULL` → `airport_traffic` не может считать трафик
|
||||||
|
- UPDATE факта **не перезаписывает SK** (by design, строка 4-5 load-скрипта) →
|
||||||
|
NULL-и остаются навсегда до TRUNCATE + перезагрузки
|
||||||
|
|
||||||
|
### Дополнительные архитектурные ограничения (из ревью)
|
||||||
|
|
||||||
|
1. **Fact DQ блокирует загрузку** (`fact_flight_sales_dq.sql`):
|
||||||
|
- Строка 71: `passenger_sk IS NULL` → **hard fail** (100% NULL → exception)
|
||||||
|
- Строка 98: `route_sk / airplane_sk IS NULL` → при >1% строк → **exception**
|
||||||
|
- На main с пустыми студенческими dims — 100% NULL → pipeline падает
|
||||||
|
|
||||||
|
2. **Batch resolver ODS DAG** (`bookings_to_gp_ods.py:59-77`):
|
||||||
|
- `INTERSECT` по `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`
|
||||||
|
- Если **любая** STG-таблица пуста — общего batch нет → весь ODS pipeline падает
|
||||||
|
- Контракт зафиксирован тестом `test_ods_sql_contract.py:30`
|
||||||
|
|
||||||
|
### Выбранное решение
|
||||||
|
|
||||||
|
**Весь STG-слой — эталонный** (все 9 таблиц загружаются reference-кодом).
|
||||||
|
Это гарантирует:
|
||||||
|
- Batch resolver всегда находит согласованный batch (все 4 snapshot-таблицы заполнены)
|
||||||
|
- DAG-зависимости и smoke-тесты STG не требуют изменений
|
||||||
|
- Никакого расхождения batch-контракта между main и solution
|
||||||
|
|
||||||
|
**ODS: `airplanes` + `seats` остаются заданием студента** (TRUNCATE+INSERT практика).
|
||||||
|
|
||||||
|
**DDS: `dim_airplanes`, `dim_passengers`, `dim_routes`** — задание студента (DDL-заглушки).
|
||||||
|
|
||||||
|
**Fact lookup**: аэропорты через `ods.routes` (эталон); `airplane_sk` остаётся
|
||||||
|
point-in-time через `dim_routes` (NULL на main — допустимо).
|
||||||
|
|
||||||
|
**Fact DQ**: на main ослабить проверки студенческих SK.
|
||||||
|
|
||||||
|
### Что теряет студент (и почему это приемлемо)
|
||||||
|
|
||||||
|
| Потеря | Компенсация |
|
||||||
|
|--------|-------------|
|
||||||
|
| STG-практика (PXF external tables) | Изучает эталонный STG-код; отдельный PXF-практикум в бэклоге (`assignment_design.md`, раздел 6) |
|
||||||
|
| `ods.routes` (TRUNCATE+INSERT) | Остаются `ods.airplanes` + `ods.seats` — тот же паттерн |
|
||||||
|
|
||||||
|
Ключевые элементы задания **сохранены**:
|
||||||
|
- ODS: 2 таблицы (airplanes, seats) — практика TRUNCATE+INSERT
|
||||||
|
- DDS: `dim_airplanes` (SCD1), `dim_passengers` (SCD1), **`dim_routes` (SCD2)** — ключевой вызов
|
||||||
|
- DM: 4 витрины разной сложности (от простой к сложной)
|
||||||
|
|
||||||
|
### Пересчёт факта после реализации студентом
|
||||||
|
|
||||||
|
После реализации всех измерений студенту нужно:
|
||||||
|
1. Запустить загрузку DDS-измерений (dim_airplanes, dim_passengers, dim_routes)
|
||||||
|
2. `TRUNCATE dds.fact_flight_sales;`
|
||||||
|
3. Перезапустить загрузку факта → теперь все SK заполнены
|
||||||
|
4. Перезапустить DM-витрины
|
||||||
|
|
||||||
|
Это стандартная практика при late-arriving dimensions и хорошая обучающая точка.
|
||||||
|
|
||||||
|
### airport_traffic — студенческое задание
|
||||||
|
|
||||||
|
`airport_traffic` архитектурно не зависит от студенческих dims (использует только
|
||||||
|
`calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`). Но это **не значит**,
|
||||||
|
что витрина готова: SQL витрины пишет студент. Данные в факте есть (эталон),
|
||||||
|
студент пишет агрегирующий SQL. Статус: **задание студента** (как и остальные 3 DM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Матрица зависимостей DM-витрин
|
||||||
|
|
||||||
|
| DM витрина | calendar | airports | tariffs | routes | passengers | airplanes | Работает без студ. dims? |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `sales_report` (эталон) | + | + | + | — | — | — | **ДА** |
|
||||||
|
| `airport_traffic` (студент) | + | + | — | — | — | — | Данные есть, SQL пишет студент |
|
||||||
|
| `route_performance` (студент) | + | — | — | **+** | — | — | Нет |
|
||||||
|
| `monthly_overview` (студент) | + | — | — | **+** | **+** | **+** | Нет |
|
||||||
|
| `passenger_loyalty` (студент) | + | — | + | **+** | **+** | — | Нет |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменения (код)
|
||||||
|
|
||||||
|
### 1. `sql/dds/fact_flight_sales_load.sql` — lookup аэропортов через ods.routes
|
||||||
|
|
||||||
|
**Ветка:** chore/bookings-etl (сейчас) + main (Этап 4)
|
||||||
|
|
||||||
|
**Текущее состояние** (строки 51-62):
|
||||||
|
```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)
|
||||||
|
...
|
||||||
|
LEFT JOIN dds.dim_airports AS dep
|
||||||
|
ON dep.airport_bk = rte.departure_airport -- через dim_routes!
|
||||||
|
LEFT JOIN dds.dim_airports AS arr
|
||||||
|
ON arr.airport_bk = rte.arrival_airport -- через dim_routes!
|
||||||
|
LEFT JOIN dds.dim_airplanes AS ap
|
||||||
|
ON ap.airplane_bk = rte.airplane_code -- через dim_routes!
|
||||||
|
```
|
||||||
|
|
||||||
|
**После изменения:**
|
||||||
|
```sql
|
||||||
|
-- Учебный комментарий: Airport lookup через ods.routes (эталонный справочник),
|
||||||
|
-- а не через dds.dim_routes (студенческое задание SCD2).
|
||||||
|
-- Это архитектурное решение: эталонный пайплайн работает независимо от студенческого кода.
|
||||||
|
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
|
||||||
|
-- Аэропорты вылета/прилёта одинаковы во всех версиях одного route_no.
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT route_no, departure_airport, arrival_airport
|
||||||
|
FROM (
|
||||||
|
SELECT route_no, departure_airport, arrival_airport,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
|
||||||
|
FROM ods.routes
|
||||||
|
) ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
) AS ods_rte ON ods_rte.route_no = flt.route_no
|
||||||
|
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)
|
||||||
|
...
|
||||||
|
LEFT JOIN dds.dim_airports AS dep
|
||||||
|
ON dep.airport_bk = ods_rte.departure_airport -- через ods.routes (эталон)
|
||||||
|
LEFT JOIN dds.dim_airports AS arr
|
||||||
|
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
|
||||||
|
LEFT JOIN dds.dim_airplanes AS ap
|
||||||
|
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему аэропорты через ods.routes, а airplane через dim_routes:**
|
||||||
|
|
||||||
|
- **Аэропорты**: `departure_airport` и `arrival_airport` одинаковы во всех версиях
|
||||||
|
одного `route_no` (маршрут SVO→LED всегда SVO→LED). Безопасно брать из ODS.
|
||||||
|
Это даёт эталонным витринам (`sales_report`, `airport_traffic`) корректные `airport_sk`
|
||||||
|
даже без студенческого `dim_routes`.
|
||||||
|
|
||||||
|
- **Самолёт**: `airplane_code` теоретически может отличаться между версиями маршрута.
|
||||||
|
Текущий контракт DDS (`bookings_dds_design.md:499`) фиксирует, что `airplane_sk`
|
||||||
|
приходит из **той же point-in-time версии** маршрута, что и `route_sk`.
|
||||||
|
Менять эту семантику — изменение модели данных, а не стабилизация.
|
||||||
|
На main `airplane_sk` будет NULL (dim_routes — заглушка). Это допустимо:
|
||||||
|
ни `sales_report`, ни `airport_traffic` не используют `airplane_sk`.
|
||||||
|
|
||||||
|
### 2. `sql/dds/fact_flight_sales_dq.sql` — ослабить проверки студенческих SK (только main)
|
||||||
|
|
||||||
|
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
|
||||||
|
|
||||||
|
Изменения:
|
||||||
|
|
||||||
|
- **Строки 65-75** (`passenger_sk IS NULL`): заменить `RAISE EXCEPTION` на `RAISE NOTICE`.
|
||||||
|
На main `dim_passengers` — заглушка, 100% строк будут с `passenger_sk IS NULL`.
|
||||||
|
Любой порог (даже >1%) даст exception. **Только логирование, без блокировки.**
|
||||||
|
|
||||||
|
- **Строки 89-108** (route-related FK): разделить на два блока:
|
||||||
|
|
||||||
|
**Блок A — эталонные FK (проверка с порогом 1%, как calendar_sk):**
|
||||||
|
```sql
|
||||||
|
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
|
||||||
|
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
|
||||||
|
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
|
||||||
|
SELECT COUNT(*) INTO v_null_airport
|
||||||
|
FROM dds.fact_flight_sales
|
||||||
|
WHERE departure_airport_sk IS NULL OR arrival_airport_sk IS NULL;
|
||||||
|
|
||||||
|
IF v_null_airport > 0 THEN
|
||||||
|
IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ FAILED: NULL airport_sk: % (>1%%)', v_null_airport;
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)', v_null_airport;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Блок B — студенческие FK (только логирование, RAISE NOTICE):**
|
||||||
|
```sql
|
||||||
|
-- route_sk и airplane_sk будут NULL, пока студент не реализует dim_routes.
|
||||||
|
-- Не блокируем pipeline.
|
||||||
|
SELECT COUNT(*) INTO v_null_student
|
||||||
|
FROM dds.fact_flight_sales
|
||||||
|
WHERE route_sk IS NULL OR airplane_sk IS NULL;
|
||||||
|
|
||||||
|
IF v_null_student > 0 THEN
|
||||||
|
RAISE NOTICE 'DQ INFO: студенческие SK (route/airplane) NULL: %. '
|
||||||
|
'После реализации dim_routes: TRUNCATE fact → перезагрузка.',
|
||||||
|
v_null_student;
|
||||||
|
END IF;
|
||||||
|
```
|
||||||
|
|
||||||
|
Итоговое правило на main:
|
||||||
|
- `departure_airport_sk`, `arrival_airport_sk` — **проверка с порогом 1%** (эталон, через ods.routes; >1% → EXCEPTION, <=1% → NOTICE)
|
||||||
|
- `passenger_sk`, `route_sk`, `airplane_sk` — **только RAISE NOTICE** (студенческие заглушки, 100% NULL допустимо)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Учебный комментарий: route_sk, airplane_sk и passenger_sk будут NULL,
|
||||||
|
-- пока вы не реализуете соответствующие DDS-измерения.
|
||||||
|
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
|
||||||
|
-- Полную версию DQ (с блокировкой) см. в ветке solution.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ODS Routes DQ: убрать RI-проверку airplane_code (только main)
|
||||||
|
|
||||||
|
**Ветка:** только main (Этап 4). На solution-ветке полные проверки сохраняются.
|
||||||
|
|
||||||
|
> **Почему только ODS, не STG?** Весь STG — эталон, `stg.airplanes` всегда заполнен,
|
||||||
|
> `stg/routes_dq.sql` RI-проверка к `stg.airplanes` проходит штатно. Менять STG не нужно.
|
||||||
|
> На уровне ODS `ods.airplanes` — студенческая заглушка (пустая), поэтому
|
||||||
|
> `ods/routes_dq.sql` RI-проверка к `ods.airplanes` упадёт.
|
||||||
|
|
||||||
|
**Файл:**
|
||||||
|
- `sql/ods/routes_dq.sql` — удалить блок строк 143-156 (RI к `ods.airplanes`)
|
||||||
|
|
||||||
|
Добавить комментарий:
|
||||||
|
```sql
|
||||||
|
-- Учебный комментарий: проверка RI airplane_code → ods.airplanes не выполняется,
|
||||||
|
-- т.к. таблица ods.airplanes реализуется студентом. После реализации — раскомментируйте
|
||||||
|
-- (см. ветку solution для полной версии).
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ODS DAG-зависимость: убрать airplanes → routes (только main)
|
||||||
|
|
||||||
|
**Ветка:** только main (Этап 4)
|
||||||
|
|
||||||
|
> **Почему только ODS, не STG?** STG DAG не меняется: `stg.airplanes` — эталон,
|
||||||
|
> барьер `check_airplanes_dq >> load_routes_to_stg` работает штатно.
|
||||||
|
> На уровне ODS `dq_ods_airplanes` — заглушка (`SELECT 1;`), формально проходит,
|
||||||
|
> но зависимость вводит в заблуждение. Убираем для ясности.
|
||||||
|
|
||||||
|
`airflow/dags/bookings_to_gp_ods.py` (строка 266):
|
||||||
|
```python
|
||||||
|
# Было:
|
||||||
|
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
|
||||||
|
# Стало:
|
||||||
|
dq_ods_airports >> load_ods_routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Заглушки для студенческих файлов (только main)
|
||||||
|
|
||||||
|
**Ветка:** только main (Этап 4)
|
||||||
|
|
||||||
|
#### STG-слой: весь код остаётся (эталон), заглушки не нужны
|
||||||
|
|
||||||
|
Все 9 STG-таблиц загружаются эталонным кодом. Batch resolver работает без изменений.
|
||||||
|
|
||||||
|
#### ODS-слой: заглушки для airplanes + seats
|
||||||
|
|
||||||
|
| Файл | main (студент) | solution |
|
||||||
|
|------|----------------|----------|
|
||||||
|
| `sql/ods/airplanes_ddl.sql` | Полный DDL (таблица нужна) | Тот же |
|
||||||
|
| `sql/ods/airplanes_load.sql` | Заглушка: `SELECT 1; -- TODO` | Полная реализация |
|
||||||
|
| `sql/ods/airplanes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
|
||||||
|
| `sql/ods/seats_ddl.sql` | Полный DDL | Тот же |
|
||||||
|
| `sql/ods/seats_load.sql` | Заглушка | Полная реализация |
|
||||||
|
| `sql/ods/seats_dq.sql` | Заглушка | Полная реализация |
|
||||||
|
|
||||||
|
#### DDS-слой: заглушки для 3 измерений
|
||||||
|
|
||||||
|
| Файл | main (студент) | solution |
|
||||||
|
|------|----------------|----------|
|
||||||
|
| `sql/dds/dim_routes_ddl.sql` | Полный DDL (нужен для LEFT JOIN) | Тот же |
|
||||||
|
| `sql/dds/dim_routes_load.sql` | Заглушка: `SELECT 1; -- TODO: реализуйте SCD2` | Полная реализация |
|
||||||
|
| `sql/dds/dim_routes_dq.sql` | Заглушка: `SELECT 1;` | Полная реализация |
|
||||||
|
| `sql/dds/dim_passengers_ddl.sql` | Полный DDL | Тот же |
|
||||||
|
| `sql/dds/dim_passengers_load.sql` | Заглушка | Полная реализация |
|
||||||
|
| `sql/dds/dim_passengers_dq.sql` | Заглушка | Полная реализация |
|
||||||
|
| `sql/dds/dim_airplanes_ddl.sql` | Полный DDL | Тот же |
|
||||||
|
| `sql/dds/dim_airplanes_load.sql` | Заглушка | Полная реализация |
|
||||||
|
| `sql/dds/dim_airplanes_dq.sql` | Заглушка | Полная реализация |
|
||||||
|
|
||||||
|
#### DM-слой: заглушки для 4 витрин
|
||||||
|
|
||||||
|
Аналогично: DDL остаётся, load/dq заменяются заглушками.
|
||||||
|
Файлы: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменения (документация)
|
||||||
|
|
||||||
|
### 6. `docs/assignment/analyst_spec.md`
|
||||||
|
|
||||||
|
- **Удалить** разделы 1.1-1.3 (все STG-задания) — весь STG теперь эталон
|
||||||
|
- **Удалить** разделы 2.3 (`ods.routes`) — routes теперь эталон
|
||||||
|
- **Обновить** рекомендуемый порядок: начинается с ODS (airplanes, seats)
|
||||||
|
- **Добавить** мотивационную секцию «Почему STG уже реализован»:
|
||||||
|
- Эталонный пайплайн требует согласованного batch по всем snapshot-таблицам
|
||||||
|
- Без данных в STG невозможна загрузка ODS и далее по цепочке
|
||||||
|
- Студент изучает эталонный STG-код как образец
|
||||||
|
- **Добавить** секцию «Пересчёт факта после реализации измерений»:
|
||||||
|
- Инструкция: TRUNCATE fact + re-run после реализации всех DDS-измерений
|
||||||
|
- Педагогическая ценность: late-arriving dimensions, dependency management
|
||||||
|
|
||||||
|
### 7. `docs/design/assignment_design.md`
|
||||||
|
|
||||||
|
- **Обновить** таблицу «Эталонные таблицы»: добавить весь STG + ods.routes
|
||||||
|
- **Обновить** таблицу «Задание студенту»: убрать STG целиком, ODS оставить airplanes+seats
|
||||||
|
- **Обновить** рекомендуемый порядок: начинается с ODS
|
||||||
|
- **Обновить** структуру валидационного DAG (раздел 4): убрать `validate_stg` секцию
|
||||||
|
|
||||||
|
### 8. `docs/design/bookings_dds_design.md`
|
||||||
|
|
||||||
|
- **Строки 463, 468** (SQL-скелет факта): обновить JOIN-блок —
|
||||||
|
`dep.airport_bk` и `arr.airport_bk` теперь через `ods_rte`, а не через `rte`;
|
||||||
|
`ap.airplane_bk` остаётся через `rte` (point-in-time)
|
||||||
|
- **Строка 499-501** (текстовое пояснение): обновить —
|
||||||
|
`departure_airport_sk` и `arrival_airport_sk` берутся из `ods.routes` (эталон);
|
||||||
|
`airplane_sk` остаётся point-in-time через `dim_routes`
|
||||||
|
- **Строка 558** (passenger_sk = 0): убрать утверждение о нулевой толерантности —
|
||||||
|
на main `passenger_sk` будет 100% NULL (dim_passengers — заглушка)
|
||||||
|
- **Строка 559** (DQ текстовое описание): обновить — на main student SK
|
||||||
|
(`passenger_sk`, `route_sk`, `airplane_sk`) не блокируют pipeline;
|
||||||
|
`airport_sk` проверяются с порогом 1%
|
||||||
|
- **Строка 741** (DQ SQL-пример): обновить встроенный SQL — разделить route-related
|
||||||
|
блок на эталонный (airport_sk, порог 1%) и студенческий (route_sk, airplane_sk, NOTICE)
|
||||||
|
|
||||||
|
### 9. `docs/design/db_schema.md`
|
||||||
|
|
||||||
|
- Пометить весь STG и `ods.routes` как эталонные (не студенческие)
|
||||||
|
- Строка 3: «Все слои реализованы» — уточнить, что на main ODS airplanes/seats,
|
||||||
|
DDS student dims и DM student vitrines — заглушки
|
||||||
|
- Строка 253: DQ-контракт «SQL-скрипты с RAISE EXCEPTION» — добавить оговорку,
|
||||||
|
что на main студенческие DQ-скрипты являются заглушками
|
||||||
|
|
||||||
|
### 10. Тесты
|
||||||
|
|
||||||
|
- `tests/test_dags_smoke.py` строки 242-243:
|
||||||
|
На main убрать assert барьера `dq_ods_airplanes → dq_ods_routes` (ODS routes
|
||||||
|
не зависит от ODS airplanes на main). STG-барьеры (строки 123-124) **не трогаем**:
|
||||||
|
STG целиком эталонный, барьер `check_airplanes_dq → check_routes_dq` работает.
|
||||||
|
- `tests/test_ods_sql_contract.py` строка 6:
|
||||||
|
На main изменить `SNAPSHOT_ENTITIES` — исключить студенческие ODS:
|
||||||
|
```python
|
||||||
|
# Было:
|
||||||
|
SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats")
|
||||||
|
# Стало (main):
|
||||||
|
SNAPSHOT_ENTITIES = ("airports", "routes")
|
||||||
|
```
|
||||||
|
Тесты `test_snapshot_load_scripts_use_truncate` (строка 13) и
|
||||||
|
`test_snapshot_dq_checks_extra_keys` (строка 22) проверяют паттерны TRUNCATE+INSERT
|
||||||
|
и `v_extra_keys_count` в SQL-файлах. Заглушки `SELECT 1;` не содержат этих паттернов →
|
||||||
|
тесты упадут для airplanes/seats. Исключение из `SNAPSHOT_ENTITIES` решает проблему.
|
||||||
|
Batch resolver тест (строка 30) проверяет DAG-код (не SQL-файлы) — без изменений.
|
||||||
|
|
||||||
|
### 11. Прочие документы
|
||||||
|
|
||||||
|
- `docs/design/PRD.md` — обновить чеклист (раздел 10)
|
||||||
|
- `docs/design/bookings_ods_design.md` — комплексное обновление:
|
||||||
|
- Строка 29, 515, 534: пометить `airplanes` и `seats` как студенческие
|
||||||
|
(на main — заглушки; STG-уровень — эталон, ODS load/dq — студент)
|
||||||
|
- Строки 521, 523: скорректировать общий DQ-контракт — на main `airplanes_dq.sql`
|
||||||
|
и `seats_dq.sql` являются заглушками, а не полноценными RAISE EXCEPTION скриптами
|
||||||
|
- Строка 620: обновить DAG-граф — routes больше не зависит от airplanes на ODS
|
||||||
|
- Строка 634: убрать зависимость routes от airplanes в описании FK
|
||||||
|
- `docs/bookings_to_gp_ods.md` — обновить описание
|
||||||
|
- `docs/bookings_to_gp_dds.md`:
|
||||||
|
- Строки 111-113: убрать гарантию отсутствия NULL SK в штатном режиме —
|
||||||
|
на main student SK (`passenger_sk`, `route_sk`, `airplane_sk`) будут NULL
|
||||||
|
- Строка 116: обновить DQ-семантику — student SK не блокируют (NOTICE);
|
||||||
|
airport_sk — порог 1%
|
||||||
|
- Строка 126: обновить описание lookup (airports через ods.routes)
|
||||||
|
- `docs/reference/qa-plan.md`:
|
||||||
|
- Строка 80: «ODS: все 9 таблиц не пустые» — на main `ods.airplanes` и `ods.seats`
|
||||||
|
будут пустыми by design (студенческие заглушки). Уточнить формулировку.
|
||||||
|
- `docs/e2e-etl-test-protocol.md` — обновить контекст
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок выполнения
|
||||||
|
|
||||||
|
### Сейчас (на chore/bookings-etl)
|
||||||
|
|
||||||
|
1. **Код**: изменить `fact_flight_sales_load.sql` (lookup через ods.routes) — п.1
|
||||||
|
2. **Документация**: обновить `analyst_spec.md` — п.6
|
||||||
|
3. **Документация**: обновить `assignment_design.md` — п.7
|
||||||
|
4. **Документация**: обновить `db_schema.md` — п.9
|
||||||
|
5. **Тестирование**: `make test` + ручная проверка SQL
|
||||||
|
6. **Коммит**
|
||||||
|
|
||||||
|
### Этап 3 (валидационный DAG)
|
||||||
|
|
||||||
|
- Проектировать с учётом DDL-заглушек
|
||||||
|
- `validate_stg` секцию **не делать** (STG — эталон)
|
||||||
|
- `check_dim_routes_scd2` — активный тест (мутация ODS → проверка → откат)
|
||||||
|
|
||||||
|
### Этап 4 (подготовка main + solution)
|
||||||
|
|
||||||
|
- Fact DQ: ослабить проверки student SK (main only) — п.2
|
||||||
|
- Routes DQ: убрать airplane RI (main only) — п.3
|
||||||
|
- DAG-зависимости: убрать airplanes → routes (main only) — п.4
|
||||||
|
- Заглушки: ODS (airplanes, seats) + DDS (3 dims) + DM (4 vitrines) — п.5
|
||||||
|
- Тесты: обновить smoke-тесты (main only) — п.10
|
||||||
|
- Документация: bookings_dds_design, PRD, прочее — п.8, п.11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
### После п.1 (fact_flight_sales fix)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # smoke-тесты DAG
|
||||||
|
```
|
||||||
|
|
||||||
|
Ручная проверка (при поднятом стенде):
|
||||||
|
|
||||||
|
> **Важно:** UPDATE факта не перезаписывает dimension SK (by design, строка 4-5).
|
||||||
|
> Уже загруженные строки сохранят старые значения `airport_sk` (через dim_routes).
|
||||||
|
> Для полной проверки новой логики нужен **TRUNCATE + перезагрузка**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
TRUNCATE dds.fact_flight_sales;
|
||||||
|
-- Перезапустить DDS DAG (или только task load_dds_fact_flight_sales)
|
||||||
|
|
||||||
|
-- Проверка: airport_sk заполнены (через ods.routes), route_sk тоже (dim_routes заполнен на этой ветке)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(departure_airport_sk) AS has_dep_sk,
|
||||||
|
COUNT(arrival_airport_sk) AS has_arr_sk,
|
||||||
|
COUNT(route_sk) AS has_route_sk
|
||||||
|
FROM dds.fact_flight_sales;
|
||||||
|
```
|
||||||
|
|
||||||
|
### После Этапа 4 (main branch)
|
||||||
|
|
||||||
|
1. `make up` → `make ddl-gp` → запустить STG+ODS+DDS DAG-и
|
||||||
|
2. Проверить: `sales_report` содержит данные с корректными аэропортами
|
||||||
|
3. Проверить: студенческие ODS/DDS-таблицы пусты (заглушки)
|
||||||
|
4. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
|
||||||
|
5. Реализовать один студенческий dim → TRUNCATE fact → re-run → проверить SK
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые файлы
|
||||||
|
|
||||||
|
| Файл | Роль | Ветка |
|
||||||
|
|------|------|-------|
|
||||||
|
| `sql/dds/fact_flight_sales_load.sql` | Главное изменение: airport lookup через ods.routes | обе |
|
||||||
|
| `sql/dds/fact_flight_sales_dq.sql` | Student SK: EXCEPTION → NOTICE | main only |
|
||||||
|
| `sql/ods/routes_dq.sql` | Убрать RI airplane_code → ods.airplanes | main only |
|
||||||
|
| `airflow/dags/bookings_to_gp_ods.py` | DAG: убрать airplanes → routes | main only |
|
||||||
|
| `tests/test_ods_sql_contract.py` | SNAPSHOT_ENTITIES: убрать airplanes, seats | main only |
|
||||||
|
| `tests/test_dags_smoke.py` | ODS-барьер airplanes → routes | main only |
|
||||||
|
| `docs/assignment/analyst_spec.md` | Убрать STG-задания, убрать ods.routes | обе |
|
||||||
|
| `docs/design/assignment_design.md` | Обновить таблицы эталон/задание | обе |
|
||||||
|
| `docs/design/bookings_dds_design.md` | Обновить описание fact lookup | обе |
|
||||||
|
| `docs/design/bookings_ods_design.md` | Убрать зависимость routes от airplanes | main only |
|
||||||
|
| `docs/design/db_schema.md` | Пометить STG + ods.routes как эталон | обе |
|
||||||
|
|
||||||
|
> **STG не трогаем**: `sql/stg/routes_dq.sql`, `bookings_to_gp_stage.py`,
|
||||||
|
> STG smoke-тесты — без изменений. Весь STG эталонный, зависимости работают штатно.
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
# План: Валидационный DAG `bookings_validate`
|
||||||
|
|
||||||
|
## Контекст и мотивация
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
Студент реализует 9 объектов (2 ODS, 3 DDS-измерения, 4 DM-витрины) и **не имеет
|
||||||
|
автоматической обратной связи** — правильно ли работает его код. Сейчас единственный
|
||||||
|
способ проверки — ручные SQL-запросы и визуальный контроль данных в таблицах.
|
||||||
|
|
||||||
|
Это приводит к типичным ошибкам, которые студент не замечает:
|
||||||
|
|
||||||
|
| Ошибка | Где проявляется | Когда обнаруживается |
|
||||||
|
|--------|-----------------|----------------------|
|
||||||
|
| NULL в PK | ODS/DDS | Только при построении витрины (неожиданные NULL в агрегатах) |
|
||||||
|
| Дубли по BK | DDS dim_passengers | Факт раздувается, витрины считают неверно |
|
||||||
|
| SCD2 не закрывает версии | DDS dim_routes | `valid_to` всегда NULL → point-in-time JOIN перестаёт работать |
|
||||||
|
| «Дыры» в SCD2-интервалах | DDS dim_routes | `sales_report` теряет строки за «пустые» даты |
|
||||||
|
| TRUNCATE+INSERT не вытащил все строки | ODS airplanes/seats | DDS-измерение неполное → NULL в факте |
|
||||||
|
| Витрина пуста при непустом источнике | DM | Видно сразу, но причина непонятна |
|
||||||
|
|
||||||
|
Без валидационного DAG студент узнаёт о проблеме **через 2-3 слоя** — на этапе DM-витрин,
|
||||||
|
где отладка многократно сложнее.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
|
||||||
|
**Отдельный DAG `bookings_validate`** — набор проверок, сгруппированных по слоям.
|
||||||
|
Студент запускает его вручную (trigger в Airflow UI) после реализации заданий.
|
||||||
|
Каждый таск — одна проверка, с дружелюбным сообщением об ошибке и подсказкой,
|
||||||
|
что делать дальше.
|
||||||
|
|
||||||
|
### Педагогическая ценность
|
||||||
|
|
||||||
|
1. **Практика чтения логов Airflow** — студент учится находить ошибки в логах тасков
|
||||||
|
2. **Инкрементальная обратная связь** — можно запускать после каждого шага, не дожидаясь
|
||||||
|
реализации всех заданий
|
||||||
|
3. **Паттерн DQ в пайплайне** — студент видит, как устроены production-проверки качества данных
|
||||||
|
4. **Активный тест SCD2** — единственный способ убедиться, что SCD2-логика действительно
|
||||||
|
работает (справочник `routes` в демо-базе статичен)
|
||||||
|
|
||||||
|
### Дизайн-решения
|
||||||
|
|
||||||
|
| Решение | Обоснование |
|
||||||
|
|---------|-------------|
|
||||||
|
| Отдельный DAG (не часть основного пайплайна) | Запускается по желанию, не блокирует основную загрузку |
|
||||||
|
| `schedule=None` (ручной триггер) | Студент запускает, когда готов проверить свой код |
|
||||||
|
| `PostgresOperator` + SQL-скрипты | Единый паттерн с основными DAG-ами |
|
||||||
|
| SQL в `sql/validate/` | Отдельный каталог — валидация не смешивается с DQ пайплайна |
|
||||||
|
| `DO $$ ... RAISE EXCEPTION ... $$` | Тот же паттерн, что в эталонных DQ-скриптах |
|
||||||
|
| TaskGroup по слоям | Студент видит, на каком слое проблема |
|
||||||
|
| Проверки «существует + не пуста» для DM | Минимально необходимо; бизнес-инварианты студент добавит сам в DQ витрин |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура DAG
|
||||||
|
|
||||||
|
```
|
||||||
|
bookings_validate
|
||||||
|
├── validate_ods (TaskGroup)
|
||||||
|
│ ├── check_ods_airplanes_rowcount
|
||||||
|
│ ├── check_ods_seats_rowcount
|
||||||
|
│ ├── check_ods_no_dup_bk
|
||||||
|
│ └── check_ods_no_null_pks
|
||||||
|
├── validate_dds (TaskGroup)
|
||||||
|
│ ├── check_dim_airplanes_exists
|
||||||
|
│ ├── check_dim_passengers_exists
|
||||||
|
│ ├── check_dim_passengers_no_dup_bk
|
||||||
|
│ ├── check_dim_routes_exists
|
||||||
|
│ ├── check_dim_routes_scd2 ← активный тест!
|
||||||
|
│ └── check_dim_routes_no_gaps
|
||||||
|
└── validate_dm (TaskGroup)
|
||||||
|
├── check_airport_traffic_exists
|
||||||
|
├── check_route_performance_exists
|
||||||
|
├── check_monthly_overview_exists
|
||||||
|
└── check_passenger_loyalty_exists
|
||||||
|
```
|
||||||
|
|
||||||
|
### Зависимости между группами
|
||||||
|
|
||||||
|
Нет жёстких зависимостей. Все три группы запускаются параллельно — студент может
|
||||||
|
реализовать только ODS и увидеть зелёные чеки для `validate_ods`, пока DDS/DM ещё
|
||||||
|
красные. Это даёт инкрементальную обратную связь.
|
||||||
|
|
||||||
|
Внутри каждой группы таски также параллельны (независимые проверки).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Детализация проверок
|
||||||
|
|
||||||
|
### ODS: `check_ods_airplanes_rowcount`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/ods_airplanes_rowcount.sql`
|
||||||
|
|
||||||
|
**Логика:** ODS загружает один конкретный snapshot-батч из STG (по `_load_id`).
|
||||||
|
STG — append-only: содержит историю всех загрузок. Поэтому сравнивать ODS
|
||||||
|
со всей STG некорректно — нужно проверять точное множество BK для того батча,
|
||||||
|
который ODS фактически загрузил.
|
||||||
|
|
||||||
|
Определяем батч: `_load_id` из `ods.airplanes` (единственный, т.к. TRUNCATE+INSERT).
|
||||||
|
Затем проверяем, что множество BK в ODS = множество BK в STG для этого батча.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_batch_count BIGINT;
|
||||||
|
v_batch TEXT;
|
||||||
|
v_ods_count BIGINT;
|
||||||
|
v_missing_in_ods BIGINT;
|
||||||
|
v_extra_in_ods BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- ODS пуста?
|
||||||
|
SELECT COUNT(*) INTO v_ods_count FROM ods.airplanes;
|
||||||
|
IF v_ods_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.airplanes пуста. Реализуйте загрузку: sql/ods/airplanes_load.sql';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Инвариант: ODS после TRUNCATE+INSERT содержит ровно один _load_id
|
||||||
|
SELECT COUNT(DISTINCT _load_id) INTO v_batch_count FROM ods.airplanes;
|
||||||
|
IF v_batch_count <> 1 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % разных _load_id (ожидается 1 после TRUNCATE+INSERT). Проверьте, что load начинается с TRUNCATE.', v_batch_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Определяем батч, из которого загружена ODS
|
||||||
|
SELECT DISTINCT _load_id INTO v_batch FROM ods.airplanes;
|
||||||
|
|
||||||
|
-- BK есть в STG-батче, но нет в ODS (потеряны при загрузке)
|
||||||
|
SELECT COUNT(*) INTO v_missing_in_ods
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code FROM stg.airplanes WHERE _load_id = v_batch
|
||||||
|
) AS stg_bk
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ods.airplanes AS o WHERE o.airplane_code = stg_bk.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_in_ods > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: % самолётов из STG-батча (%) отсутствуют в ods.airplanes. Проверьте логику TRUNCATE+INSERT.', v_missing_in_ods, v_batch;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- BK есть в ODS, но нет в STG-батче (откуда взялись?)
|
||||||
|
SELECT COUNT(*) INTO v_extra_in_ods
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code FROM ods.airplanes
|
||||||
|
) AS ods_bk
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM stg.airplanes AS s WHERE s._load_id = v_batch AND s.airplane_code = ods_bk.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_extra_in_ods > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: % самолётов в ods.airplanes отсутствуют в STG-батче (%). Возможно, TRUNCATE не выполнился перед INSERT.', v_extra_in_ods, v_batch;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: ods.airplanes содержит % самолётов, множество BK = STG-батч %', v_ods_count, v_batch;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ODS: `check_ods_seats_rowcount`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/ods_seats_rowcount.sql`
|
||||||
|
|
||||||
|
Аналогичная проверка по составному ключу `(airplane_code, seat_no)` —
|
||||||
|
точное множество BK из STG-батча (по `_load_id` из `ods.seats`).
|
||||||
|
|
||||||
|
### ODS: `check_ods_no_dup_bk`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/ods_no_dup_bk.sql`
|
||||||
|
|
||||||
|
**Логика:** Проверить, что в ODS нет дублей по бизнес-ключу. Типичная ошибка
|
||||||
|
студента — INSERT без предшествующего TRUNCATE, или TRUNCATE забыт при повторном
|
||||||
|
запуске. Дубли по BK в ODS каскадно ломают DDS (лишние строки в измерениях).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_dup_airplanes BIGINT;
|
||||||
|
v_dup_seats BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- airplanes: BK = airplane_code
|
||||||
|
SELECT COUNT(*) INTO v_dup_airplanes
|
||||||
|
FROM (
|
||||||
|
SELECT airplane_code FROM ods.airplanes
|
||||||
|
GROUP BY airplane_code HAVING COUNT(*) > 1
|
||||||
|
) AS d;
|
||||||
|
|
||||||
|
IF v_dup_airplanes > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % дублирующихся airplane_code. Проверьте, что load начинается с TRUNCATE.', v_dup_airplanes;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- seats: BK = (airplane_code, seat_no)
|
||||||
|
SELECT COUNT(*) INTO v_dup_seats
|
||||||
|
FROM (
|
||||||
|
SELECT airplane_code, seat_no FROM ods.seats
|
||||||
|
GROUP BY airplane_code, seat_no HAVING COUNT(*) > 1
|
||||||
|
) AS d;
|
||||||
|
|
||||||
|
IF v_dup_seats > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.seats содержит % дублирующихся (airplane_code, seat_no). Проверьте, что load начинается с TRUNCATE.', v_dup_seats;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: Нет дублей по BK в ods.airplanes и ods.seats';
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ODS: `check_ods_no_null_pks`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/ods_no_null_pks.sql`
|
||||||
|
|
||||||
|
**Логика:** Проверить, что PK-поля не содержат NULL в обеих студенческих ODS-таблицах.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_null_airplanes BIGINT;
|
||||||
|
v_null_seats BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- airplanes: PK = airplane_code
|
||||||
|
SELECT COUNT(*) INTO v_null_airplanes
|
||||||
|
FROM ods.airplanes WHERE airplane_code IS NULL;
|
||||||
|
|
||||||
|
IF v_null_airplanes > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.airplanes содержит % строк с NULL в airplane_code.', v_null_airplanes;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- seats: PK = (airplane_code, seat_no)
|
||||||
|
SELECT COUNT(*) INTO v_null_seats
|
||||||
|
FROM ods.seats WHERE airplane_code IS NULL OR seat_no IS NULL;
|
||||||
|
|
||||||
|
IF v_null_seats > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.seats содержит % строк с NULL в PK (airplane_code, seat_no).', v_null_seats;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: PK не содержат NULL в ods.airplanes и ods.seats';
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DDS: `check_dim_airplanes_exists`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_airplanes_exists.sql`
|
||||||
|
|
||||||
|
**Логика:**
|
||||||
|
1. Таблица не пуста
|
||||||
|
2. Нет дублей по `airplane_bk`
|
||||||
|
3. Покрытие ODS: все `airplane_code` из `ods.airplanes` есть в `dds.dim_airplanes`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_count BIGINT;
|
||||||
|
v_dup BIGINT;
|
||||||
|
v_missing BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count FROM dds.dim_airplanes;
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: dds.dim_airplanes пуста. Реализуйте загрузку: sql/dds/dim_airplanes_load.sql';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) INTO v_dup FROM dds.dim_airplanes;
|
||||||
|
IF v_dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: dds.dim_airplanes содержит % дублей по airplane_bk. Проверьте SCD1-логику (UPSERT).', v_dup;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO v_missing
|
||||||
|
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dds.dim_airplanes AS d WHERE d.airplane_bk = s.airplane_code
|
||||||
|
);
|
||||||
|
IF v_missing > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: % самолётов из ods.airplanes отсутствуют в dds.dim_airplanes.', v_missing;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: dds.dim_airplanes содержит % строк, покрывает все BK из ODS', v_count;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### DDS: `check_dim_passengers_exists`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_passengers_exists.sql`
|
||||||
|
|
||||||
|
Аналогичная структура: не пуста + нет дублей по `passenger_id` + покрытие
|
||||||
|
`ods.tickets` (все уникальные `passenger_id` из тикетов есть в измерении).
|
||||||
|
|
||||||
|
### DDS: `check_dim_passengers_no_dup_bk`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_passengers_no_dup_bk.sql`
|
||||||
|
|
||||||
|
Отдельная проверка дублей `passenger_id` — типичная ошибка студентов при SCD1:
|
||||||
|
INSERT без проверки EXISTS создаёт дубли. Выделена в отдельный таск для ясности
|
||||||
|
сообщения об ошибке.
|
||||||
|
|
||||||
|
**Примечание:** Эту проверку можно объединить с `check_dim_passengers_exists`.
|
||||||
|
Отдельный таск оправдан, если хотим дать студенту более точную диагностику.
|
||||||
|
Решение — на усмотрение реализатора.
|
||||||
|
|
||||||
|
### DDS: `check_dim_routes_exists`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_routes_exists.sql`
|
||||||
|
|
||||||
|
Таблица не пуста + покрытие ODS (`route_no`) + **не более одной текущей версии
|
||||||
|
на `route_bk`** (инвариант SCD2: `COUNT(*) WHERE valid_to IS NULL` <= 1 для каждого BK).
|
||||||
|
|
||||||
|
Типичная ошибка студента — INSERT без проверки `NOT EXISTS ... AND hashdiff = ...`,
|
||||||
|
что создаёт дубли текущей версии. Активный SCD2-тест проверяет только один маршрут,
|
||||||
|
эта проверка ловит проблему глобально.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_count BIGINT;
|
||||||
|
v_missing BIGINT;
|
||||||
|
v_multi_current BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count FROM dds.dim_routes;
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: dds.dim_routes пуста. Реализуйте загрузку: sql/dds/dim_routes_load.sql';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Покрытие ODS
|
||||||
|
SELECT COUNT(*) INTO v_missing
|
||||||
|
FROM (SELECT DISTINCT route_no FROM ods.routes) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dds.dim_routes AS d WHERE d.route_bk = s.route_no
|
||||||
|
);
|
||||||
|
IF v_missing > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: % маршрутов из ods.routes отсутствуют в dds.dim_routes.', v_missing;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Инвариант SCD2: не более одной текущей версии на route_bk
|
||||||
|
SELECT COUNT(*) INTO v_multi_current
|
||||||
|
FROM (
|
||||||
|
SELECT route_bk FROM dds.dim_routes
|
||||||
|
WHERE valid_to IS NULL
|
||||||
|
GROUP BY route_bk HAVING COUNT(*) > 1
|
||||||
|
) AS d;
|
||||||
|
|
||||||
|
IF v_multi_current > 0 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: % маршрутов имеют более одной текущей версии (valid_to IS NULL).\n'
|
||||||
|
'Подсказка: при INSERT новой версии проверяйте NOT EXISTS ... AND hashdiff = ...\n'
|
||||||
|
'чтобы не создавать дубликат, если hashdiff не изменился.', v_multi_current;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: dds.dim_routes содержит % строк, покрывает ODS, по одной текущей версии на маршрут', v_count;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### DDS: `check_dim_routes_scd2` (АКТИВНЫЙ ТЕСТ)
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_routes_scd2.sql`
|
||||||
|
|
||||||
|
Это ключевая проверка плана. Справочник `bookings.routes` в демо-базе **статичен** —
|
||||||
|
маршруты не меняются между запусками генератора. При обычном прогоне пайплайна
|
||||||
|
студент **никогда не увидит**, как SCD2 закрывает старую версию и создаёт новую.
|
||||||
|
|
||||||
|
**Алгоритм активного теста:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Бэкап: CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
|
||||||
|
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
|
||||||
|
(обычные таблицы — TEMP не сохраняются между тасками Airflow)
|
||||||
|
|
||||||
|
2. Мутация: Выбрать один маршрут из ods.routes (WHERE departure_time IS NOT NULL LIMIT 1).
|
||||||
|
Сохранить его route_no в служебную таблицу _validate_scd2_target.
|
||||||
|
Обновить в ods.routes его departure_time на +1 час.
|
||||||
|
|
||||||
|
3. Запуск студенческого кода: PostgresOperator(sql="dds/dim_routes_load.sql")
|
||||||
|
|
||||||
|
4. Проверки (все 5):
|
||||||
|
a) Ровно 2 версии тестового маршрута (было 1, стало 2)
|
||||||
|
b) Старая версия закрыта: valid_to IS NOT NULL
|
||||||
|
c) Ровно 1 открытая версия: valid_to IS NULL
|
||||||
|
d) hashdiff старой ≠ hashdiff новой (мутация отразилась в хеше)
|
||||||
|
e) Нет «дыры»: старая.valid_to = новая.valid_from
|
||||||
|
|
||||||
|
5. Откат (с проверкой существования бэкапов через to_regclass):
|
||||||
|
IF _validate_bk_ods_routes exists: TRUNCATE ods.routes + INSERT FROM backup;
|
||||||
|
IF _validate_bk_dim_routes exists: TRUNCATE dds.dim_routes + INSERT FROM backup;
|
||||||
|
DROP TABLE IF EXISTS _validate_bk_*, _validate_scd2_target;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Реализация:** Этот таск **не может быть простым PostgresOperator** с одним SQL,
|
||||||
|
потому что шаг 3 — это вызов студенческого SQL-скрипта *внутри* теста. Варианты:
|
||||||
|
|
||||||
|
**Выбранный подход: цепочка тасков.**
|
||||||
|
- Jinja-шаблоны работают из коробки
|
||||||
|
- Каждый шаг прозрачен в Airflow UI
|
||||||
|
- `trigger_rule="all_done"` на restore гарантирует откат
|
||||||
|
- Студент видит в UI, на каком именно шаге проблема
|
||||||
|
|
||||||
|
### DDS: `check_dim_routes_no_gaps`
|
||||||
|
|
||||||
|
**Файл:** `sql/validate/dim_routes_no_gaps.sql`
|
||||||
|
|
||||||
|
**Логика:** Для каждого `route_bk` с несколькими версиями проверить, что
|
||||||
|
`valid_to` предыдущей версии = `valid_from` следующей (полуоткрытый интервал
|
||||||
|
`[valid_from, valid_to)` без «дыр»).
|
||||||
|
|
||||||
|
**Важно:** SCD2-загрузка поддерживает «исчезнувшие» маршруты (`dim_routes_load.sql`,
|
||||||
|
Statement 1.1): если маршрут пропал из ODS, его текущая версия закрывается
|
||||||
|
(`valid_to = CURRENT_DATE`), но новая **не вставляется**. Это корректное поведение —
|
||||||
|
у такого маршрута `valid_to IS NOT NULL` и `next_valid_from IS NULL`. Проверка
|
||||||
|
должна считать «дырой» только случаи, когда следующая версия **существует**,
|
||||||
|
но `valid_to ≠ next_valid_from`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_gaps BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_gaps
|
||||||
|
FROM (
|
||||||
|
SELECT route_bk, valid_to,
|
||||||
|
LEAD(valid_from) OVER (PARTITION BY route_bk ORDER BY valid_from) AS next_valid_from
|
||||||
|
FROM dds.dim_routes
|
||||||
|
) AS t
|
||||||
|
WHERE valid_to IS NOT NULL
|
||||||
|
AND next_valid_from IS NOT NULL -- следующая версия существует (не «исчезнувший» маршрут)
|
||||||
|
AND valid_to <> next_valid_from;
|
||||||
|
|
||||||
|
IF v_gaps > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: В dds.dim_routes найдено % «дыр» между версиями SCD2. valid_to старой версии должен совпадать с valid_from новой.', v_gaps;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: Нет «дыр» в SCD2-версиях dim_routes';
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DM: `check_{vitrine}_exists` (4 таска)
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- `sql/validate/airport_traffic_exists.sql`
|
||||||
|
- `sql/validate/route_performance_exists.sql`
|
||||||
|
- `sql/validate/monthly_overview_exists.sql`
|
||||||
|
- `sql/validate/passenger_loyalty_exists.sql`
|
||||||
|
|
||||||
|
**Логика:** Минимальная проверка — таблица не пуста + нет NULL в ключевых полях.
|
||||||
|
|
||||||
|
Пример для `airport_traffic`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_count BIGINT;
|
||||||
|
v_null_pk BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_count FROM dm.airport_traffic;
|
||||||
|
IF v_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: dm.airport_traffic пуста. Реализуйте загрузку: sql/dm/airport_traffic_load.sql';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO v_null_pk
|
||||||
|
FROM dm.airport_traffic
|
||||||
|
WHERE traffic_date IS NULL OR airport_sk IS NULL;
|
||||||
|
|
||||||
|
IF v_null_pk > 0 THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: dm.airport_traffic содержит % строк с NULL в ключе (traffic_date, airport_sk).', v_null_pk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: dm.airport_traffic содержит % строк', v_count;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
Для `route_performance` ключ — `route_bk`, для `monthly_overview` — `(year_actual, month_actual, airplane_sk)`,
|
||||||
|
для `passenger_loyalty` — `passenger_sk`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы для создания
|
||||||
|
|
||||||
|
### SQL-скрипты (`sql/validate/`)
|
||||||
|
|
||||||
|
| # | Файл | Описание |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | `ods_airplanes_rowcount.sql` | BK coverage: ODS vs STG-батч |
|
||||||
|
| 2 | `ods_seats_rowcount.sql` | BK coverage: ODS vs STG-батч |
|
||||||
|
| 3 | `ods_no_dup_bk.sql` | Нет дублей по BK в ODS (airplanes + seats) |
|
||||||
|
| 4 | `ods_no_null_pks.sql` | NULL в PK обеих ODS-таблиц |
|
||||||
|
| 5 | `dim_airplanes_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
|
||||||
|
| 6 | `dim_passengers_exists.sql` | Не пуста + нет дублей BK + покрытие ODS |
|
||||||
|
| 7 | `dim_passengers_no_dup_bk.sql` | Дубли `passenger_id` (отдельная диагностика) |
|
||||||
|
| 8 | `dim_routes_exists.sql` | Не пуста + покрытие ODS + не более 1 текущей версии на BK |
|
||||||
|
| 9 | `dim_routes_scd2_backup.sql` | Бэкап ods.routes + dds.dim_routes |
|
||||||
|
| 10 | `dim_routes_scd2_mutate.sql` | Мутация тестового маршрута + сохранение route_no |
|
||||||
|
| 11 | `dim_routes_scd2_check.sql` | 5 проверок SCD2 после загрузки |
|
||||||
|
| 12 | `dim_routes_scd2_restore.sql` | Безопасный откат данных из бэкапа |
|
||||||
|
| 13 | `dim_routes_no_gaps.sql` | Проверка SCD2-интервалов (без «исчезнувших») |
|
||||||
|
| 14 | `airport_traffic_exists.sql` | Не пуста + NULL в ключе |
|
||||||
|
| 15 | `route_performance_exists.sql` | Не пуста + NULL в ключе |
|
||||||
|
| 16 | `monthly_overview_exists.sql` | Не пуста + NULL в ключе |
|
||||||
|
| 17 | `passenger_loyalty_exists.sql` | Не пуста + NULL в ключе |
|
||||||
|
|
||||||
|
### DAG-файл
|
||||||
|
|
||||||
|
| # | Файл | Описание |
|
||||||
|
|---|------|----------|
|
||||||
|
| 18 | `airflow/dags/bookings_validate.py` | DAG с TaskGroup по слоям |
|
||||||
|
|
||||||
|
### Тест
|
||||||
|
|
||||||
|
| # | Файл | Описание |
|
||||||
|
|---|------|----------|
|
||||||
|
| 19 | Дополнение `tests/test_dags_smoke.py` | Smoke-тест структуры DAG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DAG-файл: `bookings_validate.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Валидационный DAG: самопроверка студенческих заданий.
|
||||||
|
|
||||||
|
Запускается вручную в Airflow UI после реализации заданий.
|
||||||
|
Таски сгруппированы по слоям — студент видит, где именно проблема.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
import pendulum
|
||||||
|
from airflow import DAG
|
||||||
|
from airflow.providers.postgres.operators.postgres import PostgresOperator
|
||||||
|
from airflow.utils.task_group import TaskGroup
|
||||||
|
|
||||||
|
GREENPLUM_CONN_ID = "greenplum_conn"
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
default_args = {
|
||||||
|
"owner": "airflow",
|
||||||
|
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
|
||||||
|
"retry_delay": timedelta(seconds=10),
|
||||||
|
}
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="bookings_validate",
|
||||||
|
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
|
||||||
|
schedule=None, # Только ручной запуск
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath="/sql",
|
||||||
|
default_args=default_args,
|
||||||
|
tags=["demo", "bookings", "greenplum", "validate"],
|
||||||
|
description="Валидация студенческих заданий: ODS, DDS, DM",
|
||||||
|
) as dag:
|
||||||
|
|
||||||
|
with TaskGroup("validate_ods") as validate_ods:
|
||||||
|
check_ods_airplanes = PostgresOperator(
|
||||||
|
task_id="check_ods_airplanes_rowcount",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/ods_airplanes_rowcount.sql",
|
||||||
|
)
|
||||||
|
check_ods_seats = PostgresOperator(
|
||||||
|
task_id="check_ods_seats_rowcount",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/ods_seats_rowcount.sql",
|
||||||
|
)
|
||||||
|
check_ods_dup_bk = PostgresOperator(
|
||||||
|
task_id="check_ods_no_dup_bk",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/ods_no_dup_bk.sql",
|
||||||
|
)
|
||||||
|
check_ods_pks = PostgresOperator(
|
||||||
|
task_id="check_ods_no_null_pks",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/ods_no_null_pks.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
with TaskGroup("validate_dds") as validate_dds:
|
||||||
|
check_dim_airplanes = PostgresOperator(
|
||||||
|
task_id="check_dim_airplanes_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_airplanes_exists.sql",
|
||||||
|
)
|
||||||
|
check_dim_passengers = PostgresOperator(
|
||||||
|
task_id="check_dim_passengers_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_passengers_exists.sql",
|
||||||
|
)
|
||||||
|
check_dim_passengers_dup = PostgresOperator(
|
||||||
|
task_id="check_dim_passengers_no_dup_bk",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_passengers_no_dup_bk.sql",
|
||||||
|
)
|
||||||
|
check_dim_routes = PostgresOperator(
|
||||||
|
task_id="check_dim_routes_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_exists.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
|
||||||
|
scd2_backup = PostgresOperator(
|
||||||
|
task_id="scd2_backup",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_scd2_backup.sql",
|
||||||
|
)
|
||||||
|
scd2_mutate = PostgresOperator(
|
||||||
|
task_id="scd2_mutate",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_scd2_mutate.sql",
|
||||||
|
)
|
||||||
|
scd2_run_load = PostgresOperator(
|
||||||
|
task_id="scd2_run_student_load",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
|
||||||
|
)
|
||||||
|
scd2_check = PostgresOperator(
|
||||||
|
task_id="scd2_check",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_scd2_check.sql",
|
||||||
|
)
|
||||||
|
scd2_restore = PostgresOperator(
|
||||||
|
task_id="scd2_restore",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_scd2_restore.sql",
|
||||||
|
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
|
||||||
|
)
|
||||||
|
|
||||||
|
check_dim_routes_gaps = PostgresOperator(
|
||||||
|
task_id="check_dim_routes_no_gaps",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/dim_routes_no_gaps.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SCD2 цепочка
|
||||||
|
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
|
||||||
|
|
||||||
|
# no_gaps запускается после restore (на чистых данных)
|
||||||
|
scd2_restore >> check_dim_routes_gaps
|
||||||
|
|
||||||
|
with TaskGroup("validate_dm") as validate_dm:
|
||||||
|
check_airport_traffic = PostgresOperator(
|
||||||
|
task_id="check_airport_traffic_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/airport_traffic_exists.sql",
|
||||||
|
)
|
||||||
|
check_route_performance = PostgresOperator(
|
||||||
|
task_id="check_route_performance_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/route_performance_exists.sql",
|
||||||
|
)
|
||||||
|
check_monthly_overview = PostgresOperator(
|
||||||
|
task_id="check_monthly_overview_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/monthly_overview_exists.sql",
|
||||||
|
)
|
||||||
|
check_passenger_loyalty = PostgresOperator(
|
||||||
|
task_id="check_passenger_loyalty_exists",
|
||||||
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="validate/passenger_loyalty_exists.sql",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Активный тест SCD2: детализация SQL
|
||||||
|
|
||||||
|
### `dim_routes_scd2_backup.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Бэкап текущего состояния перед тестом SCD2.
|
||||||
|
-- Используем обычные таблицы (не TEMP) — между тасками Airflow
|
||||||
|
-- TEMP-таблицы не сохраняются (каждый таск = отдельная транзакция).
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS _validate_bk_ods_routes;
|
||||||
|
CREATE TABLE _validate_bk_ods_routes AS SELECT * FROM ods.routes;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS _validate_bk_dim_routes;
|
||||||
|
CREATE TABLE _validate_bk_dim_routes AS SELECT * FROM dds.dim_routes;
|
||||||
|
|
||||||
|
-- Cleanup служебной таблицы от предыдущего запуска (на случай если restore не доехал)
|
||||||
|
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dim_routes_scd2_mutate.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Мутация: сдвигаем departure_time у одного маршрута на 1 час.
|
||||||
|
-- Это должно изменить hashdiff → SCD2 должен закрыть старую версию.
|
||||||
|
--
|
||||||
|
-- Сохраняем route_no тестового маршрута в служебную таблицу _validate_scd2_target,
|
||||||
|
-- чтобы check-скрипт точно знал, какой маршрут проверять (а не угадывал по побочным эффектам).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_route TEXT;
|
||||||
|
v_old_time TIME;
|
||||||
|
BEGIN
|
||||||
|
-- Берём первый маршрут, у которого departure_time заполнен
|
||||||
|
SELECT route_no, departure_time
|
||||||
|
INTO v_route, v_old_time
|
||||||
|
FROM ods.routes
|
||||||
|
WHERE departure_time IS NOT NULL
|
||||||
|
ORDER BY route_no
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_route IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: ods.routes пуста или нет маршрутов с departure_time. Загрузите STG→ODS перед проверкой.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Запоминаем тестовый маршрут в служебную таблицу
|
||||||
|
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||||
|
CREATE TABLE _validate_scd2_target AS
|
||||||
|
SELECT v_route AS route_no;
|
||||||
|
|
||||||
|
-- Сдвигаем время на 1 час у всех записей этого маршрута
|
||||||
|
UPDATE ods.routes
|
||||||
|
SET departure_time = departure_time + INTERVAL '1 hour'
|
||||||
|
WHERE route_no = v_route;
|
||||||
|
|
||||||
|
RAISE NOTICE 'SCD2 TEST: маршрут % — departure_time сдвинут с % на %',
|
||||||
|
v_route, v_old_time, v_old_time + INTERVAL '1 hour';
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dim_routes_scd2_check.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Проверяем, что SCD2-логика студента сработала корректно.
|
||||||
|
-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target
|
||||||
|
-- (создана на шаге mutate), а не угадываем по побочным эффектам.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_route TEXT;
|
||||||
|
v_version_count BIGINT;
|
||||||
|
v_closed_count BIGINT;
|
||||||
|
v_open_count BIGINT;
|
||||||
|
v_old_hash TEXT;
|
||||||
|
v_new_hash TEXT;
|
||||||
|
v_gap_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Читаем тестовый маршрут из служебной таблицы
|
||||||
|
SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1;
|
||||||
|
|
||||||
|
IF v_route IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Если dim_routes пуста — load не запустился
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n'
|
||||||
|
'Проверьте sql/dds/dim_routes_load.sql.', v_route;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации)
|
||||||
|
SELECT COUNT(*) INTO v_version_count
|
||||||
|
FROM dds.dim_routes WHERE route_bk = v_route;
|
||||||
|
|
||||||
|
IF v_version_count < 2 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n'
|
||||||
|
'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_version_count > 2 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n'
|
||||||
|
'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Проверка b: Старая версия закрыта (valid_to IS NOT NULL)
|
||||||
|
SELECT COUNT(*) INTO v_closed_count
|
||||||
|
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL;
|
||||||
|
|
||||||
|
IF v_closed_count = 0 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n'
|
||||||
|
'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n'
|
||||||
|
'но ваш load-скрипт не обнаружил это изменение.\n'
|
||||||
|
'Проверьте:\n'
|
||||||
|
' 1. Формулу hashdiff — включает ли она departure_time?\n'
|
||||||
|
' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Проверка c: Новая версия открыта (valid_to IS NULL)
|
||||||
|
SELECT COUNT(*) INTO v_open_count
|
||||||
|
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
|
||||||
|
|
||||||
|
IF v_open_count <> 1 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n'
|
||||||
|
'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась)
|
||||||
|
SELECT hashdiff INTO v_old_hash
|
||||||
|
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL
|
||||||
|
ORDER BY valid_from DESC LIMIT 1;
|
||||||
|
|
||||||
|
SELECT hashdiff INTO v_new_hash
|
||||||
|
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
|
||||||
|
|
||||||
|
IF v_old_hash = v_new_hash THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n'
|
||||||
|
'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n'
|
||||||
|
'Проверьте, что departure_time входит в формулу hashdiff.', v_route;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Проверка e: Нет «дыры» между valid_to старой и valid_from новой
|
||||||
|
SELECT COUNT(*) INTO v_gap_count
|
||||||
|
FROM dds.dim_routes AS old_v
|
||||||
|
JOIN dds.dim_routes AS new_v
|
||||||
|
ON old_v.route_bk = new_v.route_bk
|
||||||
|
WHERE old_v.route_bk = v_route
|
||||||
|
AND old_v.valid_to IS NOT NULL
|
||||||
|
AND new_v.valid_to IS NULL
|
||||||
|
AND old_v.valid_to <> new_v.valid_from;
|
||||||
|
|
||||||
|
IF v_gap_count > 0 THEN
|
||||||
|
RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n'
|
||||||
|
'valid_to старой ≠ valid_from новой.\n'
|
||||||
|
'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n'
|
||||||
|
'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dim_routes_scd2_restore.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Откат данных после теста SCD2.
|
||||||
|
-- Выполняется ВСЕГДА (trigger_rule="all_done"), даже если check упал.
|
||||||
|
--
|
||||||
|
-- Безопасность: если backup-шаг не создал таблицы (сбой на backup),
|
||||||
|
-- откат НЕ трогает live-данные — просто чистит служебные таблицы.
|
||||||
|
-- Это гарантирует, что restore никогда не сломает ods.routes / dds.dim_routes.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_has_ods_backup BOOLEAN;
|
||||||
|
v_has_dim_backup BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- Проверяем существование backup-таблиц через to_regclass
|
||||||
|
-- (ищет по search_path — совпадает с тем, как CREATE TABLE их создал)
|
||||||
|
v_has_ods_backup := to_regclass('_validate_bk_ods_routes') IS NOT NULL;
|
||||||
|
v_has_dim_backup := to_regclass('_validate_bk_dim_routes') IS NOT NULL;
|
||||||
|
|
||||||
|
-- Восстанавливаем ods.routes только если бэкап существует
|
||||||
|
IF v_has_ods_backup THEN
|
||||||
|
TRUNCATE ods.routes;
|
||||||
|
INSERT INTO ods.routes SELECT * FROM _validate_bk_ods_routes;
|
||||||
|
RAISE NOTICE 'RESTORE: ods.routes восстановлена из бэкапа';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'RESTORE: бэкап ods.routes не найден — пропускаем (backup-шаг не завершился?)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Восстанавливаем dds.dim_routes только если бэкап существует
|
||||||
|
IF v_has_dim_backup THEN
|
||||||
|
TRUNCATE dds.dim_routes;
|
||||||
|
INSERT INTO dds.dim_routes SELECT * FROM _validate_bk_dim_routes;
|
||||||
|
RAISE NOTICE 'RESTORE: dds.dim_routes восстановлена из бэкапа';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'RESTORE: бэкап dds.dim_routes не найден — пропускаем';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Cleanup служебных таблиц (безусловно, IF EXISTS)
|
||||||
|
DROP TABLE IF EXISTS _validate_bk_ods_routes;
|
||||||
|
DROP TABLE IF EXISTS _validate_bk_dim_routes;
|
||||||
|
DROP TABLE IF EXISTS _validate_scd2_target;
|
||||||
|
|
||||||
|
-- Учебный комментарий: Мы восстанавливаем данные из бэкапа, чтобы тест
|
||||||
|
-- не оставлял «мусорных» версий в dim_routes. Это стандартный паттерн
|
||||||
|
-- для интеграционных тестов: setup → act → assert → teardown.
|
||||||
|
-- IF EXISTS проверки гарантируют, что restore безопасен при любом сценарии сбоя.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Smoke-тест DAG (дополнение `test_dags_smoke.py`)
|
||||||
|
|
||||||
|
Добавить новый тест-класс:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestBookingsValidate:
|
||||||
|
"""Smoke-тесты DAG bookings_validate."""
|
||||||
|
|
||||||
|
def test_dag_loads(self):
|
||||||
|
dag = _load_dag("airflow.dags.bookings_validate")
|
||||||
|
assert dag is not None
|
||||||
|
|
||||||
|
def test_expected_tasks(self):
|
||||||
|
dag = _load_dag("airflow.dags.bookings_validate")
|
||||||
|
expected = {
|
||||||
|
# ODS
|
||||||
|
"validate_ods.check_ods_airplanes_rowcount",
|
||||||
|
"validate_ods.check_ods_seats_rowcount",
|
||||||
|
"validate_ods.check_ods_no_dup_bk",
|
||||||
|
"validate_ods.check_ods_no_null_pks",
|
||||||
|
# DDS
|
||||||
|
"validate_dds.check_dim_airplanes_exists",
|
||||||
|
"validate_dds.check_dim_passengers_exists",
|
||||||
|
"validate_dds.check_dim_passengers_no_dup_bk",
|
||||||
|
"validate_dds.check_dim_routes_exists",
|
||||||
|
"validate_dds.scd2_backup",
|
||||||
|
"validate_dds.scd2_mutate",
|
||||||
|
"validate_dds.scd2_run_student_load",
|
||||||
|
"validate_dds.scd2_check",
|
||||||
|
"validate_dds.scd2_restore",
|
||||||
|
"validate_dds.check_dim_routes_no_gaps",
|
||||||
|
# DM
|
||||||
|
"validate_dm.check_airport_traffic_exists",
|
||||||
|
"validate_dm.check_route_performance_exists",
|
||||||
|
"validate_dm.check_monthly_overview_exists",
|
||||||
|
"validate_dm.check_passenger_loyalty_exists",
|
||||||
|
}
|
||||||
|
assert expected.issubset(dag.task_dict.keys())
|
||||||
|
|
||||||
|
def test_scd2_chain(self):
|
||||||
|
"""SCD2 цепочка backup → mutate → load → check → restore."""
|
||||||
|
dag = _load_dag("airflow.dags.bookings_validate")
|
||||||
|
_assert_direct_edge(dag, "validate_dds.scd2_backup", "validate_dds.scd2_mutate")
|
||||||
|
_assert_direct_edge(dag, "validate_dds.scd2_mutate", "validate_dds.scd2_run_student_load")
|
||||||
|
_assert_direct_edge(dag, "validate_dds.scd2_run_student_load", "validate_dds.scd2_check")
|
||||||
|
_assert_direct_edge(dag, "validate_dds.scd2_check", "validate_dds.scd2_restore")
|
||||||
|
|
||||||
|
def test_no_gaps_after_restore(self):
|
||||||
|
"""no_gaps должен выполняться после restore (на чистых данных)."""
|
||||||
|
dag = _load_dag("airflow.dags.bookings_validate")
|
||||||
|
_assert_direct_edge(dag, "validate_dds.scd2_restore", "validate_dds.check_dim_routes_no_gaps")
|
||||||
|
|
||||||
|
def test_restore_trigger_rule(self):
|
||||||
|
"""restore должен выполняться всегда (all_done), даже если check упал."""
|
||||||
|
dag = _load_dag("airflow.dags.bookings_validate")
|
||||||
|
restore_task = dag.task_dict["validate_dds.scd2_restore"]
|
||||||
|
assert restore_task.trigger_rule == "all_done"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
1. Создать каталог `sql/validate/`
|
||||||
|
2. Написать SQL-скрипты (17 файлов) — начать с простых (ODS, DM), затем DDS, затем SCD2
|
||||||
|
3. Написать DAG `bookings_validate.py`
|
||||||
|
4. Дополнить `tests/test_dags_smoke.py`
|
||||||
|
5. `make test` — smoke-тесты проходят
|
||||||
|
6. Ручная проверка на стенде (если поднят):
|
||||||
|
- Trigger DAG в Airflow UI
|
||||||
|
- Все ODS/DDS/DM зелёные (на solution-ветке)
|
||||||
|
- SCD2 активный тест: backup → mutate → load → check (зелёный) → restore
|
||||||
|
7. Коммит
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
### Автоматическая
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # smoke-тест DAG-структуры
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ручная (на стенде)
|
||||||
|
|
||||||
|
1. `make up && make ddl-gp` → запустить STG → ODS → DDS → DM
|
||||||
|
2. Trigger `bookings_validate` в Airflow UI
|
||||||
|
3. Проверить: все таски зелёные
|
||||||
|
4. Проверить SCD2: в логах `scd2_check` видно `PASSED: SCD2 корректен для маршрута ...`
|
||||||
|
5. Проверить restore: `ods.routes` и `dds.dim_routes` не изменились после теста
|
||||||
|
|
||||||
|
### Сценарий «студент ещё не реализовал»
|
||||||
|
|
||||||
|
На main-ветке (с DDL-заглушками):
|
||||||
|
- ODS-таски: FAILED (таблицы пусты) → дружелюбное сообщение
|
||||||
|
- DDS-таски: FAILED → сообщение «Реализуйте загрузку»
|
||||||
|
- DM-таски: FAILED → сообщение «Реализуйте загрузку»
|
||||||
|
- SCD2-тест: `scd2_run_student_load` пройдёт (заглушка `SELECT 1;` — валидный SQL),
|
||||||
|
но `scd2_check` упадёт (dim_routes не обновилась, версий < 2)
|
||||||
|
→ `scd2_restore` всё равно выполнится (`trigger_rule="all_done"`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
1. **`dim_passengers_no_dup_bk` — отдельный таск или объединить с `exists`?**
|
||||||
|
Отдельный таск даёт точнее диагностику, но увеличивает число тасков.
|
||||||
|
Рекомендация: объединить проверки в один файл `dim_passengers_exists.sql`
|
||||||
|
(как сделано для `dim_airplanes_exists`).
|
||||||
|
|
||||||
|
2. **Нужна ли проверка `total_seats` в `dim_airplanes`?**
|
||||||
|
`total_seats` вычисляется агрегацией из `ods.seats`. Если студент забудет
|
||||||
|
этот JOIN — поле будет NULL. Можно добавить `SELECT COUNT(*) WHERE total_seats IS NULL`.
|
||||||
|
Рекомендация: добавить в `dim_airplanes_exists.sql`.
|
||||||
|
|
||||||
|
3. **Бэкап SCD2: обычные таблицы vs TEMP?**
|
||||||
|
Каждый таск Airflow — отдельная транзакция → TEMP-таблицы не сохраняются.
|
||||||
|
Используем обычные таблицы с префиксом `_validate_bk_`. Риск: если DAG упадёт
|
||||||
|
между backup и restore, таблицы останутся. Cleanup: `scd2_restore` делает
|
||||||
|
`DROP TABLE IF EXISTS`, поэтому при следующем запуске проблем не будет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые файлы
|
||||||
|
|
||||||
|
| Файл | Роль |
|
||||||
|
|------|------|
|
||||||
|
| `airflow/dags/bookings_validate.py` | DAG: TaskGroup по слоям |
|
||||||
|
| `sql/validate/*.sql` | 17 SQL-скриптов проверок |
|
||||||
|
| `sql/dds/dim_routes_load.sql` | Студенческий load (вызывается из SCD2-теста) |
|
||||||
|
| `tests/test_dags_smoke.py` | Smoke-тесты DAG-структуры |
|
||||||
|
| `docs/design/assignment_design.md` | Дизайн (секция 4 — источник требований) |
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# План: Онбординг студента + маркетинг (v4)
|
||||||
|
|
||||||
|
> Статус: выполнен (2026-03-13)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Ментор опционален. Студент, клонировав main, проходит «Быстрый старт» — и застревает:
|
||||||
|
нет явного «что дальше», ссылка на задания спрятана, validate DAG не объяснён, нет маркетинга.
|
||||||
|
На main есть противоречия: docs и DAG-docstrings говорят «все реализовано»,
|
||||||
|
фактически стоят заглушки `SELECT 1;`.
|
||||||
|
|
||||||
|
## Что было сделано
|
||||||
|
|
||||||
|
### На main (10 файлов)
|
||||||
|
|
||||||
|
1. **README.md** — маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
|
||||||
|
2. **docs/assignment/README.md** — полный гид студента (эталон → ТЗ → заглушки → validate).
|
||||||
|
3. **docs/assignment/analyst_spec.md** — DAG-интеграция: «таски уже подключены, DAG менять не нужно».
|
||||||
|
4. **docs/bookings_to_gp_dm.md** — пометки заглушек, адаптация секций проверки.
|
||||||
|
5. **docs/bookings_to_gp_ods.md** — пометки заглушек airplanes/seats.
|
||||||
|
6. **docs/bookings_to_gp_dds.md** — пометки заглушек dim_routes/dim_passengers/dim_airplanes.
|
||||||
|
7-10. **4 DAG docstrings** — эталон vs задания (заглушки).
|
||||||
|
|
||||||
|
### На solution (follow-up)
|
||||||
|
|
||||||
|
- README.md — маркетинг-баннер + шаг 7 (solution-specific: «Изучите готовую реализацию»).
|
||||||
|
- docs/assignment/README.md — навигационный гид по всем паттернам (ODS → DDS → DM).
|
||||||
|
- Этот план архивирован в docs/archive/.
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
- `make test` — passed на обеих ветках.
|
||||||
|
- `make lint` — clean на обеих ветках.
|
||||||
|
- Grep «заглушки/SELECT 1» в README.md и docs/assignment/README.md на solution — не найдено.
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# Ревью архитектуры слоёв DWH: оценка учебной ценности
|
||||||
|
|
||||||
|
> Дата: 2026-03-01 (ревью), 2026-03-11 (закрытие)
|
||||||
|
> Статус: **завершён** — P0/P1/P2 выполнены, P3 отложены (покрыты другими документами)
|
||||||
|
> Контекст: оценка текущей конструкции слоёв с точки зрения учебных целей
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Стенд — курсовая работа и эталон для менти-джунов. Они понесут эти паттерны на свою первую работу. Оцениваем по двум осям: **production-ready** (чтобы не стыдно было показать на собеседовании) и **KISS** (чтобы джун не утонул в сложности).
|
||||||
|
|
||||||
|
Текущее состояние: 95 SQL-файлов, 5 слоёв (STG→ODS→DDS→DM), 9 DAG-ов, полная Star Schema с SCD2, DQ на каждом шаге. Реализована 1 из 5 витрин DM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## СИЛЬНЫЕ СТОРОНЫ (что уже отлично)
|
||||||
|
|
||||||
|
### 1. Паттерн load → DQ на каждом шаге — эталонный
|
||||||
|
Каждая сущность в каждом слое имеет тройку файлов `_ddl.sql` / `_load.sql` / `_dq.sql`. DAG-и обеспечивают порядок load→dq→next. Smoke-тесты проверяют рёбра графа. Студенты усвоят: **DQ — не опция, а часть пайплайна**.
|
||||||
|
|
||||||
|
### 2. UPSERT через UPDATE + INSERT — production-grade для Greenplum
|
||||||
|
Не DELETE+INSERT (дорого на AO-таблицах), не MERGE (нет в GP6). `IS DISTINCT FROM` для null-safe сравнения — деталь, которую даже опытные инженеры забывают.
|
||||||
|
|
||||||
|
### 3. Антипаттерн-обучение в DM (distribution by date)
|
||||||
|
Комментарий в `dm/sales_report_ddl.sql` объясняет **почему** нельзя распределять по дате, с конкретными причинами (Load Skew, Processing Skew). Это «почему нет» — именно то, что не дают учебники.
|
||||||
|
|
||||||
|
### 4. SCD2 в dim_routes — полный и корректный
|
||||||
|
Все три кейса: закрытие изменённых версий (hashdiff), закрытие исчезнувших маршрутов, вставка новых версий с правильной логикой valid_from. DQ проверяет пересечение интервалов. Готовый reference implementation.
|
||||||
|
|
||||||
|
### 5. Point-in-time lookup в факте — ключевой навык
|
||||||
|
```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)
|
||||||
|
```
|
||||||
|
Многие продакшн-DWH ошибаются, присоединяя только текущую версию.
|
||||||
|
|
||||||
|
### 6. HWM-инкрементальность в DM — самовосстанавливающийся пайплайн
|
||||||
|
`MAX(_load_ts)` + TEMP TABLE для однократной агрегации — канон MPP. Пайплайн сам «догоняет» пропущенные дни.
|
||||||
|
|
||||||
|
### 7. DAG-графы корректно отражают зависимости данных
|
||||||
|
Параллельность airports/airplanes, gates на routes (нужны оба), факт после всех измерений. Smoke-тесты проверяют и наличие, и **отсутствие** рёбер (параллельность).
|
||||||
|
|
||||||
|
### 8. ANALYZE после каждой загрузки
|
||||||
|
GP-специфичная best practice, которую забывают даже опытные команды.
|
||||||
|
|
||||||
|
### 9. Идемпотентные STG-загрузки
|
||||||
|
`NOT EXISTS (... WHERE _load_id = '{{ run_id }}')` — простой, корректный, понятный паттерн для retry-safe загрузок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЗАМЕЧАНИЯ И ЗАДАЧИ ДЛЯ ДОРАБОТКИ
|
||||||
|
|
||||||
|
### P0: Фактическая ошибка (исправить до показа студентам)
|
||||||
|
|
||||||
|
- [x] **ODS batch resolver теряет данные при двух STG-запусках подряд**
|
||||||
|
- Сценарий: STG run_1 загружает день N, STG run_2 загружает день N+1, затем ODS запускается
|
||||||
|
- `_resolve_stg_batch_id()` выбирает только последний согласованный batch (`run_2`)
|
||||||
|
- Все ODS load-скрипты фильтруют `WHERE _load_id = 'run_2'` → данные `run_1` навсегда пропущены
|
||||||
|
- **Справочники** (airports, airplanes, routes, seats): проблемы нет — full snapshot, `run_2` содержит всё
|
||||||
|
- **Транзакционные таблицы** (bookings, tickets, flights, segments, boarding_passes): **потеря данных** — инкрементальные записи `run_1` никогда не попадут в ODS
|
||||||
|
- Корень проблемы: batch resolver проектировался для согласованности справочников (INTERSECT), но тот же single-batch фильтр применяется к транзакционным таблицам, где нужны **все необработанные** batch-и
|
||||||
|
- **Нужно**: разделить логику — для транзакционных таблиц загружать все batch-и с `_load_ts > MAX(_load_ts в ODS)` (аналог HWM из DM), для справочников — по-прежнему последний согласованный
|
||||||
|
- Файлы: `airflow/dags/bookings_to_gp_ods.py`, `sql/ods/bookings_load.sql`, `sql/ods/tickets_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/segments_load.sql`, `sql/ods/boarding_passes_load.sql`
|
||||||
|
|
||||||
|
- [x] **Противоречие в distribution key для airport_traffic**
|
||||||
|
- `bookings_dm_design.md` (строка 182): `DISTRIBUTED BY (traffic_date)`
|
||||||
|
- `sales_report_ddl.sql`: явно объясняет, почему distribution by date — антипаттерн
|
||||||
|
- **Нужно**: исправить на `DISTRIBUTED BY (airport_sk)` в дизайн-документе
|
||||||
|
- Файл: `docs/design/bookings_dm_design.md`
|
||||||
|
|
||||||
|
### P1: Высокий эффект, минимум усилий (комментарии и документация)
|
||||||
|
|
||||||
|
- [x] **Нет объяснения «почему не SERIAL» в генерации SK**
|
||||||
|
- `MAX(sk) + ROW_NUMBER()` корректен для GP, но студент на PostgreSQL/Snowflake будет использовать `IDENTITY`/`SEQUENCE`
|
||||||
|
- **Нужно**: 4-строчный комментарий в `sql/dds/dim_airports_load.sql`
|
||||||
|
|
||||||
|
- [x] **Факт без суррогатного ключа — не объяснено «почему»**
|
||||||
|
- Натуральный (ticket_no, flight_id) как grain — правильное Kimball-моделирование
|
||||||
|
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_ddl.sql`
|
||||||
|
|
||||||
|
- [x] **Нет упоминания cross-DAG зависимостей**
|
||||||
|
- STG, ODS, DDS, DM — отдельные DAG-и с `schedule=None`, студент может не понять порядок
|
||||||
|
- **Нужно**: комментарий в docstring каждого DAG или `docs/dag_execution_order.md`
|
||||||
|
|
||||||
|
- [x] **Late-arriving dimensions не упомянуты**
|
||||||
|
- Факт делает LEFT JOIN → `passenger_sk = NULL` при опоздании; нет механизма исправления
|
||||||
|
- **Нужно**: комментарий в `sql/dds/fact_flight_sales_load.sql` у LEFT JOIN-ов
|
||||||
|
|
||||||
|
- [x] **Batch resolver недообъяснён**
|
||||||
|
- `_resolve_stg_batch_id` с INTERSECT по 4 таблицам — нет комментария **зачем** нужна согласованность
|
||||||
|
- **Нужно**: комментарий в `airflow/dags/bookings_to_gp_ods.py` перед SQL-запросом
|
||||||
|
|
||||||
|
- [x] **`helpers/greenplum.py`** — удалён вместе с CSV-пайплайном (перенесён в airflow-manual)
|
||||||
|
|
||||||
|
### P2: Средние усилия, заметное улучшение качества
|
||||||
|
|
||||||
|
- [x] **Явный storage type для всех таблиц + AO где возможно** ✅ РЕШЕНИЕ ПРИНЯТО
|
||||||
|
- 18 из 28 таблиц имели неявный heap (нет `WITH`) — теперь выбор сделан явно
|
||||||
|
- **Целевая раскладка по storage:**
|
||||||
|
- **AO Row + zstd**: `dds.dim_calendar` (узкая таблица, column-store не даёт выигрыша)
|
||||||
|
- **AO Row + zstd**: ODS snapshot-справочники (`airports`, `airplanes`, `routes`, `seats`)
|
||||||
|
— перевести загрузку с UPSERT на TRUNCATE+INSERT (честнее для full snapshot семантики)
|
||||||
|
- **AO Row + zstd**: `dds.dim_tariffs` (только INSERT, нет UPDATE)
|
||||||
|
- **AO Row + zstd**: `dm.route_performance` (full rebuild, по дизайну)
|
||||||
|
- **Heap (явный)**: ODS транзакционные (`bookings`, `tickets`, `flights`, `segments`,
|
||||||
|
`boarding_passes`) — row-level UPDATE при SCD1 UPSERT
|
||||||
|
- **Heap (явный)**: DDS измерения с UPDATE (`dim_airports`, `dim_airplanes`,
|
||||||
|
`dim_passengers`, `dim_routes`) и `fact_flight_sales`
|
||||||
|
- **Heap (явный)**: DM витрины с UPSERT (`sales_report` и будущие HWM-витрины)
|
||||||
|
- К каждой таблице добавить комментарий, объясняющий выбор storage type
|
||||||
|
- Файлы: все `*_ddl.sql` в ods/, dds/, dm/ + переписать 4 ODS snapshot load-скрипта
|
||||||
|
- См. ADR-3
|
||||||
|
|
||||||
|
- [x] **Дублирование hashdiff CTE в dim_routes_load.sql**
|
||||||
|
- md5(COALESCE(...)) повторяется в Statement 1 и Statement 2, ROW_NUMBER() — 3 раза
|
||||||
|
- **Решение**: вынесено в CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP ✅ ВЫПОЛНЕНО
|
||||||
|
- Файл: `sql/dds/dim_routes_load.sql`
|
||||||
|
|
||||||
|
- [x] **Несогласованность нейминга STG vs ODS+** ✅ ВЫПОЛНЕНО
|
||||||
|
- STG: `batch_id`, `load_dttm`, `src_created_at_ts` → переименованы в канон `_load_id`, `_load_ts`, `event_ts`
|
||||||
|
- Единый словарь во всех слоях снижает когнитивную нагрузку
|
||||||
|
- Секция 6 «Переходный маппинг» удалена из `naming_conventions.md` как неактуальная
|
||||||
|
- Файлы: 27 STG SQL + ODS load-скрипты + `naming_conventions.md` + тесты
|
||||||
|
|
||||||
|
- [x] **Дублирование CTE в ODS load-скриптах**
|
||||||
|
- `WITH src AS (...)` копируется 2-3 раза в каждом из 9 ODS load-файлов
|
||||||
|
- **Решение**: TEMP TABLE для самых сложных (airports, flights, routes); простые — оставить
|
||||||
|
- Файлы: `sql/ods/airports_load.sql`, `sql/ods/flights_load.sql`, `sql/ods/routes_load.sql`
|
||||||
|
- *Заметка*: Для всех транзакционных таблиц ODS внедрен паттерн TEMP TABLE для надежной работы HWM.
|
||||||
|
|
||||||
|
- [x] **DM слой спроектирован**
|
||||||
|
- 5 витрин: `sales_report`, `route_performance`, `passenger_loyalty`, `airport_traffic`, `monthly_overview`
|
||||||
|
- `sales_report`, `route_performance` — эталонные реализации; `passenger_loyalty`, `airport_traffic`, `monthly_overview` — задания для студентов
|
||||||
|
- `route_performance` — full rebuild + AO Column Store
|
||||||
|
|
||||||
|
### P3: Отложено (покрыто другими документами)
|
||||||
|
|
||||||
|
- [~] **Крутая лестница сложности ODS→DDS**
|
||||||
|
- Покрыто: `docs/assignment/analyst_spec.md` содержит рекомендуемый порядок выполнения
|
||||||
|
(STG → ODS → DDS SCD1 → DDS SCD2 → DM от простого к сложному)
|
||||||
|
|
||||||
|
- [~] **DQ без переиспользуемых функций**
|
||||||
|
- Отложено: переусложнение для учебного стенда (AGENTS.md: KISS)
|
||||||
|
|
||||||
|
- [~] **Нет документа по стратегии distribution**
|
||||||
|
- Покрыто: подсказки по DK есть в `analyst_spec.md` и комментариях к каждому DDL
|
||||||
|
|
||||||
|
- [~] **Отсутствующие паттерны** (комментарии/заметки)
|
||||||
|
- Покрыто частично: partitioning и exchange partition описаны в ADR-2 (этот документ).
|
||||||
|
SCD3/6 и data lineage — вне скоупа курсовой
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ПРИНЯТЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ
|
||||||
|
|
||||||
|
### ADR-1: Города, страны, модели самолётов — атрибуты измерений, не отдельные справочники
|
||||||
|
|
||||||
|
**Рассматривалось**: выделить `dim_city`, `dim_country`, `dim_airplane_model` как отдельные
|
||||||
|
измерения со своими суррогатными ключами.
|
||||||
|
|
||||||
|
**Решение**: оставить `city`, `country` как атрибуты `dim_airports`, а `model` — как атрибут
|
||||||
|
`dim_airplanes`. Не создавать отдельные справочники.
|
||||||
|
|
||||||
|
**Обоснование**:
|
||||||
|
1. **Star vs Snowflake.** Kimball-методология рекомендует «wide and flat» измерения.
|
||||||
|
Вынос атрибутов в подтаблицы превращает star schema в snowflake — добавляет 2-3 JOIN-а
|
||||||
|
в каждый запрос к факту без аналитического выигрыша. Для учебного стенда star schema —
|
||||||
|
правильный эталон.
|
||||||
|
2. **Нет самостоятельной сущности в домене.** Город — JSON-атрибут аэропорта в источнике
|
||||||
|
(`airport_name::json->>'ru'`). У него нет своего бизнес-ключа, жизненного цикла,
|
||||||
|
независимых атрибутов. Модель самолёта — аналогично.
|
||||||
|
3. **Когнитивная нагрузка.** Лестница ODS→DDS уже крутая (6 измерений + 1 факт + SCD2).
|
||||||
|
Добавление 2-3 измерений усложнит стенд без пропорционального обучающего эффекта.
|
||||||
|
|
||||||
|
**Когда отдельное измерение оправдано** (для справки студентам):
|
||||||
|
- Город имеет собственные атрибуты из другого источника (население, регион, координаты)
|
||||||
|
→ `dim_geography` как outrigger-измерение
|
||||||
|
- Модель самолёта имеет независимые характеристики (производитель, сертификация, конфигурации)
|
||||||
|
→ `dim_aircraft_type`
|
||||||
|
- В Data Vault — `hub_city` / `hub_country` как самостоятельные бизнес-объекты (другая парадигма)
|
||||||
|
|
||||||
|
### ADR-2: Heap + UPSERT вместо AO + партиционирование + exchange partition
|
||||||
|
|
||||||
|
**Контекст**: Greenplum широко распространён в РФ — на него активно мигрировали при
|
||||||
|
импортозамещении с Teradata и Exadata. Именно поэтому GP выбран для курсовой: опыт работы
|
||||||
|
с ним будет напрямую релевантен первой работе студента. Тем важнее, чтобы студенты понимали,
|
||||||
|
как устроены реальные GP-хранилища, даже если стенд использует упрощённый подход.
|
||||||
|
|
||||||
|
**Рассматривалось**: использовать production-паттерн крупных GP-хранилищ:
|
||||||
|
- AO Column Store (сжатие zlib/zstd, векторное чтение, колоночное хранение)
|
||||||
|
- Range-партиционирование по дате (`PARTITION BY RANGE (flight_date)`)
|
||||||
|
- Обновление через замену партиций (`ALTER TABLE EXCHANGE PARTITION`) или
|
||||||
|
`DELETE + INSERT` в рамках одной партиции вместо row-level UPDATE
|
||||||
|
|
||||||
|
**Решение**: партиционирование не применяем (учебные объёмы). Для storage —
|
||||||
|
дифференцированный подход: heap для таблиц с UPDATE, AO для иммутабельных
|
||||||
|
(см. ADR-3 с полной раскладкой).
|
||||||
|
|
||||||
|
**Обоснование**:
|
||||||
|
1. **Универсальность паттерна.** UPSERT через UPDATE + INSERT работает в PostgreSQL,
|
||||||
|
Snowflake, BigQuery, Redshift — везде. Exchange partition — GP-специфика
|
||||||
|
(`ALTER TABLE ... EXCHANGE PARTITION FOR (...) WITH TABLE tmp_...`).
|
||||||
|
Студент, освоив UPSERT, сможет применить его на любой платформе.
|
||||||
|
2. **Объём данных.** На учебных ~100K строк партиционирование не даёт partition pruning
|
||||||
|
эффекта, зато утраивает DDL (стратегия, sub-partitions, retention policy).
|
||||||
|
Выигрыш нулевой, когнитивная нагрузка — существенная.
|
||||||
|
3. **Простота ментальной модели.** «Вот строка, она обновилась» понятнее, чем «вот партиция,
|
||||||
|
она заменилась целиком». Второй паттерн требует понимания storage engine, что выходит
|
||||||
|
за рамки первого курса DWH.
|
||||||
|
|
||||||
|
**Что студенту важно знать про реальный GP** (для менти):
|
||||||
|
|
||||||
|
На продакшн-хранилищах с десятками и сотнями миллионов строк подход меняется принципиально:
|
||||||
|
|
||||||
|
| Аспект | Стенд (учебный) | Продакшн (реальный GP) |
|
||||||
|
|--------|-----------------|----------------------|
|
||||||
|
| Хранение фактов | Heap (row-oriented) | AO Column Store (сжатие, колонки) |
|
||||||
|
| Партиционирование | Нет | Range по дате (день/месяц) |
|
||||||
|
| Обновление | Row-level UPDATE | Exchange partition или DELETE+INSERT в партиции |
|
||||||
|
| Причина | UPDATE на AO «раздувает» таблицу (помечает строки deleted, дописывает новые) | |
|
||||||
|
| Когда переходить | > 10M строк, или когда VACUUM не справляется | |
|
||||||
|
|
||||||
|
Типичный production-паттерн загрузки факта по дням:
|
||||||
|
```sql
|
||||||
|
-- 1. Собрать новую партицию во временную таблицу
|
||||||
|
CREATE TABLE tmp_fact_20170102 (LIKE dds.fact_flight_sales)
|
||||||
|
WITH (appendonly=true, orientation=column, compresstype=zstd);
|
||||||
|
INSERT INTO tmp_fact_20170102 SELECT ... FROM ods... WHERE flight_date = '2017-01-02';
|
||||||
|
|
||||||
|
-- 2. Атомарно заменить партицию (без DELETE, без UPDATE)
|
||||||
|
ALTER TABLE dds.fact_flight_sales
|
||||||
|
EXCHANGE PARTITION FOR ('2017-01-02') WITH TABLE tmp_fact_20170102;
|
||||||
|
|
||||||
|
-- 3. Удалить временную таблицу (теперь в ней старые данные)
|
||||||
|
DROP TABLE tmp_fact_20170102;
|
||||||
|
```
|
||||||
|
|
||||||
|
Преимущества exchange partition:
|
||||||
|
- Нет row-level UPDATE → нет bloat, не нужен VACUUM
|
||||||
|
- AO Column Store даёт 5-10x сжатие и быстрые аналитические скана
|
||||||
|
- Partition pruning: запрос `WHERE flight_date = '2017-01-02'` читает только одну партицию
|
||||||
|
- Атомарность: EXCHANGE — одна DDL-команда, нет окна неконсистентности
|
||||||
|
|
||||||
|
### ADR-3: Явный storage type для каждой таблицы + AO где нет UPDATE
|
||||||
|
|
||||||
|
**Проблема**: 18 из 28 таблиц в ODS/DDS/DM создаются без `WITH`-клаузы. GP по умолчанию
|
||||||
|
создаёт heap, но студент не видит осознанного выбора — таблица «просто создаётся».
|
||||||
|
В учебном стенде каждое решение должно быть видимым и объяснённым.
|
||||||
|
|
||||||
|
**Решение**: добавить явный `WITH (...)` ко всем таблицам. Где row-level UPDATE не нужен —
|
||||||
|
перевести на AO (Row или Column) с компрессией.
|
||||||
|
|
||||||
|
**Целевая раскладка storage по таблицам:**
|
||||||
|
|
||||||
|
| Storage | Таблицы | Почему |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| **AO Column** zstd | `dds.dim_calendar` | Write-once (generate_series), никогда не обновляется. Колоночное хранение идеально для аналитических скан. |
|
||||||
|
| **AO Column** zstd | `dm.route_performance` | Full rebuild (TRUNCATE+INSERT), чисто аналитические чтения. |
|
||||||
|
| **AO Row** zstd | STG: все 9 таблиц | Уже реализовано. Append-only, иммутабельные батчи. Примечание: используем **zstd (level 1)** вместо zlib, так как он обеспечивает более высокую скорость декомпрессии и лучшее сжатие в современных GP-кластерах (6.0+). |
|
||||||
|
| **AO Row** zstd | ODS snapshot: `airports`, `airplanes`, `routes`, `seats` | Полный snapshot каждый раз. Перевести загрузку с UPSERT на TRUNCATE+INSERT — честнее для семантики «текущий срез». |
|
||||||
|
| **AO Row** zstd | `dds.dim_tariffs` | Только INSERT новых тарифов, UPDATE не используется. |
|
||||||
|
| **Heap** (явный) | ODS транзакционные: `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Row-level UPDATE при SCD1 UPSERT. Heap обязателен. |
|
||||||
|
| **Heap** (явный) | DDS измерения с UPDATE: `dim_airports`, `dim_airplanes`, `dim_passengers`, `dim_routes` | SCD1/SCD2 UPSERT с row-level UPDATE. |
|
||||||
|
| **Heap** (явный) | `dds.fact_flight_sales` | UPDATE (is_boarded, seat_no меняются). |
|
||||||
|
| **Heap** (явный) | DM витрины с UPSERT: `sales_report` и будущие HWM-витрины | Row-level UPDATE при инкрементальном UPSERT. |
|
||||||
|
|
||||||
|
**Учебная ценность**: студенты видят на практике три storage-стратегии в одном проекте:
|
||||||
|
1. AO Column — для иммутабельных аналитических таблиц (dim_calendar, route_performance)
|
||||||
|
2. AO Row — для append-only данных и snapshot-справочников (STG, ODS refs, dim_tariffs)
|
||||||
|
3. Heap — для таблиц с row-level UPDATE (ODS транзакции, DDS dims с UPSERT, факт, DM)
|
||||||
|
|
||||||
|
И понимают **почему** выбор именно такой: UPDATE на AO = bloat + необходимость VACUUM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЧТО ОСТАВИТЬ КАК ЕСТЬ
|
||||||
|
|
||||||
|
| Аспект | Почему не трогаем |
|
||||||
|
|--------|-------------------|
|
||||||
|
| Факт без SK | Правильное моделирование, нужен только комментарий (P1) |
|
||||||
|
| ODS DAG с 20 задачами | Не перегружает — параллельная структура наглядна на графе |
|
||||||
|
| 5 витрин DM | Правильное количество, каждая учит своему паттерну |
|
||||||
|
| PL/pgSQL DQ | Достаточно для учебного проекта, фреймворк — перебор |
|
||||||
|
| MAX+ROW_NUMBER для SK | Корректно для GP, нужен только комментарий (P1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка по трудозатратам
|
||||||
|
|
||||||
|
Все задачи P0–P2 выполнены. P3 отложены (покрыты другими документами).
|
||||||
|
|
||||||
|
| Приоритет | Действие | Статус |
|
||||||
|
|-----------|----------|--------|
|
||||||
|
| ~~P0~~ | ODS batch resolver: разделить логику для справочников и транзакций | ✅ |
|
||||||
|
| ~~P0~~ | Исправить distribution key в airport_traffic | ✅ |
|
||||||
|
| ~~P1~~ | Добавить 7 точечных комментариев | ✅ |
|
||||||
|
| ~~P2~~ | Явный storage type + AO где нет UPDATE (ADR-3) | ✅ |
|
||||||
|
| ~~P2~~ | Рефакторинг hashdiff → TEMP TABLE | ✅ |
|
||||||
|
| ~~P2~~ | Переименовать STG поля в канон | ✅ |
|
||||||
|
| ~~P2~~ | TEMP TABLE для сложных ODS load-ов | ✅ |
|
||||||
|
| ~~P2~~ | Реализовать DM слой (5 витрин) | ✅ |
|
||||||
|
| P3 | Маршрут изучения DDS | Покрыто: `analyst_spec.md` |
|
||||||
|
| P3 | DQ-функции, distribution strategy, паттерны | Отложено |
|
||||||
+35
-62
@@ -1,79 +1,52 @@
|
|||||||
# Учебные задания
|
# Учебные задания
|
||||||
|
|
||||||
## О чём задание
|
> Это ветка `solution` — здесь всё уже реализовано.
|
||||||
|
> Используйте её как справочник, а задания выполняйте на ветке `main`.
|
||||||
|
|
||||||
В стенде уже работает **эталонный вертикальный срез** — полная цепочка
|
## Навигатор по реализации
|
||||||
`STG → ODS → DDS → DM` для витрины `dm.sales_report`. Ваша задача —
|
|
||||||
реализовать недостающие загрузки по аналогии с эталоном.
|
|
||||||
|
|
||||||
На месте ваших заданий сейчас стоят **заглушки** (`SELECT 1;`). Вы замените
|
На этой ветке полностью работает пайплайн `STG → ODS → DDS → DM`.
|
||||||
их на реальную SQL-логику, и после этого весь DWH будет заполнен.
|
Ниже — рекомендуемый порядок изучения: от простых паттернов к сложным.
|
||||||
|
|
||||||
## Перед началом
|
### ODS: загрузка из STG
|
||||||
|
|
||||||
Убедитесь, что стенд запущен и данные загружены —
|
| Паттерн | Файл | Что посмотреть |
|
||||||
пройдите шаги 1–6 из [README (Быстрый старт)](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
|
|---------|------|----------------|
|
||||||
|
| TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
|
||||||
|
| SCD1 UPSERT (транзакционные данные) | `sql/ods/bookings_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT, HWM |
|
||||||
|
|
||||||
После этого в Greenplum будут данные в эталонных таблицах (`stg.*`, `ods.bookings`,
|
### DDS: измерения и факт
|
||||||
`dds.dim_airports`, `dds.fact_flight_sales`, `dm.sales_report` и др.).
|
|
||||||
|
|
||||||
## Шаг 1. Изучите эталон
|
| Паттерн | Файл | Что посмотреть |
|
||||||
|
|---------|------|----------------|
|
||||||
|
| SCD1 (простое измерение) | `sql/dds/dim_airports_load.sql` | UPSERT с суррогатным ключом |
|
||||||
|
| SCD1 с обогащением | `sql/dds/dim_airplanes_load.sql` | JOIN с `ods.seats` для `total_seats` |
|
||||||
|
| SCD2 (версионирование) | `sql/dds/dim_routes_load.sql` | `hashdiff`, `valid_from`/`valid_to`, денормализация |
|
||||||
|
| Факт с point-in-time JOIN | `sql/dds/fact_flight_sales_load.sql` | SCD2 lookup, защитные LEFT JOIN |
|
||||||
|
|
||||||
Прежде чем писать код, разберите, как устроены эталонные загрузки.
|
### DM: витрины
|
||||||
По одному файлу на каждый ключевой паттерн:
|
|
||||||
|
|
||||||
| Паттерн | Файл для изучения | Что посмотреть |
|
| Паттерн | Файл | Что посмотреть |
|
||||||
|---------|-------------------|----------------|
|
|---------|------|----------------|
|
||||||
| ODS: TRUNCATE + INSERT (snapshot-справочник) | `sql/ods/airports_load.sql` | Фильтрация по `stg_batch_id`, дедупликация через `ROW_NUMBER()` |
|
| UPSERT с HWM по датам | `sql/dm/sales_report_load.sql` | Инкрементальная агрегация фактов |
|
||||||
| DDS: SCD1 UPSERT (измерение) | `sql/dds/dim_airports_load.sql` | TEMP TABLE → UPDATE (`IS DISTINCT FROM`) → INSERT |
|
| Full Rebuild (AO Column) | `sql/dm/route_performance_load.sql` | TRUNCATE + INSERT, агрегация по SCD2 `route_bk` |
|
||||||
| DM: витрина с UPSERT | `sql/dm/sales_report_load.sql` | HWM-инкремент, агрегация фактов по измерениям |
|
| UNION ALL (dual-role dimension) | `sql/dm/airport_traffic_load.sql` | Разворот факта: departure + arrival |
|
||||||
|
| Двухуровневая агрегация | `sql/dm/monthly_overview_load.sql` | Честный `avg_load_factor` через 2 уровня |
|
||||||
|
| Метод затронутых ключей | `sql/dm/passenger_loyalty_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)
|
|
||||||
|
|
||||||
## Шаг 2. Откройте ТЗ
|
Основной документ: **[analyst_spec.md](analyst_spec.md)** — ТЗ от аналитика
|
||||||
|
с описанием всех таблиц, маппингами, бизнес-правилами и подсказками.
|
||||||
|
|
||||||
Техническое задание от аналитика: **[analyst_spec.md](analyst_spec.md)**.
|
## Описания DAG-ов
|
||||||
|
|
||||||
Там описаны все таблицы, которые нужно реализовать, с маппингами, бизнес-правилами
|
- [STG](../bookings_to_gp_stage.md) ·
|
||||||
и подсказками. Рекомендуемый порядок (от простого к сложному):
|
[ODS](../bookings_to_gp_ods.md) ·
|
||||||
|
[DDS](../bookings_to_gp_dds.md) ·
|
||||||
1. ODS: `airplanes`, `seats`
|
[DM](../bookings_to_gp_dm.md)
|
||||||
2. DDS: `dim_airplanes`, `dim_passengers` (SCD1)
|
- [Порядок запуска DAG-ов](../reference/dag_execution_order.md)
|
||||||
3. DDS: `dim_routes` (SCD2 — ключевой вызов курсовой)
|
|
||||||
4. DM: `airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`
|
|
||||||
|
|
||||||
## Шаг 3. Реализуйте задания
|
|
||||||
|
|
||||||
- **Файлы-заглушки уже на месте** — например, `sql/ods/airplanes_load.sql`
|
|
||||||
содержит `SELECT 1;`. Ваша задача: заменить `SELECT 1;` на реальную SQL-логику.
|
|
||||||
- **DDL уже создан** — таблицы существуют, создавать их не нужно.
|
|
||||||
- **Таски в DAG-ах уже подключены** — `PostgresOperator` ссылается на ваши SQL-файлы.
|
|
||||||
Менять Python-код DAG-ов не нужно.
|
|
||||||
- **Нейминг полей:** сверяйтесь с `docs/design/naming_conventions.md`.
|
|
||||||
|
|
||||||
## Шаг 4. Проверьте себя валидационным DAG-ом
|
|
||||||
|
|
||||||
DAG **`bookings_validate`** — автоматическая проверка вашей реализации.
|
|
||||||
|
|
||||||
- Три группы проверок: `validate_ods`, `validate_dds`, `validate_dm` — запускаются
|
|
||||||
параллельно. Можно проверять ODS, пока DDS ещё не готов.
|
|
||||||
- Включает активный SCD2-тест для `dim_routes`: загружает изменённые данные
|
|
||||||
и проверяет, что версионирование работает корректно.
|
|
||||||
- Сообщения об ошибках подскажут, что именно не так и что делать дальше.
|
|
||||||
|
|
||||||
**Цель: все проверки зелёные.**
|
|
||||||
|
|
||||||
## Если застряли
|
|
||||||
|
|
||||||
- Изучите эталонные SQL-скрипты — в них те же паттерны, что нужны для заданий.
|
|
||||||
- Загляните в `docs/design/naming_conventions.md` — единый источник нейминга полей.
|
|
||||||
- Сверьтесь с **веткой `solution`** — там полная рабочая реализация.
|
|
||||||
|
|
||||||
## Для менторов
|
## Для менторов
|
||||||
|
|
||||||
Дизайн заданий и педагогическая логика — в ветке `solution`
|
Дизайн заданий и педагогическая логика: [docs/design/assignment_design.md](../design/assignment_design.md).
|
||||||
(`docs/design/assignment_design.md`).
|
|
||||||
|
|||||||
@@ -41,9 +41,8 @@
|
|||||||
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
|
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
|
||||||
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
|
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
|
||||||
(например, `sql/stg/airplanes_ddl.sql`, `sql/stg/airplanes_load.sql`).
|
(например, `sql/stg/airplanes_ddl.sql`, `sql/stg/airplanes_load.sql`).
|
||||||
- **DAG-интеграция:** таски для всех заданий уже подключены в DAG-файлах
|
- **DAG-интеграция:** используйте `PostgresOperator` + путь к SQL-файлу.
|
||||||
соответствующего слоя (`PostgresOperator` + путь к SQL-файлу).
|
Добавьте таски в существующие DAG-файлы соответствующего слоя.
|
||||||
Вам нужно только заменить содержимое SQL-файлов — DAG менять не нужно.
|
|
||||||
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
|
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
|
||||||
при повторном запуске (не создавать дубликатов).
|
при повторном запуске (не создавать дубликатов).
|
||||||
- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`.
|
- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`.
|
||||||
|
|||||||
+17
-29
@@ -11,9 +11,6 @@ point-in-time join, защитные LEFT JOIN для устойчивости
|
|||||||
- `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` — SCD1 UPSERT;
|
- `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` — SCD1 UPSERT;
|
||||||
- `dds.dim_routes` — **SCD2** с `hashdiff`, `valid_from`, `valid_to` + денормализация.
|
- `dds.dim_routes` — **SCD2** с `hashdiff`, `valid_from`, `valid_to` + денормализация.
|
||||||
- Загружает факт `dds.fact_flight_sales` — инкрементальный UPSERT по зерну `(ticket_no, flight_id)`.
|
- Загружает факт `dds.fact_flight_sales` — инкрементальный UPSERT по зерну `(ticket_no, flight_id)`.
|
||||||
|
|
||||||
> На ветке `main` измерения `dim_routes`, `dim_passengers`, `dim_airplanes` —
|
|
||||||
> заглушки. Эталон: `dim_calendar`, `dim_airports`, `dim_tariffs`, `fact_flight_sales`.
|
|
||||||
- Для каждой таблицы выполняет пару задач `load → dq`.
|
- Для каждой таблицы выполняет пару задач `load → dq`.
|
||||||
- Использует `_load_id = {{ run_id }}`. DDS не требует `stg_batch_id`, потому что читает
|
- Использует `_load_id = {{ run_id }}`. DDS не требует `stg_batch_id`, потому что читает
|
||||||
текущее состояние ODS.
|
текущее состояние ODS.
|
||||||
@@ -71,15 +68,12 @@ load_dds_dim_calendar → dq_dds_dim_calendar
|
|||||||
| # | Задача | SQL-файлы | Что загружает |
|
| # | Задача | SQL-файлы | Что загружает |
|
||||||
|---|--------|-----------|---------------|
|
|---|--------|-----------|---------------|
|
||||||
| 2 | `load_dds_dim_airports` → `dq_dds_dim_airports` | `sql/dds/dim_airports_load.sql`, `sql/dds/dim_airports_dq.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 |
|
| 3 | `load_dds_dim_airplanes` → `dq_dds_dim_airplanes` | `sql/dds/dim_airplanes_load.sql`, `sql/dds/dim_airplanes_dq.sql` | Самолёты (код, модель, кол-во мест) |
|
||||||
| 4 | `load_dds_dim_tariffs` → `dq_dds_dim_tariffs` | `sql/dds/dim_tariffs_load.sql`, `sql/dds/dim_tariffs_dq.sql` | Тарифы (класс обслуживания) |
|
| 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 |
|
| 5 | `load_dds_dim_passengers` → `dq_dds_dim_passengers` | `sql/dds/dim_passengers_load.sql`, `sql/dds/dim_passengers_dq.sql` | Пассажиры (ID, имя, контакты) |
|
||||||
|
|
||||||
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
|
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
|
||||||
|
|
||||||
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
|
|
||||||
> Описание ниже — спецификация того, что нужно реализовать. Образец SCD2 — в ветке `solution`.
|
|
||||||
|
|
||||||
### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2)
|
### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2)
|
||||||
|
|
||||||
- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql`
|
- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql`
|
||||||
@@ -114,19 +108,20 @@ load_dds_dim_calendar → dq_dds_dim_calendar
|
|||||||
Три учебных приёма в этом скрипте:
|
Три учебных приёма в этом скрипте:
|
||||||
|
|
||||||
**Защитные LEFT JOIN (defensive coding).**
|
**Защитные LEFT JOIN (defensive coding).**
|
||||||
Все JOIN-ы с измерениями — `LEFT JOIN`. На ветке `solution` все измерения заполнены,
|
Все JOIN-ы с измерениями — `LEFT JOIN`. DAG гарантирует, что все измерения загружены
|
||||||
и NULL SK не возникают в штатном режиме. На ветке `main` студенческие измерения
|
и прошли DQ **до** старта факта (жёсткие зависимости в графе). Поэтому в штатном режиме
|
||||||
(`dim_passengers`, `dim_routes`, `dim_airplanes`) — заглушки, поэтому соответствующие
|
NULL SK не возникают. LEFT JOIN здесь — защита от data quality аномалий (например, если
|
||||||
SK будут NULL до реализации студентом.
|
в `ods.routes` появится маршрут с несуществующим аэропортом).
|
||||||
|
|
||||||
DQ-проверки факта на main:
|
DQ-проверки факта отражают эту логику:
|
||||||
- `tariff_sk` — **запрещён** NULL (EXCEPTION);
|
- `passenger_sk` и `tariff_sk` — **запрещены** NULL целиком (0 строк);
|
||||||
- `departure_airport_sk`, `arrival_airport_sk` (через `ods.routes`, эталон) — порог **1%** NULL (EXCEPTION);
|
- route-related FK (`route_sk`, `airport_sk`, `airplane_sk`) и `calendar_sk` —
|
||||||
- `passenger_sk`, `route_sk`, `airplane_sk` (студенческие) — только **NOTICE** (100% NULL допустимо);
|
допускается до **1%** NULL (NOTICE-предупреждение), при превышении — EXCEPTION.
|
||||||
- `calendar_sk` — порог **1%** NULL.
|
|
||||||
|
|
||||||
> После реализации всех измерений: `TRUNCATE dds.fact_flight_sales` → перезагрузка →
|
> Это **не** паттерн late-arriving dimensions (опаздывающих измерений) в классическом
|
||||||
> все SK заполнены. Полную версию DQ см. в ветке `solution`.
|
> понимании: backfill NULL SK при повторном запуске не реализован.
|
||||||
|
> В боевых системах для этого используют «строку-заглушку» (unknown member, SK = 0)
|
||||||
|
> и отдельный процесс backfill.
|
||||||
|
|
||||||
**Два пути lookup для аэропортов и маршрутов.**
|
**Два пути lookup для аэропортов и маршрутов.**
|
||||||
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
|
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
|
||||||
@@ -162,8 +157,7 @@ make gp-psql
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT COUNT(*) FROM dds.dim_calendar;
|
SELECT COUNT(*) FROM dds.dim_calendar;
|
||||||
SELECT COUNT(*) FROM dds.dim_airports;
|
SELECT COUNT(*) FROM dds.dim_routes;
|
||||||
SELECT COUNT(*) FROM dds.dim_tariffs;
|
|
||||||
SELECT COUNT(*) FROM dds.fact_flight_sales;
|
SELECT COUNT(*) FROM dds.fact_flight_sales;
|
||||||
|
|
||||||
-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments
|
-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments
|
||||||
@@ -171,20 +165,14 @@ SELECT
|
|||||||
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
|
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
|
||||||
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
|
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
|
||||||
|
|
||||||
-- dim_routes, dim_passengers, dim_airplanes — заглушки на main.
|
-- Проверка SCD2: текущие версии маршрутов (valid_to IS NULL)
|
||||||
-- Проверки ниже станут осмысленны после реализации задания.
|
|
||||||
SELECT COUNT(*) FROM dds.dim_routes;
|
|
||||||
|
|
||||||
-- Проверка SCD2 (после реализации dim_routes):
|
|
||||||
-- текущие версии маршрутов (valid_to IS NULL)
|
|
||||||
SELECT COUNT(*) AS current_versions,
|
SELECT COUNT(*) AS current_versions,
|
||||||
(SELECT COUNT(*) FROM dds.dim_routes) AS total_versions
|
(SELECT COUNT(*) FROM dds.dim_routes) AS total_versions
|
||||||
FROM dds.dim_routes
|
FROM dds.dim_routes
|
||||||
WHERE valid_to IS NULL;
|
WHERE valid_to IS NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
Ожидаемо: `fact_rows ≈ ods_rows`, `dim_calendar` и `dim_airports` непусты.
|
Ожидаемо: `fact_rows = ods_rows`, `current_versions ≤ total_versions`.
|
||||||
`dim_routes` — после реализации задания: `current_versions ≤ total_versions`.
|
|
||||||
|
|
||||||
## Типичные ошибки
|
## Типичные ошибки
|
||||||
|
|
||||||
|
|||||||
+15
-25
@@ -1,7 +1,6 @@
|
|||||||
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
|
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
|
||||||
|
|
||||||
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
|
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
|
||||||
Эталон: витрина `sales_report`. Остальные 4 витрины — задания (заглушки `SELECT 1;` на ветке `main`).
|
|
||||||
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
|
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
|
||||||
это ключевая учебная ценность данного DAG.
|
это ключевая учебная ценность данного DAG.
|
||||||
|
|
||||||
@@ -9,13 +8,13 @@
|
|||||||
|
|
||||||
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
|
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
|
||||||
|
|
||||||
| Витрина | Зерно (grain) | Паттерн загрузки | Статус на main |
|
| Витрина | Зерно (grain) | Паттерн загрузки |
|
||||||
|---------|---------------|------------------|----------------|
|
|---------|---------------|------------------|
|
||||||
| `dm.sales_report` | (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk) | Инкрементальный UPSERT (HWM по датам) | Эталон |
|
| `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.route_performance` | route_bk | Full Rebuild (TRUNCATE + INSERT) |
|
||||||
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) | Задание (заглушка) |
|
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) |
|
||||||
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) | Задание (заглушка) |
|
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) |
|
||||||
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) | Задание (заглушка) |
|
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) |
|
||||||
|
|
||||||
## Что должно быть готово перед запуском
|
## Что должно быть готово перед запуском
|
||||||
|
|
||||||
@@ -69,9 +68,6 @@ start_dm
|
|||||||
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
|
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
|
||||||
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
|
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
|
||||||
|
|
||||||
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
|
|
||||||
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
|
|
||||||
|
|
||||||
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
|
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
|
||||||
|
|
||||||
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
|
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
|
||||||
@@ -93,9 +89,6 @@ start_dm
|
|||||||
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
|
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
|
||||||
`avg_ticket_price = total_revenue / total_tickets`.
|
`avg_ticket_price = total_revenue / total_tickets`.
|
||||||
|
|
||||||
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
|
|
||||||
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
|
|
||||||
|
|
||||||
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
|
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
|
||||||
|
|
||||||
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
|
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
|
||||||
@@ -112,9 +105,6 @@ start_dm
|
|||||||
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
|
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
|
||||||
при data quality аномалиях в измерениях).
|
при data quality аномалиях в измерениях).
|
||||||
|
|
||||||
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
|
|
||||||
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
|
|
||||||
|
|
||||||
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
|
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
|
||||||
|
|
||||||
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
|
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
|
||||||
@@ -126,9 +116,6 @@ start_dm
|
|||||||
в два «события» (вылет из одного аэропорта и прилёт в другой).
|
в два «события» (вылет из одного аэропорта и прилёт в другой).
|
||||||
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
|
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
|
||||||
|
|
||||||
> **Задание.** На ветке `main` этот скрипт — заглушка (`SELECT 1;`).
|
|
||||||
> Реализуйте по описанию ниже и ТЗ в `analyst_spec.md`.
|
|
||||||
|
|
||||||
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
|
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
|
||||||
|
|
||||||
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
|
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
|
||||||
@@ -158,16 +145,19 @@ make gp-psql
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT COUNT(*) FROM dm.sales_report;
|
SELECT COUNT(*) FROM dm.sales_report;
|
||||||
SELECT COUNT(*) FROM dm.route_performance; -- будет непусто после реализации задания
|
SELECT COUNT(*) FROM dm.route_performance;
|
||||||
SELECT COUNT(*) FROM dm.passenger_loyalty; -- будет непусто после реализации задания
|
SELECT COUNT(*) FROM dm.passenger_loyalty;
|
||||||
SELECT COUNT(*) FROM dm.airport_traffic; -- будет непусто после реализации задания
|
SELECT COUNT(*) FROM dm.airport_traffic;
|
||||||
SELECT COUNT(*) FROM dm.monthly_overview; -- будет непусто после реализации задания
|
SELECT COUNT(*) FROM dm.monthly_overview;
|
||||||
|
|
||||||
-- Инвариант sales_report: посаженных не больше, чем продано
|
-- Инвариант sales_report: посаженных не больше, чем продано
|
||||||
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
|
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
|
||||||
|
|
||||||
|
-- Нет дублей по бизнес-ключу route_performance
|
||||||
|
SELECT route_bk, COUNT(*) FROM dm.route_performance GROUP BY route_bk HAVING COUNT(*) > 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
Ожидаемо: `dm.sales_report` непуста. Остальные — после реализации заданий.
|
Ожидаемо: все витрины непусты, инварианты соблюдены, дублей нет.
|
||||||
|
|
||||||
## Типичные ошибки
|
## Типичные ошибки
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
- Определяет `stg_batch_id` — последний согласованный батч, по которому все 4 snapshot-справочника
|
- Определяет `stg_batch_id` — последний согласованный батч, по которому все 4 snapshot-справочника
|
||||||
(`airports`, `airplanes`, `routes`, `seats`) уже приехали в STG.
|
(`airports`, `airplanes`, `routes`, `seats`) уже приехали в STG.
|
||||||
- Загружает 9 таблиц ODS: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`,
|
- Загружает 9 таблиц ODS: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`,
|
||||||
`flights`, `segments`, `boarding_passes` (на ветке `main` загрузка `airplanes` и `seats` —
|
`flights`, `segments`, `boarding_passes`.
|
||||||
заглушки; реализуйте их по ТЗ в `docs/assignment/analyst_spec.md`).
|
|
||||||
- Для каждой таблицы выполняет пару задач `load → dq`.
|
- Для каждой таблицы выполняет пару задач `load → dq`.
|
||||||
- Snapshot-справочники фильтруются по `stg_batch_id`, транзакционные таблицы — по HWM (`_load_ts`).
|
- Snapshot-справочники фильтруются по `stg_batch_id`, транзакционные таблицы — по HWM (`_load_ts`).
|
||||||
- Для snapshot-справочников дополнительно синхронизирует ключи (удаляет из ODS записи,
|
- Для snapshot-справочников дополнительно синхронизирует ключи (удаляет из ODS записи,
|
||||||
@@ -98,9 +97,9 @@ resolve_stg_batch_id
|
|||||||
| 2 | `load_ods_bookings` → `dq_ods_bookings` | `sql/ods/bookings_load.sql`, `sql/ods/bookings_dq.sql` | HWM (инкремент) |
|
| 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 (инкремент) |
|
| 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` |
|
| 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 |
|
| 5 | `load_ods_airplanes` → `dq_ods_airplanes` | `sql/ods/airplanes_load.sql`, `sql/ods/airplanes_dq.sql` | snapshot по `stg_batch_id` |
|
||||||
| 6 | `load_ods_routes` → `dq_ods_routes` | `sql/ods/routes_load.sql`, `sql/ods/routes_dq.sql` | snapshot по `stg_batch_id` |
|
| 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 |
|
| 7 | `load_ods_seats` → `dq_ods_seats` | `sql/ods/seats_load.sql`, `sql/ods/seats_dq.sql` | snapshot по `stg_batch_id` |
|
||||||
| 8 | `load_ods_flights` → `dq_ods_flights` | `sql/ods/flights_load.sql`, `sql/ods/flights_dq.sql` | HWM (инкремент) |
|
| 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 (инкремент) |
|
| 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 (инкремент) |
|
| 10 | `load_ods_boarding_passes` → `dq_ods_boarding_passes` | `sql/ods/boarding_passes_load.sql`, `sql/ods/boarding_passes_dq.sql` | HWM (инкремент) |
|
||||||
@@ -119,9 +118,6 @@ resolve_stg_batch_id
|
|||||||
> который не поддерживает эффективный row-level UPDATE (вызывает bloat).
|
> который не поддерживает эффективный row-level UPDATE (вызывает bloat).
|
||||||
> Для маленьких справочников (~100–300 строк) полная перезагрузка быстрее и чище.
|
> Для маленьких справочников (~100–300 строк) полная перезагрузка быстрее и чище.
|
||||||
|
|
||||||
> На ветке `main` загрузка `airplanes` и `seats` — заглушки.
|
|
||||||
> Паттерн TRUNCATE + INSERT описан выше; используйте `airports_load.sql` как образец.
|
|
||||||
|
|
||||||
**Транзакционные таблицы** (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) —
|
**Транзакционные таблицы** (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) —
|
||||||
**SCD1 UPSERT**:
|
**SCD1 UPSERT**:
|
||||||
|
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ LIMIT 10;
|
|||||||
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
|
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
|
||||||
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
|
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
|
||||||
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
|
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
|
||||||
Для технических деталей PXF см. ветку `solution` (`docs/reference/pxf_bookings.md`).
|
Для технических деталей см. `docs/reference/pxf_bookings.md`.
|
||||||
|
|
||||||
## Рекомендации по качеству решения
|
## Рекомендации по качеству решения
|
||||||
|
|
||||||
Ревью решения и список улучшений — в ветке `solution` (`docs/archive/`).
|
Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения:
|
||||||
|
`docs/archive/bookings_stg_code_review.md`.
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# PRD: Greenplum Bookings DWH
|
||||||
|
|
||||||
|
> Курсовая работа для курса [DE Roadmap](https://github.com/dementev-dev/de-roadmap).
|
||||||
|
> Статус: **ЧЕРНОВИК v0.1** | Дата: 2026-03-08
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Видение продукта
|
||||||
|
|
||||||
|
**Greenplum Bookings DWH** — учебный стенд, на котором студент самостоятельно строит
|
||||||
|
end-to-end ETL-пайплайн: от базы-источника до аналитических витрин.
|
||||||
|
|
||||||
|
Стенд имитирует реальную рабочую задачу Data-инженера:
|
||||||
|
- Есть «боевая» система-источник (bookings-db), в которой каждый день появляются
|
||||||
|
новые данные — как в жизни, без ограниченного объёма.
|
||||||
|
- Есть DWH на Greenplum с классическими слоями (STG → ODS → DDS → DM).
|
||||||
|
- Есть Airflow, оркестрирующий загрузку.
|
||||||
|
- Есть ТЗ от «аналитика» с описанием ожидаемых таблиц и маппингов.
|
||||||
|
|
||||||
|
Студент получает **частично реализованный пайплайн** (эталонный вертикальный срез)
|
||||||
|
и **дореализует остальное** по ТЗ — SQL-скрипты и таски в DAG.
|
||||||
|
|
||||||
|
### Почему именно bookings?
|
||||||
|
|
||||||
|
Домен бронирования авиабилетов выбран не ради предметной области, а благодаря
|
||||||
|
генератору данных: каждый вызов `make bookings-generate-day` создаёт новый день
|
||||||
|
с реалистичным объёмом. Это даёт бесконечный поток инкрементальных данных —
|
||||||
|
как в настоящей production-системе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Целевая аудитория и пререквизиты
|
||||||
|
|
||||||
|
**Кто:** студенты курса DE Roadmap, дошедшие до раздела «Курсовая работа».
|
||||||
|
|
||||||
|
**Что уже умеют** (к моменту старта):
|
||||||
|
- Git: ветки, PR, merge, GitFlow
|
||||||
|
- SQL: JOIN, CTE, оконные функции, планы запросов, моделирование (3NF, звезда, SCD)
|
||||||
|
- Python: скрипты, pandas, базовое ООП
|
||||||
|
- Docker: запуск контейнеров, логи, docker-compose
|
||||||
|
- Airflow: понятие DAG, операторы, зависимости, UI, логи
|
||||||
|
- Greenplum: распределение по сегментам, skew, EXPLAIN, отличие от Postgres
|
||||||
|
|
||||||
|
**Уровень:** уверенный джун, готовящийся к первым собеседованиям.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Учебные результаты (Learning Outcomes)
|
||||||
|
|
||||||
|
После выполнения курсовой студент умеет:
|
||||||
|
|
||||||
|
1. **Проектировать и реализовывать ETL-пайплайн** по слоям DWH
|
||||||
|
(STG → ODS → DDS → DM) на реальном стеке Airflow + Greenplum.
|
||||||
|
2. **Читать ТЗ от аналитика** (маппинги, описания таблиц) и превращать его
|
||||||
|
в работающий SQL + DAG.
|
||||||
|
3. **Писать идемпотентные загрузки** с инкрементальностью (HWM, _load_id,
|
||||||
|
delete+insert), понимая, почему в Greenplum не используется MERGE.
|
||||||
|
4. **Реализовывать SCD1/SCD2** и объяснять, когда что применяется.
|
||||||
|
5. **Настраивать и проверять Data Quality** — понимает, зачем DQ-проверки
|
||||||
|
и как их встроить в пайплайн.
|
||||||
|
6. **Работать с Greenplum** как с MPP: выбирать distribution key,
|
||||||
|
понимать heap vs AO, читать планы запросов.
|
||||||
|
7. **Оформить проект как портфолио** — репозиторий пригоден для упаковки
|
||||||
|
в резюме как реальный опыт работы с Airflow и Greenplum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Скоуп
|
||||||
|
|
||||||
|
### В скоупе (In Scope)
|
||||||
|
|
||||||
|
| Компонент | Описание |
|
||||||
|
|-----------------------|-------------------------------------------------------------|
|
||||||
|
| Источник данных | bookings-db (Postgres) с генератором дней |
|
||||||
|
| DWH | Greenplum, 4 слоя: STG, ODS, DDS, DM |
|
||||||
|
| Оркестрация | Apache Airflow (PostgresOperator + SQL-файлы) |
|
||||||
|
| Федеративный доступ | PXF (чтение из Postgres в Greenplum) |
|
||||||
|
| Инфраструктура | Docker Compose (полный стенд в одной команде) |
|
||||||
|
| Data Quality | DQ-проверки, встроенные в DAG |
|
||||||
|
| Документация | README, ТЗ, design docs, naming conventions |
|
||||||
|
|
||||||
|
### Вне скоупа (Out of Scope)
|
||||||
|
|
||||||
|
| Что | Почему |
|
||||||
|
|-----------------------|-------------------------------------------------------------|
|
||||||
|
| Kafka / стриминг | Отдельный стенд в курсе |
|
||||||
|
| BI-инструменты | Фокус на ETL, не на визуализации |
|
||||||
|
| CI/CD | Избыточно для курсовой |
|
||||||
|
| Spark / Trino / dbt | Отдельные стенды в курсе |
|
||||||
|
| Второй источник | Усложнение без пропорциональной учебной ценности |
|
||||||
|
| CSV-пайплайн | Вынести в [airflow-manual](https://github.com/dementev-dev/airflow-manual) |
|
||||||
|
| Облачная инфраструктура | Всё локально, через Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Архитектура стенда
|
||||||
|
|
||||||
|
### Сервисы (Docker Compose)
|
||||||
|
|
||||||
|
```
|
||||||
|
bookings-db (Postgres 16) ──PXF──> Greenplum 6.27
|
||||||
|
├── stg.* (стейджинг)
|
||||||
|
├── ods.* (операционное хранилище)
|
||||||
|
├── dds.* (детальное хранилище)
|
||||||
|
└── dm.* (витрины)
|
||||||
|
|
||||||
|
pgmeta (Postgres 16) ─────────────> Airflow (webserver + scheduler)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Слои DWH
|
||||||
|
|
||||||
|
| Слой | Назначение | Паттерн загрузки | Кол-во таблиц |
|
||||||
|
|------|-----------------------------------|---------------------------|---------------|
|
||||||
|
| STG | Зеркало источника | TRUNCATE + INSERT (batch) | 9 |
|
||||||
|
| ODS | Нормализованное хранилище | SCD1 UPSERT | 9 |
|
||||||
|
| DDS | Измерения + факты (Kimball) | SCD1/SCD2 + fact load | 7 (6D + 1F) |
|
||||||
|
| DM | Аналитические витрины | HWM-инкремент | 5 |
|
||||||
|
|
||||||
|
### Сущности
|
||||||
|
|
||||||
|
| STG / ODS | DDS | DM |
|
||||||
|
|----------------------|--------------------------|-----------------------|
|
||||||
|
| bookings | dim_airports | airport_traffic |
|
||||||
|
| tickets | dim_airplanes | monthly_overview |
|
||||||
|
| airports | dim_passengers | passenger_loyalty |
|
||||||
|
| airplanes | dim_routes (SCD2) | route_performance |
|
||||||
|
| routes | dim_calendar | sales_report |
|
||||||
|
| seats | dim_tariffs | |
|
||||||
|
| flights | fact_flight_sales | |
|
||||||
|
| segments | | |
|
||||||
|
| boarding_passes | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Педагогическая модель
|
||||||
|
|
||||||
|
Подробности — в [assignment_design.md](assignment_design.md).
|
||||||
|
|
||||||
|
### Принцип: «Эталонный срез + ТЗ»
|
||||||
|
|
||||||
|
Студент получает репозиторий, в котором:
|
||||||
|
|
||||||
|
1. **Эталонный вертикальный срез** — полностью реализованная цепочка
|
||||||
|
`sales_report` и все её источники вниз по слоям (STG → ODS → DDS → DM).
|
||||||
|
2. **ТЗ от аналитика** — описание остальных таблиц
|
||||||
|
(analyst_spec.md — будет создан на Этапе 3, см. TODO.md).
|
||||||
|
3. **Частично готовый DAG** — студент добавляет свои таски по аналогии.
|
||||||
|
4. **Валидационный DAG** — студент запускает для самоконтроля.
|
||||||
|
|
||||||
|
### Что делает студент
|
||||||
|
|
||||||
|
- Пишет DDL, SQL-загрузки, DQ-проверки для назначенных таблиц
|
||||||
|
- Добавляет таски в существующий DAG
|
||||||
|
- Проверяет результат через валидационный DAG и запросы в Greenplum
|
||||||
|
|
||||||
|
### Что студент НЕ делает
|
||||||
|
|
||||||
|
- Не поднимает инфраструктуру с нуля (Docker Compose дан)
|
||||||
|
- Не пишет DAG с нуля (шаблон дан)
|
||||||
|
- Не настраивает Airflow Connections (преднастроены)
|
||||||
|
- Не работает с PXF-конфигурацией (настроен)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ветки и workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
main (стартовое состояние)
|
||||||
|
├── Эталонный срез: реализованные таблицы + DAG
|
||||||
|
├── ТЗ от аналитика
|
||||||
|
├── Инфраструктура (Docker, Make, PXF)
|
||||||
|
├── Заглушки / TODO-маркеры для студенческих заданий
|
||||||
|
└── Валидационный DAG для самоконтроля
|
||||||
|
|
||||||
|
solution (полное решение)
|
||||||
|
└── Все таблицы реализованы — эталон для самопроверки
|
||||||
|
и подсказка, если студент застрял
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow студента
|
||||||
|
|
||||||
|
1. Форкает репозиторий
|
||||||
|
2. Читает README и ТЗ
|
||||||
|
3. `make up` — поднимает стенд
|
||||||
|
4. `make bookings-init` — инициализирует источник
|
||||||
|
5. Запускает DDL-DAG'и (эталонные таблицы создаются)
|
||||||
|
6. Запускает ETL-DAG'и — эталонный срез работает
|
||||||
|
7. Реализует задания из ТЗ (SQL + таски в DAG)
|
||||||
|
8. Проверяет себя через валидационный DAG
|
||||||
|
9. `make bookings-generate-day` — генерирует новый день, проверяет
|
||||||
|
инкрементальность
|
||||||
|
10. Защищает работу перед ментором
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Критерии приёмки курсовой
|
||||||
|
|
||||||
|
### Для студента (самопроверка)
|
||||||
|
|
||||||
|
- [ ] Стенд поднимается (`make up`) без ошибок
|
||||||
|
- [ ] Все DAG'и проходят без failed-тасков
|
||||||
|
- [ ] Данные доезжают от STG до DM
|
||||||
|
- [ ] Валидационный DAG проходит на всех реализованных таблицах
|
||||||
|
- [ ] После `make bookings-generate-day` + повторного запуска DAG
|
||||||
|
данные корректно доливаются (инкрементальность работает)
|
||||||
|
|
||||||
|
### Для ментора (ревью + защита)
|
||||||
|
|
||||||
|
- [ ] Код соответствует naming conventions (`docs/design/naming_conventions.md`)
|
||||||
|
- [ ] SQL идемпотентен (повторный запуск не ломает данные)
|
||||||
|
- [ ] Distribution keys выбраны осмысленно
|
||||||
|
- [ ] Студент может объяснить: почему delete+insert, а не MERGE;
|
||||||
|
разницу SCD1/SCD2; что такое HWM; как работает _load_id
|
||||||
|
- [ ] Код оформлен для портфолио (чистый Git-history, README)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ограничения и риски
|
||||||
|
|
||||||
|
| Риск / ограничение | Митигация |
|
||||||
|
|--------------------------------------------|-------------------------------------------------|
|
||||||
|
| Стенд тяжёлый (~8-16 GB RAM) | Указать минимальные требования; не утяжелять |
|
||||||
|
| bookings-db генерирует данные медленно | Не добавлять нагрузку; задокументировать ожидание|
|
||||||
|
| Студент может застрять надолго | Ветка `solution` как подсказка; еженедельные встречи |
|
||||||
|
| Greenplum 6.x — устаревающая версия | Для учебных целей достаточно; паттерны переносимы |
|
||||||
|
| PXF нестабилен при холодном старте | Задокументировано в README; healthcheck настроен |
|
||||||
|
|
||||||
|
### Требования к машине студента
|
||||||
|
|
||||||
|
- 2-4 CPU, 8-16 GB RAM, 25-40 GB диска
|
||||||
|
- Linux / WSL2 / macOS
|
||||||
|
- Docker + Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. План работ
|
||||||
|
|
||||||
|
План с чекбоксами и рекомендациями по инструментам: [TODO.md](../../TODO.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Открытые вопросы
|
||||||
|
|
||||||
|
1. **Название** — рабочее: «Greenplum Bookings DWH». Финализировать.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Дизайн курсового задания
|
||||||
|
|
||||||
|
> Тактические решения по нарезке задания, порядку выполнения и самопроверке.
|
||||||
|
> Стратегию и контекст см. в [PRD.md](PRD.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Эталонный срез: витрина `sales_report`
|
||||||
|
|
||||||
|
Эталоном выбрана витрина `dm.sales_report` и вся её цепочка вниз по слоям.
|
||||||
|
|
||||||
|
**Почему `sales_report`:**
|
||||||
|
- Покрывает SCD1 (airports, tariffs), HWM-инкремент, fact load
|
||||||
|
- Богатая денормализация — хороший образец для подражания
|
||||||
|
- Средняя сложность — не пугает, но и не тривиальна
|
||||||
|
|
||||||
|
### Эталонные таблицы (даны студенту)
|
||||||
|
|
||||||
|
| Слой | Таблицы |
|
||||||
|
|------|-----------------------------------------------------------------|
|
||||||
|
| DM | `sales_report` |
|
||||||
|
| DDS | `fact_flight_sales`, `dim_airports` (SCD1), `dim_tariffs` (SCD1), `dim_calendar` |
|
||||||
|
| ODS | `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes` |
|
||||||
|
| STG | весь слой: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `airplanes`, `seats`, `routes` |
|
||||||
|
|
||||||
|
### Задание студенту
|
||||||
|
|
||||||
|
| Слой | Таблицы | Что нового для студента |
|
||||||
|
|------|-------------------------------------------------------------------|--------------------------------------------------|
|
||||||
|
| ODS | `airplanes`, `seats` | Практика TRUNCATE+INSERT по аналогии с эталоном |
|
||||||
|
| DDS | `dim_airplanes` (SCD1), `dim_passengers` (SCD1), `dim_routes` (SCD2) | **SCD2 — ключевой вызов курсовой** |
|
||||||
|
| DM | `airport_traffic`, `monthly_overview`, `route_performance`, `passenger_loyalty` | Разная сложность (от простой к сложной) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Рекомендуемый порядок выполнения
|
||||||
|
|
||||||
|
Студенту рекомендуется (но не обязательно) двигаться в таком порядке:
|
||||||
|
|
||||||
|
1. **ODS** (airplanes, seats) — практика TRUNCATE+INSERT
|
||||||
|
2. **DDS** dim_airplanes, dim_passengers (SCD1) — новые измерения
|
||||||
|
3. **DDS** dim_routes (**SCD2**) — ключевой вызов
|
||||||
|
4. **DM** airport_traffic — простая витрина, похожа на sales_report
|
||||||
|
5. **DM** route_performance — TRUNCATE+INSERT, SCD2-агрегация по BK
|
||||||
|
6. **DM** monthly_overview — двухуровневая агрегация
|
||||||
|
7. **DM** passenger_loyalty — самая сложная, пересчёт истории
|
||||||
|
|
||||||
|
Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт
|
||||||
|
предыдущего.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SCD2: подход «рецепт без готового SQL»
|
||||||
|
|
||||||
|
Реализация `dim_routes` (SCD2) — ключевой вызов курсовой. Студент делает это
|
||||||
|
самостоятельно, но ТЗ содержит пошаговую подсказку:
|
||||||
|
|
||||||
|
1. Алгоритм SCD2 текстом (без SQL):
|
||||||
|
- Вычисли `hashdiff` по набору атрибутов (атрибуты перечислены в ТЗ)
|
||||||
|
- Найди строки, у которых `hashdiff` изменился
|
||||||
|
- Закрой старую версию (`valid_to = текущая_дата`)
|
||||||
|
- Вставь новую версию (`valid_from = текущая_дата`, `valid_to = NULL`)
|
||||||
|
2. Формула hashdiff: `md5(concat_ws('|', field1, field2, ...))`
|
||||||
|
3. Ссылка на `naming_conventions.md` (поля `valid_from`, `valid_to`, `hashdiff`)
|
||||||
|
4. Напоминание: полуоткрытый интервал `[valid_from, valid_to)`
|
||||||
|
5. Если застрял — ветка `solution`
|
||||||
|
|
||||||
|
Самостоятельная реализация — ключ к запоминанию. SCD2 — обязательный вопрос
|
||||||
|
на собеседованиях DE, и студент должен уметь объяснить его на основе
|
||||||
|
собственного опыта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Валидационный DAG (`bookings_validate`)
|
||||||
|
|
||||||
|
Отдельный DAG для самопроверки студента. Запускается вручную в Airflow UI
|
||||||
|
после реализации заданий. Таски сгруппированы по слоям — студент видит,
|
||||||
|
где именно проблема. Дополнительный бонус — практика чтения логов Airflow.
|
||||||
|
|
||||||
|
### Примерная структура тасков
|
||||||
|
|
||||||
|
```
|
||||||
|
bookings_validate
|
||||||
|
├── validate_ods
|
||||||
|
│ ├── check_ods_airplanes_rowcount (ODS >= STG по кол-ву уникальных BK)
|
||||||
|
│ ├── check_ods_seats_rowcount
|
||||||
|
│ └── check_ods_no_null_pks (PK not null)
|
||||||
|
├── validate_dds
|
||||||
|
│ ├── check_dim_airplanes_exists
|
||||||
|
│ ├── check_dim_passengers_exists
|
||||||
|
│ ├── check_dim_routes_scd2 (valid_from/valid_to корректны)
|
||||||
|
│ └── check_dim_routes_no_gaps (нет «дыр» в версиях SCD2)
|
||||||
|
└── validate_dm
|
||||||
|
├── check_airport_traffic_exists
|
||||||
|
├── check_monthly_overview_exists
|
||||||
|
├── check_route_performance_exists
|
||||||
|
└── check_passenger_loyalty_exists
|
||||||
|
```
|
||||||
|
|
||||||
|
### Реализация
|
||||||
|
|
||||||
|
- `PostgresOperator` + SQL-скрипты в `sql/validate/`
|
||||||
|
- Каждый SQL-скрипт выполняет SELECT и бросает исключение (через
|
||||||
|
`DO $$ ... RAISE EXCEPTION ... $$`), если проверка не пройдена
|
||||||
|
- Сообщения об ошибках — дружелюбные, с подсказкой что делать дальше
|
||||||
|
|
||||||
|
### Ключевые проверки
|
||||||
|
|
||||||
|
- Таблицы существуют и содержат данные
|
||||||
|
- PK не содержат NULL
|
||||||
|
- SCD2: `valid_to IS NULL` для текущих версий, нет перекрытий интервалов
|
||||||
|
- Кросс-слойная консистентность (row count ODS vs STG)
|
||||||
|
- DM-витрины содержат данные за загруженные дни
|
||||||
|
|
||||||
|
### Активная проверка SCD2 (`check_dim_routes_scd2`)
|
||||||
|
|
||||||
|
Справочник `bookings.routes` в демо-базе статичен — маршруты не меняются
|
||||||
|
между запусками генератора. Поэтому при обычном прогоне пайплайна студент
|
||||||
|
никогда не увидит, как SCD2 закрывает старую версию и создаёт новую.
|
||||||
|
|
||||||
|
Чтобы проверить корректность реализации, таск `check_dim_routes_scd2`
|
||||||
|
должен быть **активным** (не только читать, но и тестировать загрузку):
|
||||||
|
|
||||||
|
1. Сохранить текущее состояние `ods.routes` и `dds.dim_routes` (temp-таблицы).
|
||||||
|
2. Вставить в `ods.routes` тестовый маршрут с изменённым атрибутом
|
||||||
|
(например, `scheduled_time` → `departure_time` сдвинут на 1 час).
|
||||||
|
3. Вызвать студенческий SQL загрузки `dim_routes` (`sql/dds/dim_routes_load.sql`).
|
||||||
|
4. Проверить результат:
|
||||||
|
- Старая версия маршрута закрыта (`valid_to IS NOT NULL`).
|
||||||
|
- Новая версия открыта (`valid_to IS NULL`, `hashdiff` отличается).
|
||||||
|
- Нет «дыр» между `valid_to` старой и `valid_from` новой версии.
|
||||||
|
5. Откатить изменения: восстановить `ods.routes` и `dds.dim_routes`
|
||||||
|
из сохранённых temp-таблиц.
|
||||||
|
|
||||||
|
Это единственный способ гарантировать, что SCD2 работает, без мутации
|
||||||
|
источника (что сломало бы генератор `continue()`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Формат ТЗ от аналитика
|
||||||
|
|
||||||
|
Файл: `docs/assignment/analyst_spec.md` (или несколько файлов по слоям).
|
||||||
|
|
||||||
|
Для каждой таблицы-задания документ содержит:
|
||||||
|
|
||||||
|
- **Имя таблицы** и целевая схема (stg / ods / dds / dm)
|
||||||
|
- **Описание** — что хранит таблица, бизнес-смысл
|
||||||
|
- **Список полей** с типами и описанием
|
||||||
|
- **Маппинг источников** — откуда берётся каждое поле
|
||||||
|
- **Бизнес-правила и фильтры** (если есть)
|
||||||
|
- **Тип историзации** (SCD1 / SCD2 / snapshot / append)
|
||||||
|
- **Гранулярность** (одна строка = ?)
|
||||||
|
- **Distribution key** (подсказка или задание на выбор)
|
||||||
|
|
||||||
|
Формат — приближен к реальным ТЗ, которые студент встретит на работе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Бэклог: идеи для будущих итераций
|
||||||
|
|
||||||
|
### PXF-практикум (замена STG-заданий)
|
||||||
|
|
||||||
|
После переноса всего STG-слоя в эталон (см. `docs/plans/2026-03-11_routes-to-reference.md`)
|
||||||
|
студенты не практикуются в создании PXF external tables и загрузке сырых данных.
|
||||||
|
Это важный навык для DE — нужно компенсировать отдельным заданием.
|
||||||
|
|
||||||
|
Варианты:
|
||||||
|
- **Новый источник данных**: подключить CSV/JSON-файл (например, справочник городов
|
||||||
|
или курсов валют) через PXF и загрузить в отдельную STG-таблицу
|
||||||
|
- **Мини-задание «подключи внешний справочник»**: студент создаёт PXF external table
|
||||||
|
для существующей таблицы источника и сравнивает результат с эталонной STG
|
||||||
|
- **Лабораторная работа**: отдельное упражнение на создание external table +
|
||||||
|
сравнение форматов (TEXT vs CUSTOM), профилей PXF, обработку типов
|
||||||
@@ -569,9 +569,8 @@ UPDATE факта обновляет только **мутабельные по
|
|||||||
1. Таблица не пуста
|
1. Таблица не пуста
|
||||||
2. Нет дублей по зерну `(ticket_no, flight_id)`
|
2. Нет дублей по зерну `(ticket_no, flight_id)`
|
||||||
3. Количество строк = `COUNT(*)` из `ods.segments`
|
3. Количество строк = `COUNT(*)` из `ods.segments`
|
||||||
4. **Обязательные FK**: `tariff_sk IS NULL` = 0. На main `passenger_sk` — NOTICE (dim_passengers — заглушка); на solution — EXCEPTION.
|
4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0
|
||||||
5. **Эталонные FK аэропортов**: `departure_airport_sk`, `arrival_airport_sk` (через `ods.routes`) — порог 1%, EXCEPTION.
|
5. **FK маршрута**: NULL в любом из `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
|
||||||
**Студенческие FK**: `route_sk`, `airplane_sk` — на main NOTICE (dim_routes — заглушка, 100% NULL допустимо); на solution — порог 1%, EXCEPTION.
|
|
||||||
6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
|
6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
|
||||||
7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL
|
7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL
|
||||||
|
|
||||||
|
|||||||
@@ -512,9 +512,7 @@ ANALYZE ods.bookings;
|
|||||||
### 6.4. Поведение при пустом батче
|
### 6.4. Поведение при пустом батче
|
||||||
|
|
||||||
- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) пустой батч допустим;
|
- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) пустой батч допустим;
|
||||||
- для snapshot-справочников (`airports`, `routes`) пустой батч считаем ошибкой.
|
- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой.
|
||||||
На main `airplanes` и `seats` — студенческие заглушки (load/dq = `SELECT 1;`),
|
|
||||||
таблицы будут пустыми до реализации студентом.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -533,8 +531,7 @@ ANALYZE ods.bookings;
|
|||||||
|
|
||||||
3. **Обязательные поля** не `NULL`/не пустые.
|
3. **Обязательные поля** не `NULL`/не пустые.
|
||||||
|
|
||||||
4. **Батч не пустой** для эталонных snapshot-справочников (`airports`, `routes`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
|
4. **Батч не пустой** для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
|
||||||
На main `airplanes_dq.sql` и `seats_dq.sql` — студенческие заглушки.
|
|
||||||
|
|
||||||
5. **Ссылочная целостность** в ODS:
|
5. **Ссылочная целостность** в ODS:
|
||||||
- `tickets.book_ref -> bookings.book_ref`
|
- `tickets.book_ref -> bookings.book_ref`
|
||||||
@@ -620,8 +617,7 @@ tests/test_dags_smoke.py (+ smoke для 2 новых DAG)
|
|||||||
resolve_stg_batch_id
|
resolve_stg_batch_id
|
||||||
├-> load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets ──────────────────┐
|
├-> load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets ──────────────────┐
|
||||||
├-> load_ods_airports -> dq_ods_airports ─┐ │
|
├-> load_ods_airports -> dq_ods_airports ─┐ │
|
||||||
├-> load_ods_airplanes -> dq_ods_airplanes ─┐
|
├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes │
|
||||||
│ ├-> load_ods_routes -> dq_ods_routes │
|
|
||||||
└-> └-> load_ods_seats -> dq_ods_seats │
|
└-> └-> load_ods_seats -> dq_ods_seats │
|
||||||
│
|
│
|
||||||
dq_ods_routes -> load_ods_flights -> dq_ods_flights │
|
dq_ods_routes -> load_ods_flights -> dq_ods_flights │
|
||||||
@@ -635,8 +631,7 @@ resolve_stg_batch_id
|
|||||||
|
|
||||||
Зависимости (по FK):
|
Зависимости (по FK):
|
||||||
- `tickets` после `bookings` (FK: `book_ref`);
|
- `tickets` после `bookings` (FK: `book_ref`);
|
||||||
- `routes` после `airports` (FK: `departure_airport`, `arrival_airport`).
|
- `routes` после `airports` и `airplanes` (FK: `departure_airport`, `arrival_airport`, `airplane_code`);
|
||||||
На ветке `solution` также зависит от `airplanes` (FK: `airplane_code`);
|
|
||||||
- `seats` после `airplanes` (FK: `airplane_code`);
|
- `seats` после `airplanes` (FK: `airplane_code`);
|
||||||
- `flights` после `routes` (FK: `route_no`);
|
- `flights` после `routes` (FK: `route_no`);
|
||||||
- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`);
|
- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. Цель и общий контур
|
## 1. Цель и общий контур
|
||||||
|
|
||||||
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings`.
|
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md)).
|
||||||
- Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum.
|
- Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum.
|
||||||
- В этом документе описываем часть `src (bookings-db) → STG (Greenplum)`. STG — входной слой; далее данные обрабатываются в ODS → DDS → DM (см. соответствующие design-документы).
|
- В этом документе описываем часть `src (bookings-db) → STG (Greenplum)`. STG — входной слой; далее данные обрабатываются в ODS → DDS → DM (см. соответствующие design-документы).
|
||||||
|
|
||||||
@@ -127,7 +127,8 @@ DDL определён в `sql/stg/bookings_ddl.sql` и подключается
|
|||||||
|
|
||||||
## 5. Связь с остальными документами
|
## 5. Связь с остальными документами
|
||||||
|
|
||||||
- Детали настройки PXF и часовых поясов — в ветке `solution` (`docs/reference/`).
|
- [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md) — как готовится и генерируется источник `bookings-db`.
|
||||||
|
- [`docs/reference/pxf_bookings.md`](../reference/pxf_bookings.md) — детали настройки PXF и внешней таблицы для чтения из `bookings-db`.
|
||||||
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`).
|
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`).
|
||||||
|
|
||||||
Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5).
|
Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5).
|
||||||
|
|||||||
@@ -310,5 +310,6 @@ Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`.
|
|||||||
- [`bookings_dds_design.md`](bookings_dds_design.md) — дизайн DDS (Star Schema, SCD2)
|
- [`bookings_dds_design.md`](bookings_dds_design.md) — дизайн DDS (Star Schema, SCD2)
|
||||||
- [`bookings_dm_design.md`](bookings_dm_design.md) — дизайн DM (5 витрин)
|
- [`bookings_dm_design.md`](bookings_dm_design.md) — дизайн DM (5 витрин)
|
||||||
- [`naming_conventions.md`](naming_conventions.md) — нейминг полей
|
- [`naming_conventions.md`](naming_conventions.md) — нейминг полей
|
||||||
- Часовые пояса и настройка PXF — в ветке `solution` (`docs/reference/`)
|
- [`../reference/bookings_tz.md`](../reference/bookings_tz.md) — часовые пояса
|
||||||
|
- [`../reference/pxf_bookings.md`](../reference/pxf_bookings.md) — настройка PXF
|
||||||
- [`../assignment/analyst_spec.md`](../assignment/analyst_spec.md) — ТЗ от аналитика (курсовое задание)
|
- [`../assignment/analyst_spec.md`](../assignment/analyst_spec.md) — ТЗ от аналитика (курсовое задание)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Протокол сквозного (E2E) тестирования ETL
|
||||||
|
|
||||||
|
Этот документ описывает процедуру полной проверки цепочки ETL: `STG -> ODS -> DDS -> DM`.
|
||||||
|
Цель теста — убедиться в корректности инкрементальной загрузки, работы паттерна `Temporary Table` и механизмов `HWM`.
|
||||||
|
|
||||||
|
**Управление DAG'ами — только через Airflow UI или REST API.**
|
||||||
|
Не используйте CLI-команды внутри контейнера (`docker exec ... airflow dags ...`) — они могут молча не сработать (например, `unpause` не снимает паузу). REST API работает надёжно и приближает опыт к реальной боевой эксплуатации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Подготовка окружения
|
||||||
|
|
||||||
|
Убедитесь, что все сервисы запущены и генератор инициализирован.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
make bookings-init
|
||||||
|
```
|
||||||
|
|
||||||
|
**Доступ к API:**
|
||||||
|
В `docker-compose.yml` включена базовая аутентификация (`basic_auth`).
|
||||||
|
Для curl-запросов используйте учетные данные из вашего `.env` файла (переменные `AIRFLOW_USER` и `AIRFLOW_PASSWORD`, по умолчанию `admin:admin`).
|
||||||
|
|
||||||
|
- **UI:** `http://localhost:8080`
|
||||||
|
- **API Endpoint:** `http://localhost:8080/api/v1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Очистка данных (Reset)
|
||||||
|
|
||||||
|
Перед началом теста необходимо полностью очистить все слои DWH.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dwh-truncate
|
||||||
|
```
|
||||||
|
*(Если вы меняли DDL, лучше полностью пересоздать схемы: `make gp-psql -c "DROP SCHEMA IF EXISTS ods CASCADE; DROP SCHEMA IF EXISTS dds CASCADE; DROP SCHEMA IF EXISTS dm CASCADE; CREATE SCHEMA ods; CREATE SCHEMA dds; CREATE SCHEMA dm;"`)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Этап 1: Создание схем и Загрузка за первый день (Initial Load)
|
||||||
|
|
||||||
|
Рекомендуется запускать слои последовательно, дожидаясь завершения предыдущего.
|
||||||
|
|
||||||
|
### Создание DDL
|
||||||
|
Откройте **Airflow UI** (`http://localhost:8080`) и нажмите кнопку **▶ Play -> Trigger DAG** для DDL-дагов:
|
||||||
|
1. `bookings_stg_ddl`
|
||||||
|
2. `bookings_ods_ddl`
|
||||||
|
3. `bookings_dds_ddl`
|
||||||
|
4. `bookings_dm_ddl`
|
||||||
|
|
||||||
|
### Запуск пайплайна (Day 1)
|
||||||
|
После успешного создания таблиц, запустите DAG загрузки `bookings_to_gp_stage`.
|
||||||
|
|
||||||
|
**Через UI:** откройте http://localhost:8080, **снимите DAG с паузы** (переключатель слева от названия) и нажмите **▶ Play -> Trigger DAG**.
|
||||||
|
|
||||||
|
**Через REST API (рекомендуется для автоматизации и агентов):**
|
||||||
|
|
||||||
|
**Важно:** при старте стенда все DAG-и находятся на паузе. Каждый DAG перед первым запуском нужно «разморозить» (unpause), иначе запуск зависнет в статусе `queued`. Для каждого DAG требуется два шага: **1) unpause, 2) trigger**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Снятие с паузы (обязательно перед первым запуском!)
|
||||||
|
curl -s -X PATCH "http://localhost:8080/api/v1/dags/bookings_to_gp_stage" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"is_paused": false}'
|
||||||
|
|
||||||
|
# 2. Запуск
|
||||||
|
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка статуса
|
||||||
|
Дождитесь, пока DAG перейдет в статус `success` (следите в UI или опрашивайте через API):
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_stage/dagRuns?order_by=-execution_date&limit=1" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | python3 -c "import sys,json; print(json.load(sys.stdin)['dag_runs'][0]['state'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск остальных слоёв
|
||||||
|
Поочерёдно запускайте каждый следующий слой **по той же схеме: unpause + trigger** (замените `<dag_id>`):
|
||||||
|
1. `bookings_to_gp_ods`
|
||||||
|
2. `bookings_to_gp_dds`
|
||||||
|
3. `bookings_to_gp_dm`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Повторите для каждого DAG из списка выше
|
||||||
|
DAG_ID=bookings_to_gp_ods
|
||||||
|
|
||||||
|
# 1. Unpause
|
||||||
|
curl -s -X PATCH "http://localhost:8080/api/v1/dags/${DAG_ID}" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"is_paused": false}'
|
||||||
|
|
||||||
|
# 2. Trigger
|
||||||
|
curl -s -X POST "http://localhost:8080/api/v1/dags/${DAG_ID}/dagRuns" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Этап 2: Проверка инкремента
|
||||||
|
|
||||||
|
Эмулируйте появление данных за второй день и проверьте дозагрузку. DAG слоя STG автоматически сгенерирует новый день в базе-источнике перед загрузкой.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Повторный запуск цепочки ETL (DAG STG сам сгенерирует новый день)
|
||||||
|
# Снова нажмите "Trigger DAG" в UI для каждого слоя (STG -> ODS -> DDS -> DM).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Цикл отладки: Логи и Перезапуск (Clear)
|
||||||
|
|
||||||
|
Если DAG упал, **не нужно пересоздавать стенд с нуля**. Airflow позволяет исправить код и перезапустить только упавшие задачи.
|
||||||
|
|
||||||
|
Для выполнения команд ниже вам понадобится **Run ID** упавшего запуска. Его можно скопировать из UI (вкладка *Graph* -> кликнуть на фон сетки -> вкладка *Details* -> `Run ID`) или получить последним API-запросом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Получить Run ID последнего запуска ODS
|
||||||
|
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns?order_by=-execution_date&limit=1" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" | grep -o '"dag_run_id": "[^"]*"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Чтение логов через API
|
||||||
|
Подставьте ваш `<RUN_ID>` (например, `manual__2026-03-03T...`) и имя упавшей таски:
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/dagRuns/<RUN_ID>/taskInstances/<TASK_ID>/logs/1" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Перезапуск задачи (Clear)
|
||||||
|
1. Прочитайте ошибку в логах.
|
||||||
|
2. Исправьте SQL-файл локально на хосте.
|
||||||
|
3. Очистите состояние упавших задач (`only_failed: true`) в конкретном запуске, передав ваш `<RUN_ID>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://localhost:8080/api/v1/dags/bookings_to_gp_ods/clearTaskInstances" \
|
||||||
|
--user "${AIRFLOW_USER}:${AIRFLOW_PASSWORD}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"only_failed": true,
|
||||||
|
"reset_dag_runs": true,
|
||||||
|
"dag_run_id": "<RUN_ID>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
После этого планировщик подхватит обновленный SQL-код и продолжит выполнение DAG с точки падения. Вы также можете сделать это в UI: клик по упавшей задаче -> кнопка **Clear**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Финальная верификация (Критерии успеха)
|
||||||
|
|
||||||
|
Выполните SQL-запрос для сверки данных:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make gp-psql -c "
|
||||||
|
SELECT 'STG' as layer, COUNT(*) FROM stg.bookings
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'ODS' as layer, COUNT(*) FROM ods.bookings
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'DDS' as layer, COUNT(*) FROM dds.fact_flight_sales
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'DM ' as layer, COUNT(*) FROM dm.sales_report;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Критерии корректности:**
|
||||||
|
1. **Инкремент STG**: Количество строк в `stg.bookings` после Этапа 2 больше, чем после Этапа 1.
|
||||||
|
2. **Инкремент ODS**: Количество строк в `ods.bookings` выросло. В ODS нет дублей (`SELECT book_ref FROM ods.bookings GROUP BY book_ref HAVING COUNT(*) > 1` должен вернуть 0 строк).
|
||||||
|
3. **ODS Справочники**: Количество строк в `ods.airports` и `ods.routes` не должно меняться между днями (работает паттерн TRUNCATE+INSERT полного снимка).
|
||||||
|
4. **HWM в DM**: Витрина `sales_report` содержит данные за оба дня. Значение `COUNT(*)` после Этапа 2 выросло.
|
||||||
|
5. **Lineage**: Поля `_load_id` и `_load_ts` во всех слоях содержат метки соответствующих запусков (`manual__...`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Зафиксированный опыт (Типичные ошибки)
|
||||||
|
|
||||||
|
- **Рассинхронизация DDL и Load скриптов**: Частая причина падения ODS/DDS — несовпадение имен колонок (например, `amount` vs `segment_amount`) или типов данных (например, `INTEGER[]` vs `TEXT`) между схемой таблицы и запросом загрузки. Внимательно читайте логи задачи.
|
||||||
|
- **Работа с массивами**: При генерации `hashdiff` в Greenplum/PostgreSQL нельзя использовать пустую строку `''` в `COALESCE` для массива. Массив нужно предварительно привести к тексту: `COALESCE(days_of_week::TEXT, '')`.
|
||||||
|
- **Кавычки в psql**: При выполнении ручных проверок через `psql -c "..."` помните, что строковые литералы должны оборачиваться в **одинарные кавычки** (`'text'`), а двойные кавычки (`"text"`) интерпретируются как идентификаторы колонок.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Plan: Подготовка main + ветка solution (Этап 4)
|
||||||
|
|
||||||
|
> Дата: 2026-03-12 | Версия: 4 (после третьего ревью Codex)
|
||||||
|
> Ветка: chore/bookings-etl → main → solution
|
||||||
|
> Спецификации кода: `docs/archive/2026-03-11_routes-to-reference.md` (п.2–5, п.8, п.10–11)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Мотивация
|
||||||
|
|
||||||
|
### Зачем раскладка по веткам
|
||||||
|
|
||||||
|
Сейчас весь код (эталон + студенческие задания) живёт в ветке `chore/bookings-etl`.
|
||||||
|
Студент должен получить **частично реализованный пайплайн**: эталонный вертикальный
|
||||||
|
срез работает «из коробки», а студенческие файлы — заглушки (`SELECT 1; -- TODO`).
|
||||||
|
Полная реализация доступна в ветке `solution` для самопроверки.
|
||||||
|
|
||||||
|
### Почему именно такой порядок
|
||||||
|
|
||||||
|
**Рассмотренные варианты:**
|
||||||
|
|
||||||
|
| # | Схема | Проблема |
|
||||||
|
|---|-------|----------|
|
||||||
|
| A | Создать solution от bookings-etl, потом на main — cherry-pick + заглушки | Грязные cherry-pick'и, main временно «не main» |
|
||||||
|
| B | Мерж в main, заглушки на main, solution = cherry-pick эталона обратно | main в какой-то момент содержит полный код, потом — заглушки; solution — через cherry-pick |
|
||||||
|
| **C** | **Мерж в main → общие правки → ветка solution (снимок) → main-only правки** | **Выбран** |
|
||||||
|
|
||||||
|
**Почему вариант C:**
|
||||||
|
|
||||||
|
- **main всегда остаётся main.** Нет момента, когда main «подменяется» другой веткой.
|
||||||
|
Любые CI/CD, ссылки, клонирования — работают непрерывно.
|
||||||
|
- **Простая линейная история.** Мерж → общие правки → бранч → main-only коммиты.
|
||||||
|
Никаких cherry-pick'ов.
|
||||||
|
- **solution — архивный снимок.** Не ожидается активная разработка. Студент сверяется
|
||||||
|
с ним, а не мержит.
|
||||||
|
- **Общие правки до ветвления.** Документация, актуальная для обеих веток,
|
||||||
|
обновляется *до* создания solution. Solution получает её автоматически.
|
||||||
|
|
||||||
|
### Стратегия синхронизации веток
|
||||||
|
|
||||||
|
После раскладки `main` (заглушки) и `solution` (полный код) расходятся.
|
||||||
|
|
||||||
|
**Главный принцип: solution — source of truth.**
|
||||||
|
|
||||||
|
Все изменения начинаются на solution (или feature-ветке от solution),
|
||||||
|
затем портируются в main. Направление потока: **solution → main**.
|
||||||
|
|
||||||
|
Файлы делятся на три категории:
|
||||||
|
|
||||||
|
| Категория | Примеры | Правило |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| **Общий код** | STG SQL, docker, Makefile, инфраструктура | Фиксим на solution, cherry-pick в main |
|
||||||
|
| **Общая документация** | README, TESTING, stack.md, naming_conventions | Фиксим на solution, cherry-pick в main |
|
||||||
|
| **Branch-specific** | заглушки load/dq, fact_flight_sales_dq (ослабленный), routes_dq (без RI), ODS DAG (без airplanes→routes), branch-specific формулировки в design docs | Фиксим на нужной ветке. Конфликтов нет — содержимое файлов разное |
|
||||||
|
|
||||||
|
**Исключение:** main-only правка (опечатка в заглушке, битая ссылка
|
||||||
|
в main-only документе) — правим прямо на main, solution не трогаем.
|
||||||
|
|
||||||
|
**Главное правило: никогда не мержим main → solution целиком.**
|
||||||
|
|
||||||
|
> Детальный протокол синхронизации — открытый вопрос, см. секцию 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Порядок выполнения
|
||||||
|
|
||||||
|
### Шаг 1. PR chore/bookings-etl → main
|
||||||
|
|
||||||
|
- Создать PR, ревью
|
||||||
|
- Мерж (squash или обычный — на усмотрение)
|
||||||
|
- После мержа: `git checkout main && git pull`
|
||||||
|
|
||||||
|
### Шаг 2. Общие правки на main (до ветвления solution)
|
||||||
|
|
||||||
|
> **Bootstrap-исключение:** стратегия синхронизации (секция 1) определяет
|
||||||
|
> solution как source of truth с потоком solution → main. Но при первичной
|
||||||
|
> раскладке solution ещё не существует — общие правки делаются на main,
|
||||||
|
> и solution наследует их при ветвлении (шаг 3). После создания solution
|
||||||
|
> действует штатный протокол.
|
||||||
|
|
||||||
|
Эти изменения отражают код, уже изменённый на bookings-etl
|
||||||
|
(fact_flight_sales_load.sql использует ods.routes). Документация должна
|
||||||
|
соответствовать коду на **обеих** ветках.
|
||||||
|
|
||||||
|
#### 2.1. Документация (общая для обеих веток)
|
||||||
|
|
||||||
|
- `docs/design/bookings_dds_design.md` — обновить описание fact lookup
|
||||||
|
(airports через ods.routes, airplane через dim_routes point-in-time)
|
||||||
|
- `docs/bookings_to_gp_dds.md` — обновить описание DDS lookup
|
||||||
|
- `docs/bookings_to_gp_ods.md` — обновить описание ODS
|
||||||
|
- `docs/design/db_schema.md` — пометить STG + ods.routes как эталон
|
||||||
|
|
||||||
|
#### 2.2. TODO.md — переписать
|
||||||
|
|
||||||
|
Текущий текст Этапа 4 в TODO.md описывает устаревшую стратегию
|
||||||
|
(удалить routes из STG, убирать `\i` из ddl_gp.sql). Нужно обновить,
|
||||||
|
чтобы на solution осталась корректная история:
|
||||||
|
|
||||||
|
- Отметить этапы 2, 3 выполненными (✅)
|
||||||
|
- **Переписать** текст этапа 4 (не просто поставить галочку) — привести
|
||||||
|
в соответствие с фактической стратегией (данный план)
|
||||||
|
- Отметить этап 4 выполненным после завершения
|
||||||
|
|
||||||
|
### Шаг 3. Создать ветку solution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git checkout -b solution
|
||||||
|
git push -u origin solution
|
||||||
|
```
|
||||||
|
|
||||||
|
Solution получает: полный эталонный код + актуальную общую документацию +
|
||||||
|
все design docs, plans, archive, TODO.md — полный контекст для мейнтейнера.
|
||||||
|
|
||||||
|
### Шаг 4. Main-only правки (заглушки, ослабление DQ)
|
||||||
|
|
||||||
|
Работаем на main (или на feature-ветке → PR в main).
|
||||||
|
|
||||||
|
#### 4.1. Код: fact_flight_sales_dq.sql — ослабить student SK
|
||||||
|
|
||||||
|
Спецификация: архивный план, п.2.
|
||||||
|
|
||||||
|
- `passenger_sk IS NULL` → `RAISE NOTICE` (было `RAISE EXCEPTION`)
|
||||||
|
- Разделить route-related блок: airport_sk (порог 1%, EXCEPTION) vs
|
||||||
|
route_sk/airplane_sk (NOTICE only)
|
||||||
|
- Добавить учебный комментарий
|
||||||
|
|
||||||
|
#### 4.2. Код: routes_dq.sql — закомментировать RI airplane
|
||||||
|
|
||||||
|
Спецификация: архивный план, п.3.
|
||||||
|
|
||||||
|
- **Закомментировать** (не удалять) блок RI `airplane_code → ods.airplanes`
|
||||||
|
- Добавить комментарий:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Проверка RI airplane_code → ods.airplanes закомментирована,
|
||||||
|
-- т.к. таблица ods.airplanes реализуется студентом.
|
||||||
|
-- После реализации — раскомментируйте этот блок.
|
||||||
|
-- Полную версию см. в ветке solution.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3. Код: ODS DAG — убрать зависимость airplanes → routes
|
||||||
|
|
||||||
|
Спецификация: архивный план, п.4.
|
||||||
|
|
||||||
|
- `[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes`
|
||||||
|
→ `dq_ods_airports >> load_ods_routes`
|
||||||
|
|
||||||
|
#### 4.4. Заглушки: студенческие файлы
|
||||||
|
|
||||||
|
Спецификация: архивный план, п.5.
|
||||||
|
|
||||||
|
| Слой | Файлы (load + dq) | DDL |
|
||||||
|
|------|--------------------|-----|
|
||||||
|
| ODS | airplanes, seats | Оставить (таблица нужна) |
|
||||||
|
| DDS | dim_routes, dim_passengers, dim_airplanes | Оставить (нужен для LEFT JOIN) |
|
||||||
|
| DM | airport_traffic, route_performance, monthly_overview, passenger_loyalty | Оставить |
|
||||||
|
|
||||||
|
Формат заглушки load:
|
||||||
|
```sql
|
||||||
|
-- TODO: реализуйте загрузку (см. ТЗ в docs/assignment/analyst_spec.md)
|
||||||
|
-- Эталонную реализацию можно найти в ветке solution.
|
||||||
|
SELECT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Формат заглушки dq:
|
||||||
|
```sql
|
||||||
|
-- TODO: реализуйте проверки качества данных
|
||||||
|
-- Эталонную реализацию можно найти в ветке solution.
|
||||||
|
SELECT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.5. Тесты
|
||||||
|
|
||||||
|
Спецификация: архивный план, п.10.
|
||||||
|
|
||||||
|
- `test_dags_smoke.py`: убрать assert барьера `dq_ods_airplanes → dq_ods_routes`
|
||||||
|
- `test_ods_sql_contract.py`: `SNAPSHOT_ENTITIES = ("airports", "routes")`
|
||||||
|
(убрать airplanes, seats — их load/dq теперь заглушки)
|
||||||
|
|
||||||
|
#### 4.6. Документация (main-specific)
|
||||||
|
|
||||||
|
- `docs/design/bookings_ods_design.md` — пометить airplanes/seats как студенческие,
|
||||||
|
убрать зависимость routes от airplanes, скорректировать DQ-контракт
|
||||||
|
- `docs/design/bookings_dds_design.md` — обновить DQ-описание:
|
||||||
|
student SK не блокируют (NOTICE), airport_sk — порог 1%
|
||||||
|
- `docs/bookings_to_gp_dds.md` — обновить: student SK будут NULL на main
|
||||||
|
- `docs/reference/qa-plan.md` — уточнить: ods.airplanes и ods.seats
|
||||||
|
пусты by design на main
|
||||||
|
|
||||||
|
### Шаг 5. Очистка docs на main (после всех правок)
|
||||||
|
|
||||||
|
> Этот шаг выполняется **после** шагов 2–4, потому что:
|
||||||
|
> - solution уже создан (шаг 3) и сохранил все файлы
|
||||||
|
> - план и архивный справочник были доступны во время работы (шаг 4)
|
||||||
|
|
||||||
|
#### 5.1. Удалить с main (остаётся на solution)
|
||||||
|
|
||||||
|
| Файл/каталог | Почему не нужен студенту |
|
||||||
|
|--------------|------------------------|
|
||||||
|
| `docs/plans/` (весь каталог) | Планы разработки — внутренняя кухня |
|
||||||
|
| `docs/archive/` (весь каталог) | Архив планов — внутренняя кухня |
|
||||||
|
| `docs/design/PRD.md` | Продуктовые требования — внутренний документ |
|
||||||
|
| `docs/design/assignment_design.md` | Мета-дизайн задания (для авторов курса, не для студентов) |
|
||||||
|
| `docs/reference/bookings_generation_benchmark.md` | Бенчмарки генерации — отладочная информация |
|
||||||
|
| `docs/reference/bookings_tz.md` | Заметки о таймзонах — отладочная информация |
|
||||||
|
| `docs/reference/pxf_bookings.md` | PXF-конфигурация — внутренняя отладка |
|
||||||
|
| `docs/agent-dag-testing.md` | Инструкция для AI-агентов по тестированию |
|
||||||
|
| `docs/e2e-etl-test-protocol.md` | E2E-протокол — внутреннее тестирование |
|
||||||
|
| `TODO.md` | Таск-лист мейнтейнера |
|
||||||
|
|
||||||
|
#### 5.2. Что остаётся на main (полезно студенту)
|
||||||
|
|
||||||
|
| Файл | Зачем студенту |
|
||||||
|
|------|---------------|
|
||||||
|
| `README.md` | Установка, запуск, структура проекта |
|
||||||
|
| `TESTING.md` | Как проверять свою работу |
|
||||||
|
| `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` | Если студент использует AI-помощников |
|
||||||
|
| `docs/README.md` | Навигация по документации |
|
||||||
|
| `docs/stack.md` | Стек технологий — контекст |
|
||||||
|
| `docs/dag_execution_order.md` | Порядок запуска DAG'ов |
|
||||||
|
| `docs/bookings_to_gp_stage.md` | Описание STG — эталонный код для изучения |
|
||||||
|
| `docs/bookings_to_gp_ods.md` | Описание ODS |
|
||||||
|
| `docs/bookings_to_gp_dds.md` | Описание DDS |
|
||||||
|
| `docs/bookings_to_gp_dm.md` | Описание DM |
|
||||||
|
| `docs/design/naming_conventions.md` | Нейминг полей — нужен для DDL/SQL |
|
||||||
|
| `docs/design/db_schema.md` | Схема БД — справочник |
|
||||||
|
| `docs/design/bookings_stg_design.md` | Дизайн STG — эталон для изучения |
|
||||||
|
| `docs/design/bookings_ods_design.md` | Дизайн ODS — нужен для задания |
|
||||||
|
| `docs/design/bookings_dds_design.md` | Дизайн DDS — нужен для задания |
|
||||||
|
| `docs/design/bookings_dm_design.md` | Дизайн DM — нужен для задания |
|
||||||
|
| `docs/assignment/` | Задание (analyst_spec.md) |
|
||||||
|
| `docs/reference/qa-plan.md` | QA-чеклист — полезен для самопроверки |
|
||||||
|
| `docs/reference/bookings_db_issues.md` | Известные проблемы — чтобы студент не тратил время на отладку |
|
||||||
|
|
||||||
|
#### 5.3. AGENTS.md — адаптировать для main
|
||||||
|
|
||||||
|
`AGENTS.md` содержит карту проекта и ссылки, которые побьются после cleanup.
|
||||||
|
Нужно обновить, а не просто добавить одну строку:
|
||||||
|
|
||||||
|
- **Карта проекта (секция 2):** убрать упоминания `docs/plans/`, `docs/archive/`.
|
||||||
|
Добавить: «Полные дизайн-документы (PRD, assignment_design, планы) — в ветке solution»
|
||||||
|
- **Тестирование:** убрать ссылку на `docs/agent-dag-testing.md` (файл удалён)
|
||||||
|
- **Прочие ссылки:** проверить, что все пути в AGENTS.md ведут на существующие файлы
|
||||||
|
|
||||||
|
#### 5.4. Link audit — полная проверка ссылок
|
||||||
|
|
||||||
|
Проверить **все** ссылки во **всех** оставшихся на main markdown-файлах,
|
||||||
|
а не только ссылки на удалённые файлы. В документации уже есть битые ссылки
|
||||||
|
(напр. `docs/README.md` → `design/architecture_review.md`, файл в архиве).
|
||||||
|
|
||||||
|
**Метод:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Найти все markdown-ссылки в оставшихся файлах
|
||||||
|
rg -o '\[.*?\]\([^)]+\.md[^)]*\)' docs/ README.md TESTING.md AGENTS.md
|
||||||
|
|
||||||
|
# 2. Проверить существование каждого целевого файла
|
||||||
|
# 3. Починить: удалить ссылку, обновить путь, или заменить на «см. ветку solution»
|
||||||
|
```
|
||||||
|
|
||||||
|
Известные проблемы (минимум):
|
||||||
|
|
||||||
|
- `docs/README.md` — ссылки на plans/, archive/, PRD, assignment_design, architecture_review
|
||||||
|
- `docs/assignment/README.md` — возможные ссылки на assignment_design
|
||||||
|
- `docs/design/bookings_stg_design.md` — ссылки на внутренние reference
|
||||||
|
- `docs/bookings_to_gp_stage.md` — ссылки на reference
|
||||||
|
- `docs/stack.md` — ссылки на reference
|
||||||
|
- `docs/design/db_schema.md` — ссылки на PRD, assignment_design
|
||||||
|
- `README.md` — ссылки на TODO.md, PRD
|
||||||
|
- `AGENTS.md` — покрыто шагом 5.3
|
||||||
|
|
||||||
|
### Шаг 6. Верификация main
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # smoke-тесты и контракты
|
||||||
|
make lint # линтинг
|
||||||
|
```
|
||||||
|
|
||||||
|
При поднятом стенде:
|
||||||
|
1. `make up` → `make ddl-gp`
|
||||||
|
2. Запустить STG → ODS → DDS → DM DAG'и
|
||||||
|
3. Проверить: `sales_report` содержит данные с корректными аэропортами
|
||||||
|
4. Проверить: студенческие ODS/DDS-таблицы пусты
|
||||||
|
5. Fact DQ проходит (student SK = NULL, но DQ не блокирует)
|
||||||
|
|
||||||
|
### Шаг 7. Верификация solution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout solution
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Убедиться, что полный пайплайн работает без изменений (это снимок
|
||||||
|
проверенного chore/bookings-etl + общие doc-правки).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Открытый вопрос: протокол синхронизации веток
|
||||||
|
|
||||||
|
> Не блокирует Этап 4. Доработать после раскладки, когда появится конкретный
|
||||||
|
> опыт (например, при первом PXF-задании).
|
||||||
|
|
||||||
|
### Предварительное решение: solution как source of truth
|
||||||
|
|
||||||
|
Все изменения (новые задания, баг-фиксы, доработки) начинаются на solution
|
||||||
|
или на feature-ветке от solution. Main — производная.
|
||||||
|
|
||||||
|
```
|
||||||
|
solution ← feat/new-task (разработка + тесты)
|
||||||
|
↓ merge
|
||||||
|
solution (полный эталон, всегда рабочий)
|
||||||
|
↓ порт
|
||||||
|
main (cherry-pick общего + заглушки)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поток по умолчанию:** solution → main (одно направление).
|
||||||
|
|
||||||
|
**Исключение (редко):** main-only правка (опечатка в заглушке, битая ссылка
|
||||||
|
в main-only документе) — правим прямо на main, solution не трогаем.
|
||||||
|
|
||||||
|
### Что нужно доработать
|
||||||
|
|
||||||
|
- Как именно выглядит «порт в main»: cherry-pick, ручной перенос, чеклист?
|
||||||
|
- Что делать при конфликтах cherry-pick?
|
||||||
|
- Нужен ли реестр known-different файлов (заглушки vs реализации) для
|
||||||
|
автоматической проверки синхронизации?
|
||||||
|
- Нужен ли периодический `git diff main..solution -- <shared files>` для
|
||||||
|
обнаружения расхождений?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ключевые файлы
|
||||||
|
|
||||||
|
Полная таблица с ролями и ветками: архивный план, секция «Ключевые файлы».
|
||||||
|
|
||||||
|
Дополнительно (docs cleanup, шаг 5.1):
|
||||||
|
|
||||||
|
| Действие | Файлы |
|
||||||
|
|----------|-------|
|
||||||
|
| Удалить с main | `docs/plans/*`, `docs/archive/*`, `docs/design/PRD.md`, `docs/design/assignment_design.md`, `docs/reference/bookings_generation_benchmark.md`, `docs/reference/bookings_tz.md`, `docs/reference/pxf_bookings.md`, `docs/agent-dag-testing.md`, `docs/e2e-etl-test-protocol.md`, `TODO.md` |
|
||||||
|
| Обновить на main | `AGENTS.md` (ссылка на solution), все .md с битыми ссылками (link audit) |
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Планы работ
|
||||||
|
|
||||||
|
Активные планы. После выполнения переносятся в [`archive/`](../archive/).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Бенчмарк генерации bookings: параллельность и тюнинг PostgreSQL
|
||||||
|
|
||||||
|
> Дата: 2026-03-09
|
||||||
|
> Железо: Intel Core i7-11800H @ 2.30GHz (8 ядер / 16 потоков), игровой ноутбук
|
||||||
|
> Среда: WSL2, Docker Desktop, PostgreSQL 16 в контейнере
|
||||||
|
> Данные: seed-дамп 60 дней (~500k bookings, ~180k boarding_passes, ~1.2M tickets)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Генератор demodb (`postgrespro/demodb`) использует очередь событий `gen.events` с обработкой через `process_queue()`. При `jobs>1` воркеры запускаются через `dblink_send_query` и конкурируют за очередь через `SELECT ... FOR UPDATE SKIP LOCKED`.
|
||||||
|
|
||||||
|
Задача: найти оптимальную конфигурацию для `make bookings-generate-day` (инкремент +1 день).
|
||||||
|
|
||||||
|
## Методика
|
||||||
|
|
||||||
|
- Между экспериментами: `make bookings-init BOOKINGS_JOBS=N` (восстановление из seed-дампа, ~18 сек).
|
||||||
|
- Замер: `time make bookings-generate-day BOOKINGS_JOBS=N`.
|
||||||
|
- Контроль: `max(book_date)` должен сдвинуться на +1 день.
|
||||||
|
- Тюнинг PG: `ALTER SYSTEM SET` + `docker compose restart bookings-db`.
|
||||||
|
|
||||||
|
Параметры тюнинга PostgreSQL:
|
||||||
|
```
|
||||||
|
shared_buffers = 512MB (дефолт: 128MB)
|
||||||
|
work_mem = 64MB (дефолт: 4MB)
|
||||||
|
maintenance_work_mem = 256MB (дефолт: 64MB)
|
||||||
|
effective_cache_size = 1GB (дефолт: 4GB)
|
||||||
|
wal_buffers = 16MB (дефолт: -1, авто)
|
||||||
|
checkpoint_completion_target = 0.9 (дефолт: 0.9)
|
||||||
|
random_page_cost = 1.1 (дефолт: 4.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
| # | Тюнинг PG | sync_commit | Jobs | Время | vs baseline |
|
||||||
|
|---|-----------|-------------|------|--------|--------------|
|
||||||
|
| 1 | нет | on | 1 | 2m 48s | **baseline** |
|
||||||
|
| 2 | нет | on | 2 | 8m 36s | 3× хуже |
|
||||||
|
| 3 | да | on | 1 | 3m 01s | шум |
|
||||||
|
| 4 | да | on | 2 | 8m 42s | 3× хуже |
|
||||||
|
| 5 | да | off | 1 | 2m 50s | шум |
|
||||||
|
| 6 | да | off | 2 | 8m 39s | 3× хуже |
|
||||||
|
|
||||||
|
Повторный инкремент (прогретый кэш): 5m 03s (jobs=2), не замерялся (jobs=1).
|
||||||
|
|
||||||
|
## Выводы
|
||||||
|
|
||||||
|
### 1. jobs=1 оптимален для инкрементов
|
||||||
|
|
||||||
|
Для генерации +1 дня параллельность через dblink **контрпродуктивна**:
|
||||||
|
- Два воркера конкурируют за одну очередь `gen.events` через `FOR UPDATE SKIP LOCKED` — это row-level lock contention.
|
||||||
|
- Overhead: dblink-соединения, синхронизация через `gen.stat_jobs`, polling через `dblink_is_busy()`.
|
||||||
|
- На WSL2 дополнительно: виртуализированный I/O не масштабируется при параллельных записях.
|
||||||
|
|
||||||
|
При генерации с нуля (`make bookings-generate`, десятки тысяч событий) jobs>1 **может** давать прирост, но не тестировалось в этом бенчмарке.
|
||||||
|
|
||||||
|
### 2. Тюнинг PostgreSQL не влияет
|
||||||
|
|
||||||
|
Увеличение shared_buffers в 4 раза, work_mem в 16 раз и т.д. не дало измеримого эффекта. Узкое место — не буферы и не I/O, а сама логика генератора: последовательная обработка событий с COMMIT после каждого.
|
||||||
|
|
||||||
|
### 3. synchronous_commit=off не влияет
|
||||||
|
|
||||||
|
Генератор делает COMMIT после каждого события (~сотни раз за день). Ожидалось, что `synchronous_commit=off` ускорит запись WAL. Эффект не обнаружен — вероятно, WAL-буферы и так справляются при одном потоке.
|
||||||
|
|
||||||
|
### 4. VACUUM-ивенты — отдельная проблема
|
||||||
|
|
||||||
|
Генератор вставляет в очередь событие `VACUUM` каждую неделю модельного времени. Обработчик запускает `VACUUM ANALYZE` всей базы через `dblink_exec`. При 500k+ строках это занимает минуты. Решение: `DELETE FROM gen.events WHERE type = 'VACUUM'` перед `continue()` в инкрементальных скриптах.
|
||||||
|
|
||||||
|
## Итоговая конфигурация
|
||||||
|
|
||||||
|
```
|
||||||
|
BOOKINGS_JOBS=1 # синхронно, без dblink — ~3 мин на +1 день
|
||||||
|
BOOKINGS_INIT_DAYS=60 # seed-дамп покрывает ~34 дня бронирований + boarding_passes
|
||||||
|
```
|
||||||
|
|
||||||
|
Тюнинг PostgreSQL не требуется для текущих объёмов данных.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Временное ТЗ по блоку bookings (для текущей разработки)
|
||||||
|
|
||||||
|
_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._
|
||||||
|
|
||||||
|
- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований.
|
||||||
|
- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum.
|
||||||
|
- Инициализация: два способа:
|
||||||
|
- `make bookings-init` (рекомендуется): быстрое восстановление из seed-дампа (~18 сек).
|
||||||
|
- `make bookings-generate` (для разработчиков): полная генерация с нуля — клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`, `connstr` без хардкода), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=60`, `jobs=2`.
|
||||||
|
- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink.
|
||||||
|
- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически при `make bookings-generate`.
|
||||||
|
- Документация: в README описаны команды (`bookings-init`, `bookings-generate`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап.
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
- `make bookings-init` — быстрое восстановление из seed-дампа (~18 сек), рекомендуется для студентов.
|
||||||
|
- `make bookings-generate` — полная генерация с нуля: автоматически применяет патчи (`engine_jobs1_sync.patch`, `install_drop_if_exists.patch`), ждёт готовности Postgres через `pg_isready`, запускает `install.sql`, выставляет `gen.connstr`/GUC и вызывает `generate_next_day.sql` через `psql -f`.
|
||||||
|
- Дефолты: `BOOKINGS_START_DATE=2017-01-01`, `BOOKINGS_INIT_DAYS=60`, `BOOKINGS_JOBS=2`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию.
|
||||||
|
- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`.
|
||||||
|
- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`.
|
||||||
|
|
||||||
|
## Текущее состояние тестов/проблем
|
||||||
|
- Чистый прогон `make bookings-init` (восстановление из seed-дампа) проходит за ~18 секунд.
|
||||||
|
- Чистый прогон `make bookings-generate` (после `docker compose down -v` и удаления `bookings/demodb`) проходит за ~1,5 минуты: база ставится, `busy()` → `f`, `bookings.bookings` от `2017-01-01 00:00:18` до `2017-01-01 23:59:59`.
|
||||||
|
- Ранее зависание на `busy()` при `jobs=1` лечится патчем: `process_queue` теперь синхронный, а `busy()` игнорирует текущий backend.
|
||||||
|
- Данных пока только на 1 день по умолчанию, чтобы генерация не занимала много времени.
|
||||||
|
|
||||||
|
## Идеи/следующие шаги
|
||||||
|
- Если понадобится больше дней — увеличивать `BOOKINGS_INIT_DAYS`, но помнить, что генерация может идти долго; контролировать через `SELECT busy();`.
|
||||||
|
- Следующий этап — PXF/ETL в Greenplum; текущая задача — лишь подготовить источник bookings.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# PXF для bookings в учебном стенде (актуально)
|
||||||
|
|
||||||
|
Этот документ описывает **текущую реализацию** PXF в проекте: чтение данных из демо‑БД
|
||||||
|
`bookings` (Postgres, сервис `bookings-db`) в Greenplum через JDBC.
|
||||||
|
|
||||||
|
## 1. Что должно работать
|
||||||
|
|
||||||
|
- В Greenplum доступны внешние таблицы:
|
||||||
|
- `public.ext_bookings_bookings` (создаётся `make ddl-gp`);
|
||||||
|
- `stg.bookings_ext` (создаётся DAG `bookings_stg_ddl`).
|
||||||
|
- PXF должен быть готов **после каждого старта** контейнера `greenplum`.
|
||||||
|
|
||||||
|
## 2. Почему мы делаем свой образ Greenplum
|
||||||
|
|
||||||
|
Изначально PXF‑скрипты/конфиги монтировались в контейнер как bind‑mount `:ro`.
|
||||||
|
Базовый entrypoint образа Greenplum пытается делать `chown` файлов в
|
||||||
|
`/docker-entrypoint-initdb.d/`, из‑за чего контейнер иногда падал с ошибкой:
|
||||||
|
|
||||||
|
`chown: changing ownership ... Read-only file system`
|
||||||
|
|
||||||
|
Снять `:ro` тоже нежелательно — можно получить проблемы с правами на файлах хоста
|
||||||
|
(файл становится `root`, IDE перестаёт сохранять, появляются лишние изменения в git).
|
||||||
|
|
||||||
|
Решение для учебного стенда:
|
||||||
|
|
||||||
|
- собрать **свой образ** Greenplum (`Dockerfile.greenplum`);
|
||||||
|
- «вшить» в образ seed‑файлы и скрипты PXF;
|
||||||
|
- на каждом старте контейнера идемпотентно докладывать файлы в `PXF_BASE`,
|
||||||
|
который живёт на persistent volume.
|
||||||
|
|
||||||
|
## 3. Где что хранится
|
||||||
|
|
||||||
|
**Внутри образа (immutable):**
|
||||||
|
|
||||||
|
- seed для PXF: `/opt/pxf-seed/` (JDBC‑JAR и `servers/bookings-db/jdbc-site.xml`);
|
||||||
|
- скрипты:
|
||||||
|
- `/opt/pxf-scripts/ensure_pxf_bookings.sh` (подготовка `PXF_BASE`);
|
||||||
|
- `/start_greenplum_with_pxf.sh` (startup wrapper).
|
||||||
|
|
||||||
|
**На persistent volume (переживает рестарты):**
|
||||||
|
|
||||||
|
- `PXF_BASE` по умолчанию: `${GREENPLUM_DATA_DIRECTORY}/pxf` → в нашем compose это
|
||||||
|
`/data/pxf` на томе `greenplum_data`.
|
||||||
|
|
||||||
|
Важно: так как `PXF_BASE` лежит на томе, обновления seed‑файлов из нового образа
|
||||||
|
**не перезатирают** файлы в `PXF_BASE` автоматически (это сделано намеренно, чтобы
|
||||||
|
не ломать ручные правки студентов).
|
||||||
|
|
||||||
|
## 4. Что происходит при старте контейнера `greenplum`
|
||||||
|
|
||||||
|
1) Docker запускает контейнер с базовым entrypoint образа и командой
|
||||||
|
`/start_greenplum_with_pxf.sh` (она задана в `Dockerfile.greenplum` как `CMD`).
|
||||||
|
|
||||||
|
2) `/start_greenplum_with_pxf.sh` выполняет подготовку PXF:
|
||||||
|
|
||||||
|
- запускает ensure‑скрипт `/opt/pxf-scripts/ensure_pxf_bookings.sh`;
|
||||||
|
- параллельно пытается выполнить `CREATE EXTENSION IF NOT EXISTS pxf`
|
||||||
|
в базе `${GP_DB}` (по умолчанию `gp_dwh`), когда Greenplum начинает принимать
|
||||||
|
подключения.
|
||||||
|
|
||||||
|
3) Затем управление передаётся оригинальному старту Greenplum: `exec /start_gpdb.sh`.
|
||||||
|
|
||||||
|
4) Healthcheck сервиса `greenplum` ждёт и готовность Greenplum, и то, что PXF уже
|
||||||
|
запущен (`pxf cluster status`). Это нужно, чтобы Airflow не стартовал раньше PXF.
|
||||||
|
|
||||||
|
## 5. Управляющие переменные окружения
|
||||||
|
|
||||||
|
Все переменные можно задать в `.env` (см. `.env.example`):
|
||||||
|
|
||||||
|
- `PXF_SEED_OVERWRITE=1` — принудительно перезаписать seed‑файлы из образа в `PXF_BASE`
|
||||||
|
(обычно нужно после правок в каталоге `pxf/`).
|
||||||
|
- `PXF_SYNC_ON_START=1` — выполнять `pxf cluster sync` при старте контейнера
|
||||||
|
(делает старт чуть дольше, но гарантирует актуальные конфиги на хостах кластера).
|
||||||
|
|
||||||
|
## 6. Быстрая ручная проверка
|
||||||
|
|
||||||
|
1) Дождаться `healthy` у `greenplum`:
|
||||||
|
|
||||||
|
`docker compose ps`
|
||||||
|
|
||||||
|
2) Проверить статус PXF (PXF CLI запускается только под пользователем `gpadmin`):
|
||||||
|
|
||||||
|
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
|
||||||
|
|
||||||
|
3) После применения DDL (`make ddl-gp`) проверить чтение через PXF:
|
||||||
|
|
||||||
|
- `make gp-psql`
|
||||||
|
- `SELECT COUNT(*) FROM public.ext_bookings_bookings;`
|
||||||
|
|
||||||
|
## 7. Типовые ошибки
|
||||||
|
|
||||||
|
- `protocol "pxf" does not exist`
|
||||||
|
- причина: не создано расширение `pxf` в базе Greenplum;
|
||||||
|
- решение: перезапустить `greenplum` (скрипт сделает `CREATE EXTENSION IF NOT EXISTS pxf`)
|
||||||
|
или выполнить вручную `CREATE EXTENSION pxf;`.
|
||||||
|
- `Connection refused` к порту `5888`
|
||||||
|
- причина: PXF не поднялся/не успел подняться;
|
||||||
|
- решение: проверить `pxf cluster status`, посмотреть логи PXF в `/data/pxf/logs`,
|
||||||
|
перезапустить сервис `greenplum`.
|
||||||
|
- PXF «не подхватывает» изменения конфигов
|
||||||
|
- причина: файлы уже лежат в `PXF_BASE` на томе, а seed из образа по умолчанию не перетирает их;
|
||||||
|
- решение: `make build` + restart `greenplum` + (при необходимости) `PXF_SEED_OVERWRITE=1`.
|
||||||
|
|
||||||
|
## 8. Известная проблема: `protocol "pxf" does not exist` на «холодном старте» (исправлено)
|
||||||
|
|
||||||
|
Раньше (воспроизводилось в `./scripts/e2e_smoke.sh`) при первом `make ddl-gp` можно было получить:
|
||||||
|
|
||||||
|
`ERROR: protocol "pxf" does not exist`
|
||||||
|
|
||||||
|
### Почему так происходило
|
||||||
|
|
||||||
|
В базовом `/start_gpdb.sh` из образа Greenplum создание расширения `pxf` связано с проверкой
|
||||||
|
файла `${PXF_BASE}/conf/pxf-env.sh`:
|
||||||
|
|
||||||
|
- если `pxf-env.sh` **отсутствует**, скрипт выполняет `pxf cluster prepare/register` и затем
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS pxf`;
|
||||||
|
- если `pxf-env.sh` **уже существует**, этот блок **пропускается**, и расширение может не появиться.
|
||||||
|
|
||||||
|
При этом наш ensure‑скрипт `pxf/init/10_pxf_bookings.sh` копировал `pxf-env.sh` в `${PXF_BASE}`
|
||||||
|
ещё до запуска Greenplum, из‑за чего базовый скрипт считал PXF “уже настроенным” и
|
||||||
|
пропускал создание расширения.
|
||||||
|
|
||||||
|
### Что изменили
|
||||||
|
|
||||||
|
- создание `extension pxf` вынесено в `start_greenplum_with_pxf.sh` и обёрнуто ретраями;
|
||||||
|
- `pxf-env.sh` по‑прежнему копируется в `PXF_BASE`, чтобы `/start_gpdb.sh` не пытался выполнять
|
||||||
|
`pxf cluster prepare` на непустом `PXF_BASE`;
|
||||||
|
- healthcheck `greenplum` ждёт не только PXF, но и наличие `extension pxf`.
|
||||||
|
- добавлен экспорт `PGPASSWORD` для `pxf cluster start`, чтобы `docker compose stop/start`
|
||||||
|
не ломал запуск из‑за `password authentication failed` для `gpadmin`.
|
||||||
|
|
||||||
|
### Если ошибка всё ещё возникает
|
||||||
|
|
||||||
|
1) Пересоберите образ и перезапустите контейнер `greenplum`:
|
||||||
|
`make build && make down && make up`
|
||||||
|
|
||||||
|
2) Проверьте наличие extension:
|
||||||
|
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gp_dwh -t -A -c \"SELECT extname FROM pg_extension WHERE extname = ''pxf'';\"'"`
|
||||||
|
|
||||||
|
## 9. Связанные файлы
|
||||||
|
|
||||||
|
- `Dockerfile.greenplum`
|
||||||
|
- `docker-compose.yml` (сервис `greenplum`: `build`, `hostname`, env, healthcheck)
|
||||||
|
- `pxf/init/10_pxf_bookings.sh` (ensure‑логика)
|
||||||
|
- `pxf/init/start_greenplum_with_pxf.sh` (старт контейнера)
|
||||||
|
- `docs/stack.md` (раздел «Greenplum + PXF: свой образ»)
|
||||||
|
|
||||||
|
## 10. Известная проблема: после `docker compose stop/start` Greenplum может упасть (auth для PXF)
|
||||||
|
|
||||||
|
### Симптом
|
||||||
|
|
||||||
|
После `docker compose stop`, затем `docker compose start` контейнер `greenplum` иногда уходит в `Exited (1)`.
|
||||||
|
В логах видно, что GPDB поднялся, но упал на старте PXF:
|
||||||
|
|
||||||
|
- `INFO - pxf cluster start`
|
||||||
|
- `ERROR: Could not connect to GPDB`
|
||||||
|
- `FATAL: password authentication failed for user "gpadmin"`
|
||||||
|
|
||||||
|
### Текущее понимание причины (почему это “иногда”)
|
||||||
|
|
||||||
|
1) При старте GPDB образ `woblerr/greenplum` генерирует/дописывает `pg_hba.conf` на persistent volume.
|
||||||
|
2) В `pg_hba.conf` присутствует trust‑правило для **конкретного IP** контейнера в docker‑сети
|
||||||
|
(пример из диагностики: `host all gpadmin 172.21.0.2/32 trust`).
|
||||||
|
3) После `docker compose stop/start` Docker может выдать контейнеру **другой IP** (например, `172.21.0.3`).
|
||||||
|
Тогда trust‑правило больше не подходит, и подключение начинает идти по `md5`.
|
||||||
|
4) `pxf cluster start` подключается к GPDB по TCP на `host=gpdbsne` (hostname контейнера),
|
||||||
|
то есть попадает именно в `pg_hba.conf` (а не в local‑auth).
|
||||||
|
5) В результате при “не совпавшем IP” получаем `md5` + пароль (возможно пустой/не тот) → падение на `28P01`.
|
||||||
|
|
||||||
|
Эта проблема выглядит флапающей, потому что IP после `stop/start` иногда совпадает с захардкоженным trust‑/32,
|
||||||
|
а иногда нет.
|
||||||
|
|
||||||
|
### Как подтвердить при следующем воспроизведении
|
||||||
|
|
||||||
|
1) Посмотреть логи `greenplum`:
|
||||||
|
`docker compose logs --tail=200 greenplum`
|
||||||
|
|
||||||
|
2) Найти реальный IP клиента в master‑логах GPDB (на томе):
|
||||||
|
`Password does not match ...` обычно содержит адрес вида `172.21.0.X`.
|
||||||
|
|
||||||
|
3) Сравнить его с trust‑строкой в `pg_hba.conf` на томе:
|
||||||
|
`/data/master/gpseg-1/pg_hba.conf`
|
||||||
|
|
||||||
|
Если IP в ошибке (например, `172.21.0.3`) **не** совпадает с trust‑/32 (например, `172.21.0.2/32`) —
|
||||||
|
это почти наверняка корень падения.
|
||||||
|
|
||||||
|
### Что с этим делать дальше (варианты решения, без реализации здесь)
|
||||||
|
|
||||||
|
Основная цель — убрать зависимость от “случайного IP после stop/start”:
|
||||||
|
|
||||||
|
- заставить `pxf cluster start` подключаться к GPDB через `127.0.0.1` (тогда работает существующий trust на localhost);
|
||||||
|
- или перестать добавлять в `pg_hba.conf` trust на конкретный `172.21.0.2/32` и заменить на более стабильное правило
|
||||||
|
(например, на подсеть docker‑сети или на `samehost`);
|
||||||
|
- или закрепить IP контейнера в compose (static IP), чтобы он не “плавал”;
|
||||||
|
- или отказаться от `stop/start` в пользу сценария, который не меняет сетевое окружение (но это хуже для UX студентов).
|
||||||
|
|
||||||
|
### Что реализовано
|
||||||
|
|
||||||
|
- В `pxf/init/start_greenplum_with_pxf.sh` добавлен шаг, который на каждом старте
|
||||||
|
обеспечивает в `pg_hba.conf` trust‑правило `host all gpadmin samehost trust`
|
||||||
|
(вставка перед `host all all 0.0.0.0/0 md5`), и делает `pg_ctl reload`, если GPDB уже запущен.
|
||||||
@@ -6,12 +6,6 @@
|
|||||||
>
|
>
|
||||||
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
|
> Аудитория документа: 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
|
> Зависимость: перед запуском этого плана нужно починить bookings-db
|
||||||
> (см. `docs/reference/bookings_db_issues.md`).
|
> (см. `docs/reference/bookings_db_issues.md`).
|
||||||
|
|
||||||
@@ -83,7 +77,7 @@ UNION ALL SELECT 'stg.seats', COUNT(*) FROM stg.seats
|
|||||||
UNION ALL SELECT 'stg.boarding_passes', COUNT(*) FROM stg.boarding_passes
|
UNION ALL SELECT 'stg.boarding_passes', COUNT(*) FROM stg.boarding_passes
|
||||||
ORDER BY 1;
|
ORDER BY 1;
|
||||||
|
|
||||||
-- ODS: эталонные таблицы не пустые (на main ods.airplanes и ods.seats пусты by design)
|
-- ODS: все 9 таблиц не пустые
|
||||||
SELECT 'ods.bookings' AS tbl, COUNT(*) FROM ods.bookings
|
SELECT 'ods.bookings' AS tbl, COUNT(*) FROM ods.bookings
|
||||||
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
|
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
|
||||||
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
|
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
|
||||||
@@ -95,7 +89,7 @@ UNION ALL SELECT 'ods.seats', COUNT(*) FROM ods.seats
|
|||||||
UNION ALL SELECT 'ods.boarding_passes', COUNT(*) FROM ods.boarding_passes
|
UNION ALL SELECT 'ods.boarding_passes', COUNT(*) FROM ods.boarding_passes
|
||||||
ORDER BY 1;
|
ORDER BY 1;
|
||||||
|
|
||||||
-- DDS: все 7 таблиц не пустые (на main dim_airplanes, dim_passengers, dim_routes пусты by design)
|
-- DDS: все 7 таблиц не пустые
|
||||||
SELECT 'dds.dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar
|
SELECT 'dds.dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar
|
||||||
UNION ALL SELECT 'dds.dim_airports', COUNT(*) FROM dds.dim_airports
|
UNION ALL SELECT 'dds.dim_airports', COUNT(*) FROM dds.dim_airports
|
||||||
UNION ALL SELECT 'dds.dim_airplanes', COUNT(*) FROM dds.dim_airplanes
|
UNION ALL SELECT 'dds.dim_airplanes', COUNT(*) FROM dds.dim_airplanes
|
||||||
@@ -105,7 +99,7 @@ UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes
|
|||||||
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
|
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
|
||||||
ORDER BY 1;
|
ORDER BY 1;
|
||||||
|
|
||||||
-- DM: все 5 витрин не пустые (на main только sales_report непуста, остальные 4 — заглушки)
|
-- DM: все 5 витрин не пустые
|
||||||
SELECT 'dm.sales_report' AS tbl, COUNT(*) FROM dm.sales_report
|
SELECT 'dm.sales_report' AS tbl, COUNT(*) FROM dm.sales_report
|
||||||
UNION ALL SELECT 'dm.route_performance', COUNT(*) FROM dm.route_performance
|
UNION ALL SELECT 'dm.route_performance', COUNT(*) FROM dm.route_performance
|
||||||
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
|
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
|
||||||
@@ -114,8 +108,7 @@ UNION ALL SELECT 'dm.monthly_overview', COUNT(*) FROM dm.monthly_overview
|
|||||||
ORDER BY 1;
|
ORDER BY 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ожидание (solution):** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
|
**Ожидание:** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
|
||||||
**Ожидание (main):** Эталонные таблицы > 0; студенческие (см. список выше) = 0 до реализации заданий.
|
|
||||||
|
|
||||||
#### B. Сквозная сверка количеств (STG → ODS)
|
#### B. Сквозная сверка количеств (STG → ODS)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ docker compose ps # проверить health
|
|||||||
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
|
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
|
||||||
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
|
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
|
||||||
|
|
||||||
Подробнее: см. ветку `solution` (`docs/reference/pxf_bookings.md`).
|
Подробнее: `docs/reference/pxf_bookings.md`.
|
||||||
|
|
||||||
## Airflow: свой образ
|
## Airflow: свой образ
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,83 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dds.dim_airplanes
|
-- DQ для DDS dim_airplanes.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
|
||||||
SELECT 1;
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_row_count BIGINT;
|
||||||
|
v_dup_sk BIGINT;
|
||||||
|
v_dup_bk BIGINT;
|
||||||
|
v_missing_bk BIGINT;
|
||||||
|
v_null_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Таблица не пуста.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_row_count
|
||||||
|
FROM dds.dim_airplanes;
|
||||||
|
|
||||||
|
IF v_row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ FAILED: dds.dim_airplanes пуста.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет дублей по SK.
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT airplane_sk)
|
||||||
|
INTO v_dup_sk
|
||||||
|
FROM dds.dim_airplanes;
|
||||||
|
|
||||||
|
IF v_dup_sk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_sk: %',
|
||||||
|
v_dup_sk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет дублей по BK.
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk)
|
||||||
|
INTO v_dup_bk
|
||||||
|
FROM dds.dim_airplanes;
|
||||||
|
|
||||||
|
IF v_dup_bk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_bk: %',
|
||||||
|
v_dup_bk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Покрытие ODS: все airplane_code из ODS есть в DDS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_missing_bk
|
||||||
|
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_airplanes AS d
|
||||||
|
WHERE d.airplane_bk = s.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_bk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_airplanes отсутствуют ключи из ods.airplanes: %',
|
||||||
|
v_missing_bk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обязательные поля.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM dds.dim_airplanes
|
||||||
|
WHERE airplane_sk IS NULL
|
||||||
|
OR airplane_bk IS NULL
|
||||||
|
OR airplane_bk = ''
|
||||||
|
OR model IS NULL
|
||||||
|
OR model = ''
|
||||||
|
OR total_seats IS NULL
|
||||||
|
OR created_at IS NULL
|
||||||
|
OR updated_at IS NULL
|
||||||
|
OR _load_id IS NULL
|
||||||
|
OR _load_id = ''
|
||||||
|
OR _load_ts IS NULL;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_airplanes найдены NULL/пустые обязательные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: dds.dim_airplanes ок, строк=%',
|
||||||
|
v_row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,4 +1,96 @@
|
|||||||
-- TODO: реализуйте загрузку dds.dim_airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка DDS dim_airplanes: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
||||||
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
|
||||||
SELECT 1;
|
WITH seats_agg AS (
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
COUNT(*)::INTEGER AS total_seats
|
||||||
|
FROM ods.seats AS s
|
||||||
|
GROUP BY s.airplane_code
|
||||||
|
),
|
||||||
|
src AS (
|
||||||
|
SELECT
|
||||||
|
a.airplane_code,
|
||||||
|
a.model,
|
||||||
|
a.range_km,
|
||||||
|
a.speed_kmh,
|
||||||
|
COALESCE(sa.total_seats, 0) AS total_seats
|
||||||
|
FROM ods.airplanes AS a
|
||||||
|
LEFT JOIN seats_agg AS sa
|
||||||
|
ON sa.airplane_code = a.airplane_code
|
||||||
|
)
|
||||||
|
UPDATE dds.dim_airplanes AS d
|
||||||
|
SET model = s.model,
|
||||||
|
range_km = s.range_km,
|
||||||
|
speed_kmh = s.speed_kmh,
|
||||||
|
total_seats = s.total_seats,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM src AS s
|
||||||
|
WHERE d.airplane_bk = s.airplane_code
|
||||||
|
AND (
|
||||||
|
d.model IS DISTINCT FROM s.model
|
||||||
|
OR d.range_km IS DISTINCT FROM s.range_km
|
||||||
|
OR d.speed_kmh IS DISTINCT FROM s.speed_kmh
|
||||||
|
OR d.total_seats IS DISTINCT FROM s.total_seats
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
|
||||||
|
WITH seats_agg AS (
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
COUNT(*)::INTEGER AS total_seats
|
||||||
|
FROM ods.seats AS s
|
||||||
|
GROUP BY s.airplane_code
|
||||||
|
),
|
||||||
|
src AS (
|
||||||
|
SELECT
|
||||||
|
a.airplane_code,
|
||||||
|
a.model,
|
||||||
|
a.range_km,
|
||||||
|
a.speed_kmh,
|
||||||
|
COALESCE(sa.total_seats, 0) AS total_seats
|
||||||
|
FROM ods.airplanes AS a
|
||||||
|
LEFT JOIN seats_agg AS sa
|
||||||
|
ON sa.airplane_code = a.airplane_code
|
||||||
|
),
|
||||||
|
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
|
||||||
|
-- Этот подход работает безопасно только потому, что Airflow запускает
|
||||||
|
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
|
||||||
|
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
|
||||||
|
max_sk AS (
|
||||||
|
SELECT COALESCE(MAX(airplane_sk), 0) AS v
|
||||||
|
FROM dds.dim_airplanes
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_airplanes (
|
||||||
|
airplane_sk,
|
||||||
|
airplane_bk,
|
||||||
|
model,
|
||||||
|
range_km,
|
||||||
|
speed_kmh,
|
||||||
|
total_seats,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
_load_id,
|
||||||
|
_load_ts
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airplane_code)::INTEGER,
|
||||||
|
s.airplane_code,
|
||||||
|
s.model,
|
||||||
|
s.range_km,
|
||||||
|
s.speed_kmh,
|
||||||
|
s.total_seats,
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
'{{ run_id }}',
|
||||||
|
now()
|
||||||
|
FROM src AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_airplanes AS d
|
||||||
|
WHERE d.airplane_bk = s.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dds.dim_airplanes;
|
||||||
|
|||||||
@@ -1,3 +1,100 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dds.dim_passengers
|
-- DQ для DDS dim_passengers.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
|
||||||
SELECT 1;
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_row_count BIGINT;
|
||||||
|
v_src_count BIGINT;
|
||||||
|
v_dup_sk BIGINT;
|
||||||
|
v_dup_bk BIGINT;
|
||||||
|
v_missing_bk BIGINT;
|
||||||
|
v_null_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Для инкрементальных периодов без новых билетов допускаем пустую dim_passengers.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_row_count
|
||||||
|
FROM dds.dim_passengers;
|
||||||
|
|
||||||
|
SELECT COUNT(DISTINCT passenger_id)
|
||||||
|
INTO v_src_count
|
||||||
|
FROM ods.tickets
|
||||||
|
WHERE passenger_id IS NOT NULL
|
||||||
|
AND passenger_id <> '';
|
||||||
|
|
||||||
|
IF v_row_count = 0 AND v_src_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: dds.dim_passengers пуста при непустом источнике ods.tickets (passenger_id=%).',
|
||||||
|
v_src_count;
|
||||||
|
ELSIF v_row_count = 0 AND v_src_count = 0 THEN
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: dds.dim_passengers пуста, т.к. в ods.tickets нет passenger_id для загрузки.';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет дублей по SK.
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT passenger_sk)
|
||||||
|
INTO v_dup_sk
|
||||||
|
FROM dds.dim_passengers;
|
||||||
|
|
||||||
|
IF v_dup_sk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_sk: %',
|
||||||
|
v_dup_sk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет дублей по BK (ID).
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT passenger_id)
|
||||||
|
INTO v_dup_bk
|
||||||
|
FROM dds.dim_passengers;
|
||||||
|
|
||||||
|
IF v_dup_bk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_id: %',
|
||||||
|
v_dup_bk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Покрытие ODS: все passenger_id из ods.tickets есть в DDS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_missing_bk
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT passenger_id
|
||||||
|
FROM ods.tickets
|
||||||
|
WHERE passenger_id IS NOT NULL
|
||||||
|
AND passenger_id <> ''
|
||||||
|
) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_passengers AS d
|
||||||
|
WHERE d.passenger_id = s.passenger_id
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_bk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_passengers отсутствуют passenger_id из ods.tickets: %',
|
||||||
|
v_missing_bk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обязательные поля.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM dds.dim_passengers
|
||||||
|
WHERE passenger_sk IS NULL
|
||||||
|
OR passenger_id IS NULL
|
||||||
|
OR passenger_id = ''
|
||||||
|
OR passenger_name IS NULL
|
||||||
|
OR passenger_name = ''
|
||||||
|
OR created_at IS NULL
|
||||||
|
OR updated_at IS NULL
|
||||||
|
OR _load_id IS NULL
|
||||||
|
OR _load_id = ''
|
||||||
|
OR _load_ts IS NULL;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_passengers найдены NULL/пустые обязательные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: dds.dim_passengers ок, строк=%',
|
||||||
|
v_row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,4 +1,67 @@
|
|||||||
-- TODO: реализуйте загрузку dds.dim_passengers (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка DDS dim_passengers: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
||||||
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
|
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
|
||||||
SELECT 1;
|
-- Это избавляет от дублирования сложной оконной функции в UPDATE и INSERT блоках.
|
||||||
|
CREATE TEMP TABLE tmp_passengers_src ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
d.passenger_id,
|
||||||
|
d.passenger_name
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
t.passenger_id,
|
||||||
|
t.passenger_name,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY t.passenger_id
|
||||||
|
ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ods.tickets AS t
|
||||||
|
WHERE t.passenger_id IS NOT NULL
|
||||||
|
AND t.passenger_id <> ''
|
||||||
|
AND t.passenger_name IS NOT NULL
|
||||||
|
AND t.passenger_name <> ''
|
||||||
|
) AS d
|
||||||
|
WHERE d.rn = 1;
|
||||||
|
|
||||||
|
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
|
||||||
|
UPDATE dds.dim_passengers AS d
|
||||||
|
SET passenger_name = s.passenger_name,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM tmp_passengers_src AS s
|
||||||
|
WHERE d.passenger_id = s.passenger_id
|
||||||
|
AND d.passenger_name IS DISTINCT FROM s.passenger_name;
|
||||||
|
|
||||||
|
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
|
||||||
|
WITH max_sk AS (
|
||||||
|
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
|
||||||
|
-- Этот подход работает безопасно только потому, что Airflow запускает
|
||||||
|
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
|
||||||
|
SELECT COALESCE(MAX(passenger_sk), 0) AS v
|
||||||
|
FROM dds.dim_passengers
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_passengers (
|
||||||
|
passenger_sk,
|
||||||
|
passenger_id,
|
||||||
|
passenger_name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
_load_id,
|
||||||
|
_load_ts
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.passenger_id)::INTEGER,
|
||||||
|
s.passenger_id,
|
||||||
|
s.passenger_name,
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
'{{ run_id }}',
|
||||||
|
now()
|
||||||
|
FROM tmp_passengers_src AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_passengers AS d
|
||||||
|
WHERE d.passenger_id = s.passenger_id
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dds.dim_passengers;
|
||||||
|
|||||||
+157
-3
@@ -1,3 +1,157 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dds.dim_routes (SCD2)
|
-- DQ для DDS dim_routes (SCD2).
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
|
||||||
SELECT 1;
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_row_count BIGINT;
|
||||||
|
v_dup_sk BIGINT;
|
||||||
|
v_dup_current BIGINT;
|
||||||
|
v_overlap_count BIGINT;
|
||||||
|
v_missing_count BIGINT;
|
||||||
|
v_orphan_current BIGINT;
|
||||||
|
v_null_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Таблица не пуста.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_row_count
|
||||||
|
FROM dds.dim_routes;
|
||||||
|
|
||||||
|
IF v_row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет дублей по SK.
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT route_sk)
|
||||||
|
INTO v_dup_sk
|
||||||
|
FROM dds.dim_routes;
|
||||||
|
|
||||||
|
IF v_dup_sk <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %',
|
||||||
|
v_dup_sk;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Корректность интервалов (valid_from <= valid_to для закрытых версий).
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM dds.dim_routes
|
||||||
|
WHERE valid_to IS NOT NULL
|
||||||
|
AND valid_from > valid_to;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Нет перекрытий интервалов для одного route_bk.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_overlap_count
|
||||||
|
FROM (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_routes AS d1
|
||||||
|
JOIN dds.dim_routes AS d2
|
||||||
|
ON d1.route_bk = d2.route_bk
|
||||||
|
AND d1.route_sk < d2.route_sk
|
||||||
|
AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31')
|
||||||
|
AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31')
|
||||||
|
) AS overlap_rows;
|
||||||
|
|
||||||
|
IF v_overlap_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %',
|
||||||
|
v_overlap_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Не более одной текущей версии на route_bk.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_dup_current
|
||||||
|
FROM (
|
||||||
|
SELECT route_bk
|
||||||
|
FROM dds.dim_routes
|
||||||
|
WHERE valid_to IS NULL
|
||||||
|
GROUP BY route_bk
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) AS d;
|
||||||
|
|
||||||
|
IF v_dup_current <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %',
|
||||||
|
v_dup_current;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Покрытие ODS: все route_no имеют хотя бы одну версию в DDS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_missing_count
|
||||||
|
FROM (SELECT DISTINCT route_no FROM ods.routes) AS o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_routes AS d
|
||||||
|
WHERE d.route_bk = o.route_no
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %',
|
||||||
|
v_missing_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Current-срез DDS не содержит route_bk, которых нет в ODS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_orphan_current
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT route_bk
|
||||||
|
FROM dds.dim_routes
|
||||||
|
WHERE valid_to IS NULL
|
||||||
|
) AS d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ods.routes AS o
|
||||||
|
WHERE o.route_no = d.route_bk
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_orphan_current <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %',
|
||||||
|
v_orphan_current;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обязательные поля.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM dds.dim_routes
|
||||||
|
WHERE route_sk IS NULL
|
||||||
|
OR route_bk IS NULL
|
||||||
|
OR route_bk = ''
|
||||||
|
OR departure_airport IS NULL
|
||||||
|
OR departure_airport = ''
|
||||||
|
OR arrival_airport IS NULL
|
||||||
|
OR arrival_airport = ''
|
||||||
|
OR airplane_code IS NULL
|
||||||
|
OR airplane_code = ''
|
||||||
|
OR departure_city IS NULL
|
||||||
|
OR departure_city = ''
|
||||||
|
OR arrival_city IS NULL
|
||||||
|
OR arrival_city = ''
|
||||||
|
OR airplane_model IS NULL
|
||||||
|
OR airplane_model = ''
|
||||||
|
OR total_seats IS NULL
|
||||||
|
OR hashdiff IS NULL
|
||||||
|
OR hashdiff = ''
|
||||||
|
OR valid_from IS NULL
|
||||||
|
OR created_at IS NULL
|
||||||
|
OR updated_at IS NULL
|
||||||
|
OR _load_id IS NULL
|
||||||
|
OR _load_id = ''
|
||||||
|
OR _load_ts IS NULL;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: dds.dim_routes ок, строк=% (версий)',
|
||||||
|
v_row_count;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|||||||
+144
-4
@@ -1,4 +1,144 @@
|
|||||||
-- TODO: реализуйте загрузку dds.dim_routes (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка DDS dim_routes: SCD2 с hashdiff.
|
||||||
-- Паттерн: SCD2 с hashdiff — самый нетривиальный паттерн в проекте.
|
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
|
||||||
SELECT 1;
|
-- Это избавляет от дублирования логики hashdiff в UPDATE и INSERT блоках.
|
||||||
|
CREATE TEMP TABLE tmp_routes_src ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
route_no,
|
||||||
|
departure_airport,
|
||||||
|
arrival_airport,
|
||||||
|
airplane_code,
|
||||||
|
days_of_week,
|
||||||
|
departure_time,
|
||||||
|
duration,
|
||||||
|
md5(
|
||||||
|
COALESCE(departure_airport, '') || '|' ||
|
||||||
|
COALESCE(arrival_airport, '') || '|' ||
|
||||||
|
COALESCE(airplane_code, '') || '|' ||
|
||||||
|
COALESCE(days_of_week::TEXT, '') || '|' ||
|
||||||
|
COALESCE(departure_time::TEXT, '') || '|' ||
|
||||||
|
COALESCE(duration::TEXT, '')
|
||||||
|
) AS hashdiff,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
|
||||||
|
FROM ods.routes;
|
||||||
|
|
||||||
|
-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата).
|
||||||
|
UPDATE dds.dim_routes AS d
|
||||||
|
SET valid_to = CURRENT_DATE,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM tmp_routes_src AS s
|
||||||
|
WHERE s.rn = 1
|
||||||
|
AND d.route_bk = s.route_no
|
||||||
|
AND d.valid_to IS NULL
|
||||||
|
AND d.hashdiff <> s.hashdiff;
|
||||||
|
|
||||||
|
-- Statement 1.1: Закрыть "исчезнувшие" маршруты.
|
||||||
|
UPDATE dds.dim_routes AS d
|
||||||
|
SET valid_to = CURRENT_DATE,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tmp_routes_src AS s
|
||||||
|
WHERE s.rn = 1
|
||||||
|
AND s.route_no = d.route_bk
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Statement 2: Вставить новые версии (для изменённых и новых route_no).
|
||||||
|
WITH max_sk AS (
|
||||||
|
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
|
||||||
|
-- Этот подход работает безопасно только потому, что Airflow запускает
|
||||||
|
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
|
||||||
|
SELECT COALESCE(MAX(route_sk), 0) AS v
|
||||||
|
FROM dds.dim_routes
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_routes (
|
||||||
|
route_sk,
|
||||||
|
route_bk,
|
||||||
|
departure_airport,
|
||||||
|
arrival_airport,
|
||||||
|
airplane_code,
|
||||||
|
departure_city,
|
||||||
|
arrival_city,
|
||||||
|
airplane_model,
|
||||||
|
total_seats,
|
||||||
|
days_of_week,
|
||||||
|
departure_time,
|
||||||
|
duration,
|
||||||
|
hashdiff,
|
||||||
|
valid_from,
|
||||||
|
valid_to,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
_load_id,
|
||||||
|
_load_ts
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER,
|
||||||
|
s.route_no,
|
||||||
|
s.departure_airport,
|
||||||
|
s.arrival_airport,
|
||||||
|
s.airplane_code,
|
||||||
|
dep.city,
|
||||||
|
arr.city,
|
||||||
|
air.model,
|
||||||
|
air.total_seats,
|
||||||
|
s.days_of_week,
|
||||||
|
s.departure_time,
|
||||||
|
s.duration,
|
||||||
|
s.hashdiff,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_routes AS d2
|
||||||
|
WHERE d2.route_bk = s.route_no
|
||||||
|
) THEN CURRENT_DATE
|
||||||
|
ELSE '1900-01-01'::DATE
|
||||||
|
END AS valid_from,
|
||||||
|
NULL,
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
'{{ run_id }}',
|
||||||
|
now()
|
||||||
|
FROM tmp_routes_src AS s
|
||||||
|
LEFT JOIN dds.dim_airports AS dep ON s.departure_airport = dep.airport_bk
|
||||||
|
LEFT JOIN dds.dim_airports AS arr ON s.arrival_airport = arr.airport_bk
|
||||||
|
LEFT JOIN dds.dim_airplanes AS air ON s.airplane_code = air.airplane_bk
|
||||||
|
WHERE s.rn = 1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_routes AS d
|
||||||
|
WHERE d.route_bk = s.route_no
|
||||||
|
AND d.valid_to IS NULL
|
||||||
|
AND d.hashdiff = s.hashdiff
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Фаза 3: Обновление денормализованных атрибутов (refresh).
|
||||||
|
-- Нужна для SCD1-изменений в dim_airports/dim_airplanes (напр. переименование города).
|
||||||
|
-- Обновляем все версии (и текущие, и исторические), т.к. измерения SCD1.
|
||||||
|
-- При этом мы не перезаписываем _load_id и _load_ts, чтобы не размывать lineage версий.
|
||||||
|
UPDATE dds.dim_routes AS d
|
||||||
|
SET departure_city = dep.city,
|
||||||
|
arrival_city = arr.city,
|
||||||
|
airplane_model = air.model,
|
||||||
|
total_seats = air.total_seats,
|
||||||
|
updated_at = now()
|
||||||
|
FROM dds.dim_airports AS dep,
|
||||||
|
dds.dim_airports AS arr,
|
||||||
|
dds.dim_airplanes AS air
|
||||||
|
WHERE dep.airport_bk = d.departure_airport
|
||||||
|
AND arr.airport_bk = d.arrival_airport
|
||||||
|
AND air.airplane_bk = d.airplane_code
|
||||||
|
AND (
|
||||||
|
d.departure_city IS DISTINCT FROM dep.city
|
||||||
|
OR d.arrival_city IS DISTINCT FROM arr.city
|
||||||
|
OR d.airplane_model IS DISTINCT FROM air.model
|
||||||
|
OR d.total_seats IS DISTINCT FROM air.total_seats
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dds.dim_routes;
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ DECLARE
|
|||||||
v_dup_count BIGINT;
|
v_dup_count BIGINT;
|
||||||
v_null_passenger BIGINT;
|
v_null_passenger BIGINT;
|
||||||
v_null_tariff BIGINT;
|
v_null_tariff BIGINT;
|
||||||
v_null_airport BIGINT;
|
v_null_route_related BIGINT;
|
||||||
v_null_student BIGINT;
|
|
||||||
v_null_calendar BIGINT;
|
v_null_calendar BIGINT;
|
||||||
v_null_required BIGINT;
|
v_null_required BIGINT;
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -63,17 +62,15 @@ BEGIN
|
|||||||
v_dup_count;
|
v_dup_count;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Учебный комментарий: passenger_sk будет NULL, пока вы не реализуете dim_passengers.
|
-- Ссылочная целостность: passenger_sk.
|
||||||
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
|
|
||||||
-- Полную версию DQ (с блокировкой) см. в ветке solution.
|
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
INTO v_null_passenger
|
INTO v_null_passenger
|
||||||
FROM dds.fact_flight_sales
|
FROM dds.fact_flight_sales
|
||||||
WHERE passenger_sk IS NULL;
|
WHERE passenger_sk IS NULL;
|
||||||
|
|
||||||
IF v_null_passenger <> 0 THEN
|
IF v_null_passenger <> 0 THEN
|
||||||
RAISE NOTICE
|
RAISE EXCEPTION
|
||||||
'DQ INFO: студенческий SK (passenger) NULL: %. После реализации dim_passengers: TRUNCATE fact → перезагрузка.',
|
'DQ FAILED: в fact_flight_sales строки без passenger_sk: %',
|
||||||
v_null_passenger;
|
v_null_passenger;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
@@ -89,40 +86,25 @@ BEGIN
|
|||||||
v_null_tariff;
|
v_null_tariff;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
|
-- Route-related FK: допустимо при аномалиях, фейлим если > 1%.
|
||||||
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
|
|
||||||
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
|
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
INTO v_null_airport
|
INTO v_null_route_related
|
||||||
FROM dds.fact_flight_sales
|
|
||||||
WHERE departure_airport_sk IS NULL
|
|
||||||
OR arrival_airport_sk IS NULL;
|
|
||||||
|
|
||||||
IF v_null_airport > 0 THEN
|
|
||||||
IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
|
|
||||||
RAISE EXCEPTION
|
|
||||||
'DQ FAILED: NULL airport_sk: % (>1%%)',
|
|
||||||
v_null_airport;
|
|
||||||
ELSE
|
|
||||||
RAISE NOTICE
|
|
||||||
'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)',
|
|
||||||
v_null_airport;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- route_sk и airplane_sk будут NULL, пока вы не реализуете dim_routes.
|
|
||||||
-- Не блокируем pipeline.
|
|
||||||
-- Полную версию DQ (с блокировкой) см. в ветке solution.
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_null_student
|
|
||||||
FROM dds.fact_flight_sales
|
FROM dds.fact_flight_sales
|
||||||
WHERE route_sk IS NULL
|
WHERE route_sk IS NULL
|
||||||
|
OR departure_airport_sk IS NULL
|
||||||
|
OR arrival_airport_sk IS NULL
|
||||||
OR airplane_sk IS NULL;
|
OR airplane_sk IS NULL;
|
||||||
|
|
||||||
IF v_null_student > 0 THEN
|
IF v_null_route_related > 0 THEN
|
||||||
|
IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)',
|
||||||
|
v_null_route_related;
|
||||||
|
ELSE
|
||||||
RAISE NOTICE
|
RAISE NOTICE
|
||||||
'DQ INFO: студенческие SK (route/airplane) NULL: %. После реализации dim_routes: TRUNCATE fact → перезагрузка.',
|
'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)',
|
||||||
v_null_student;
|
v_null_route_related;
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%.
|
-- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%.
|
||||||
|
|||||||
@@ -1,3 +1,49 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dm.airport_traffic
|
-- DQ проверки для витрины dm.airport_traffic.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Проверка арифметических инвариантов (сумма частей должна быть равна целому).
|
||||||
|
-- 2. Валидация уникальности составного ключа (зерна).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
row_count INTEGER;
|
||||||
|
duplicate_count INTEGER;
|
||||||
|
invalid_total_count INTEGER;
|
||||||
|
negative_metrics_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Проверка на наполненность
|
||||||
|
SELECT COUNT(*) INTO row_count FROM dm.airport_traffic;
|
||||||
|
IF row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: Таблица dm.airport_traffic пуста.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Проверка на уникальность (зерно - traffic_date + airport_sk)
|
||||||
|
SELECT COUNT(*) INTO duplicate_count
|
||||||
|
FROM (
|
||||||
|
SELECT traffic_date, airport_sk FROM dm.airport_traffic
|
||||||
|
GROUP BY traffic_date, airport_sk HAVING COUNT(*) > 1
|
||||||
|
) q;
|
||||||
|
IF duplicate_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены дубликаты по (date, airport_sk) (% шт).', duplicate_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Проверка инварианта total = departures + arrivals
|
||||||
|
SELECT COUNT(*) INTO invalid_total_count
|
||||||
|
FROM dm.airport_traffic
|
||||||
|
WHERE total_passengers != (departures_passengers + arrivals_passengers);
|
||||||
|
|
||||||
|
IF invalid_total_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic нарушен инвариант total_passengers = dep + arr (% строк).', invalid_total_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Проверка на отрицательные значения
|
||||||
|
SELECT COUNT(*) INTO negative_metrics_count
|
||||||
|
FROM dm.airport_traffic
|
||||||
|
WHERE departures_flights < 0 OR arrivals_flights < 0 OR departures_revenue < 0 OR arrivals_revenue < 0;
|
||||||
|
|
||||||
|
IF negative_metrics_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены отрицательные метрики (% строк).', negative_metrics_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'DQ Success: dm.airport_traffic успешно прошла все проверки (% строк).', row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,3 +1,131 @@
|
|||||||
-- TODO: реализуйте загрузку витрины dm.airport_traffic (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка витрины dm.airport_traffic: инкрементальный UPSERT по датам.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Обработка dual-role dimensions через UNION ALL:
|
||||||
|
-- Мы превращаем один билет (факт) в два "события": вылет и прилет.
|
||||||
|
-- Это позволяет собрать единую статистику аэропорта в одном проходе.
|
||||||
|
-- 2. Инкремент по датам (HWM): в этой витрине метрики ограничены сутками,
|
||||||
|
-- поэтому пересчитываем только те дни, где появились новые факты.
|
||||||
|
-- 3. Агрегация рейсов: используем COUNT(DISTINCT flight_id) для подсчета рейсов.
|
||||||
|
-- 4. Денормализация (SCD1): обновление атрибутов (город) при изменениях в измерении.
|
||||||
|
|
||||||
|
-- Шаг 1: Находим даты, затронутые новыми/измененными фактами.
|
||||||
|
CREATE TEMP TABLE tmp_traffic_affected_dates ON COMMIT DROP AS
|
||||||
|
SELECT DISTINCT cal.date_actual AS traffic_date
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
WHERE f._load_ts > (
|
||||||
|
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
|
||||||
|
FROM dm.airport_traffic
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 2: Unpivot (UNION ALL) и агрегация по затронутым датам.
|
||||||
|
CREATE TEMP TABLE tmp_airport_traffic_delta ON COMMIT DROP AS
|
||||||
|
WITH raw_events AS (
|
||||||
|
-- Роль 1: Аэропорт вылета
|
||||||
|
SELECT
|
||||||
|
cal.date_actual AS traffic_date,
|
||||||
|
f.departure_airport_sk AS airport_sk,
|
||||||
|
f.flight_id,
|
||||||
|
'departure' AS role,
|
||||||
|
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
|
||||||
|
f.price AS revenue
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Роль 2: Аэропорт прилета
|
||||||
|
SELECT
|
||||||
|
cal.date_actual AS traffic_date,
|
||||||
|
f.arrival_airport_sk AS airport_sk,
|
||||||
|
f.flight_id,
|
||||||
|
'arrival' AS role,
|
||||||
|
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
|
||||||
|
f.price AS revenue
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
e.traffic_date,
|
||||||
|
e.airport_sk,
|
||||||
|
a.airport_bk, -- Берем из dim_airports.airport_bk
|
||||||
|
a.city,
|
||||||
|
-- Агрегация вылетов
|
||||||
|
COUNT(DISTINCT CASE WHEN e.role = 'departure' THEN e.flight_id END) AS departures_flights,
|
||||||
|
SUM(CASE WHEN e.role = 'departure' THEN e.is_passenger ELSE 0 END) AS departures_passengers,
|
||||||
|
SUM(CASE WHEN e.role = 'departure' THEN e.revenue ELSE 0 END) AS departures_revenue,
|
||||||
|
-- Агрегация прилетов
|
||||||
|
COUNT(DISTINCT CASE WHEN e.role = 'arrival' THEN e.flight_id END) AS arrivals_flights,
|
||||||
|
SUM(CASE WHEN e.role = 'arrival' THEN e.is_passenger ELSE 0 END) AS arrivals_passengers,
|
||||||
|
SUM(CASE WHEN e.role = 'arrival' THEN e.revenue ELSE 0 END) AS arrivals_revenue,
|
||||||
|
-- Итого: все пассажиры (сели + вышли в этом аэропорту)
|
||||||
|
SUM(e.is_passenger) AS total_passengers
|
||||||
|
FROM raw_events e
|
||||||
|
JOIN dds.dim_airports a ON e.airport_sk = a.airport_sk
|
||||||
|
GROUP BY e.traffic_date, e.airport_sk, a.airport_bk, a.city;
|
||||||
|
|
||||||
|
-- Шаг 3: UPDATE существующих записей (включая денормализованные атрибуты).
|
||||||
|
UPDATE dm.airport_traffic AS tgt
|
||||||
|
SET
|
||||||
|
airport_bk = src.airport_bk,
|
||||||
|
city = src.city,
|
||||||
|
departures_flights = src.departures_flights,
|
||||||
|
departures_passengers = src.departures_passengers,
|
||||||
|
departures_revenue = src.departures_revenue,
|
||||||
|
arrivals_flights = src.arrivals_flights,
|
||||||
|
arrivals_passengers = src.arrivals_passengers,
|
||||||
|
arrivals_revenue = src.arrivals_revenue,
|
||||||
|
total_passengers = src.total_passengers,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM tmp_airport_traffic_delta AS src
|
||||||
|
WHERE tgt.traffic_date = src.traffic_date
|
||||||
|
AND tgt.airport_sk = src.airport_sk
|
||||||
|
AND (
|
||||||
|
tgt.total_passengers IS DISTINCT FROM src.total_passengers
|
||||||
|
OR tgt.departures_flights IS DISTINCT FROM src.departures_flights
|
||||||
|
OR tgt.arrivals_flights IS DISTINCT FROM src.arrivals_flights
|
||||||
|
OR tgt.city IS DISTINCT FROM src.city
|
||||||
|
OR tgt.airport_bk IS DISTINCT FROM src.airport_bk
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 4: INSERT новых записей.
|
||||||
|
INSERT INTO dm.airport_traffic (
|
||||||
|
traffic_date,
|
||||||
|
airport_sk,
|
||||||
|
airport_bk,
|
||||||
|
city,
|
||||||
|
departures_flights,
|
||||||
|
departures_passengers,
|
||||||
|
departures_revenue,
|
||||||
|
arrivals_flights,
|
||||||
|
arrivals_passengers,
|
||||||
|
arrivals_revenue,
|
||||||
|
total_passengers,
|
||||||
|
_load_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
src.traffic_date,
|
||||||
|
src.airport_sk,
|
||||||
|
src.airport_bk,
|
||||||
|
src.city,
|
||||||
|
src.departures_flights,
|
||||||
|
src.departures_passengers,
|
||||||
|
src.departures_revenue,
|
||||||
|
src.arrivals_flights,
|
||||||
|
src.arrivals_passengers,
|
||||||
|
src.arrivals_revenue,
|
||||||
|
src.total_passengers,
|
||||||
|
'{{ run_id }}' AS _load_id
|
||||||
|
FROM tmp_airport_traffic_delta AS src
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dm.airport_traffic AS tgt
|
||||||
|
WHERE tgt.traffic_date = src.traffic_date
|
||||||
|
AND tgt.airport_sk = src.airport_sk
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dm.airport_traffic;
|
||||||
|
|||||||
@@ -1,3 +1,52 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dm.monthly_overview
|
-- DQ проверки для витрины dm.monthly_overview.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Валидация логики двухуровневой агрегации: load factor не должен превышать 1.0
|
||||||
|
-- (в отличие от route_performance, где допускалась погрешность из-за упрощенного расчета).
|
||||||
|
-- 2. Базовые санити-проверки для дат (month BETWEEN 1 AND 12).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
row_count INTEGER;
|
||||||
|
duplicate_count INTEGER;
|
||||||
|
invalid_month_count INTEGER;
|
||||||
|
invalid_load_factor_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Проверка на наполненность
|
||||||
|
SELECT COUNT(*) INTO row_count FROM dm.monthly_overview;
|
||||||
|
IF row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: Таблица dm.monthly_overview пуста.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Проверка на уникальность (зерно - год + месяц + самолет)
|
||||||
|
SELECT COUNT(*) INTO duplicate_count
|
||||||
|
FROM (
|
||||||
|
SELECT year_actual, month_actual, airplane_sk FROM dm.monthly_overview
|
||||||
|
GROUP BY year_actual, month_actual, airplane_sk HAVING COUNT(*) > 1
|
||||||
|
) q;
|
||||||
|
IF duplicate_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружены дубликаты по (year, month, airplane_sk) (% шт).', duplicate_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Санити-проверка месяца
|
||||||
|
SELECT COUNT(*) INTO invalid_month_count
|
||||||
|
FROM dm.monthly_overview
|
||||||
|
WHERE month_actual < 1 OR month_actual > 12;
|
||||||
|
|
||||||
|
IF invalid_month_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружены некорректные месяцы (% строк).', invalid_month_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Проверка точного load_factor
|
||||||
|
-- В этой витрине мы считаем его честно (по рейсам), поэтому он строго <= 1.0
|
||||||
|
-- (исключая экзотические случаи овербукинга, но для учебного стенда ставим жесткий лимит 1.0).
|
||||||
|
SELECT COUNT(*) INTO invalid_load_factor_count
|
||||||
|
FROM dm.monthly_overview
|
||||||
|
WHERE avg_load_factor < 0 OR avg_load_factor > 1.0;
|
||||||
|
|
||||||
|
IF invalid_load_factor_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружен avg_load_factor вне диапазона 0..1 (% строк).', invalid_load_factor_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'DQ Success: dm.monthly_overview успешно прошла все проверки (% строк).', row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,3 +1,167 @@
|
|||||||
-- TODO: реализуйте загрузку витрины dm.monthly_overview (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка витрины dm.monthly_overview: инкрементальный UPSERT по месяцам.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Двухуровневая агрегация: чтобы честно посчитать среднюю заполняемость (avg_load_factor),
|
||||||
|
-- мы сначала считаем её для КАЖДОГО рейса, а затем берем среднее по месяцу.
|
||||||
|
-- 2. Ограничения SCD1: dim_airplanes — это SCD1-измерение. Мы берем total_seats из
|
||||||
|
-- его текущего состояния. Если бы самолет переоборудовали (изменили число мест) в прошлом,
|
||||||
|
-- для точного исторического расчета нам потребовалось бы SCD2-измерение.
|
||||||
|
|
||||||
|
-- Шаг 1: Находим затронутые месяцы (HWM).
|
||||||
|
CREATE TEMP TABLE tmp_monthly_affected_dates ON COMMIT DROP AS
|
||||||
|
SELECT DISTINCT cal.year_actual, cal.month_actual
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
WHERE f._load_ts > (
|
||||||
|
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
|
||||||
|
FROM dm.monthly_overview
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 2: Уровень 1 - Агрегация фактов до рейса (flight_id).
|
||||||
|
-- Считаем точный load_factor для каждого отдельного перелета.
|
||||||
|
CREATE TEMP TABLE tmp_flight_level_metrics ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
cal.year_actual,
|
||||||
|
cal.month_actual,
|
||||||
|
f.flight_id,
|
||||||
|
f.airplane_sk,
|
||||||
|
a.total_seats, -- ВНИМАНИЕ: берется текущее значение (SCD1)
|
||||||
|
COUNT(*) AS tickets_sold_per_flight,
|
||||||
|
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS boarded_per_flight,
|
||||||
|
SUM(f.price) AS revenue_per_flight,
|
||||||
|
-- Точный load factor конкретного рейса: посаженные пассажиры / кол-во мест
|
||||||
|
(SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END)::NUMERIC / NULLIF(a.total_seats, 0)) AS flight_load_factor
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
JOIN dds.dim_airplanes a ON f.airplane_sk = a.airplane_sk
|
||||||
|
-- Фильтруем только затронутые месяцы
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM tmp_monthly_affected_dates tad
|
||||||
|
WHERE tad.year_actual = cal.year_actual AND tad.month_actual = cal.month_actual
|
||||||
|
)
|
||||||
|
GROUP BY cal.year_actual, cal.month_actual, f.flight_id, f.airplane_sk, a.total_seats;
|
||||||
|
|
||||||
|
-- Шаг 3: Уровень 2 - Агрегация от рейсов до месяцев.
|
||||||
|
-- Плюс собираем уникальные метрики (пассажиры, маршруты) напрямую из фактов.
|
||||||
|
CREATE TEMP TABLE tmp_monthly_overview_delta ON COMMIT DROP AS
|
||||||
|
WITH flight_aggs AS (
|
||||||
|
SELECT
|
||||||
|
year_actual,
|
||||||
|
month_actual,
|
||||||
|
airplane_sk,
|
||||||
|
COUNT(DISTINCT flight_id) AS total_flights,
|
||||||
|
SUM(tickets_sold_per_flight) AS total_tickets,
|
||||||
|
SUM(boarded_per_flight) AS total_boarded,
|
||||||
|
SUM(revenue_per_flight) AS total_revenue,
|
||||||
|
-- Честное среднее от рейсовых показателей
|
||||||
|
AVG(flight_load_factor) AS avg_load_factor
|
||||||
|
FROM tmp_flight_level_metrics
|
||||||
|
GROUP BY year_actual, month_actual, airplane_sk
|
||||||
|
),
|
||||||
|
unique_aggs AS (
|
||||||
|
SELECT
|
||||||
|
cal.year_actual,
|
||||||
|
cal.month_actual,
|
||||||
|
f.airplane_sk,
|
||||||
|
COUNT(DISTINCT r.route_bk) AS unique_routes, -- Считаем по BK (SCD2)
|
||||||
|
COUNT(DISTINCT f.passenger_sk) AS unique_passengers
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
JOIN dds.dim_routes r ON f.route_sk = r.route_sk
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM tmp_monthly_affected_dates tad
|
||||||
|
WHERE tad.year_actual = cal.year_actual AND tad.month_actual = cal.month_actual
|
||||||
|
)
|
||||||
|
GROUP BY cal.year_actual, cal.month_actual, f.airplane_sk
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
fa.year_actual,
|
||||||
|
fa.month_actual,
|
||||||
|
fa.airplane_sk,
|
||||||
|
a.airplane_bk,
|
||||||
|
a.model AS airplane_model,
|
||||||
|
a.total_seats,
|
||||||
|
fa.total_flights,
|
||||||
|
fa.total_tickets,
|
||||||
|
fa.total_boarded,
|
||||||
|
fa.total_revenue,
|
||||||
|
(fa.total_revenue / NULLIF(fa.total_tickets, 0))::NUMERIC(10,2) AS avg_ticket_price,
|
||||||
|
fa.avg_load_factor::NUMERIC(5,4) AS avg_load_factor,
|
||||||
|
ua.unique_routes,
|
||||||
|
ua.unique_passengers
|
||||||
|
FROM flight_aggs fa
|
||||||
|
JOIN unique_aggs ua ON fa.year_actual = ua.year_actual AND fa.month_actual = ua.month_actual AND fa.airplane_sk = ua.airplane_sk
|
||||||
|
JOIN dds.dim_airplanes a ON fa.airplane_sk = a.airplane_sk;
|
||||||
|
|
||||||
|
-- Шаг 4: UPDATE существующих записей.
|
||||||
|
UPDATE dm.monthly_overview AS tgt
|
||||||
|
SET
|
||||||
|
airplane_bk = src.airplane_bk,
|
||||||
|
airplane_model = src.airplane_model,
|
||||||
|
total_seats = src.total_seats,
|
||||||
|
total_flights = src.total_flights,
|
||||||
|
total_tickets = src.total_tickets,
|
||||||
|
total_boarded = src.total_boarded,
|
||||||
|
total_revenue = src.total_revenue,
|
||||||
|
avg_ticket_price = src.avg_ticket_price,
|
||||||
|
avg_load_factor = src.avg_load_factor,
|
||||||
|
unique_routes = src.unique_routes,
|
||||||
|
unique_passengers = src.unique_passengers,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM tmp_monthly_overview_delta AS src
|
||||||
|
WHERE tgt.year_actual = src.year_actual
|
||||||
|
AND tgt.month_actual = src.month_actual
|
||||||
|
AND tgt.airplane_sk = src.airplane_sk
|
||||||
|
AND (
|
||||||
|
tgt.total_flights IS DISTINCT FROM src.total_flights
|
||||||
|
OR tgt.total_revenue IS DISTINCT FROM src.total_revenue
|
||||||
|
OR tgt.total_boarded IS DISTINCT FROM src.total_boarded
|
||||||
|
OR tgt.unique_passengers IS DISTINCT FROM src.unique_passengers
|
||||||
|
OR tgt.airplane_model IS DISTINCT FROM src.airplane_model
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 5: INSERT новых записей.
|
||||||
|
INSERT INTO dm.monthly_overview (
|
||||||
|
year_actual,
|
||||||
|
month_actual,
|
||||||
|
airplane_sk,
|
||||||
|
airplane_bk,
|
||||||
|
airplane_model,
|
||||||
|
total_seats,
|
||||||
|
total_flights,
|
||||||
|
total_tickets,
|
||||||
|
total_boarded,
|
||||||
|
total_revenue,
|
||||||
|
avg_ticket_price,
|
||||||
|
avg_load_factor,
|
||||||
|
unique_routes,
|
||||||
|
unique_passengers,
|
||||||
|
_load_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
src.year_actual,
|
||||||
|
src.month_actual,
|
||||||
|
src.airplane_sk,
|
||||||
|
src.airplane_bk,
|
||||||
|
src.airplane_model,
|
||||||
|
src.total_seats,
|
||||||
|
src.total_flights,
|
||||||
|
src.total_tickets,
|
||||||
|
src.total_boarded,
|
||||||
|
src.total_revenue,
|
||||||
|
src.avg_ticket_price,
|
||||||
|
src.avg_load_factor,
|
||||||
|
src.unique_routes,
|
||||||
|
src.unique_passengers,
|
||||||
|
'{{ run_id }}' AS _load_id
|
||||||
|
FROM tmp_monthly_overview_delta AS src
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dm.monthly_overview AS tgt
|
||||||
|
WHERE tgt.year_actual = src.year_actual
|
||||||
|
AND tgt.month_actual = src.month_actual
|
||||||
|
AND tgt.airplane_sk = src.airplane_sk
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dm.monthly_overview;
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dm.passenger_loyalty
|
-- DQ проверки для витрины dm.passenger_loyalty.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Валидация бизнес-логики дат (стаж не может быть отрицательным).
|
||||||
|
-- 2. Учебная проверка ссылочной целостности (FK Integrity).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
row_count INTEGER;
|
||||||
|
duplicate_count INTEGER;
|
||||||
|
invalid_dates_count INTEGER;
|
||||||
|
orphan_keys_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Проверка на наполненность
|
||||||
|
SELECT COUNT(*) INTO row_count FROM dm.passenger_loyalty;
|
||||||
|
IF row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: Таблица dm.passenger_loyalty пуста.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Проверка на уникальность (зерно - passenger_sk)
|
||||||
|
SELECT COUNT(*) INTO duplicate_count
|
||||||
|
FROM (
|
||||||
|
SELECT passenger_sk FROM dm.passenger_loyalty
|
||||||
|
GROUP BY passenger_sk HAVING COUNT(*) > 1
|
||||||
|
) q;
|
||||||
|
IF duplicate_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены дубликаты по passenger_sk (% шт).', duplicate_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Проверка логики дат
|
||||||
|
SELECT COUNT(*) INTO invalid_dates_count
|
||||||
|
FROM dm.passenger_loyalty
|
||||||
|
WHERE first_flight_date > last_flight_date;
|
||||||
|
|
||||||
|
IF invalid_dates_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены записи с first_date > last_date (% строк).', invalid_dates_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Учебная проверка ссылочной целостности (FK Check)
|
||||||
|
-- В продакшене это обычно гарантируется JOIN при загрузке, но здесь мы показываем саму возможность проверки.
|
||||||
|
SELECT COUNT(*) INTO orphan_keys_count
|
||||||
|
FROM dm.passenger_loyalty tgt
|
||||||
|
LEFT JOIN dds.dim_passengers p ON tgt.passenger_sk = p.passenger_sk
|
||||||
|
WHERE p.passenger_sk IS NULL;
|
||||||
|
|
||||||
|
IF orphan_keys_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены пассажиры, отсутствующие в dim_passengers (% строк).', orphan_keys_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'DQ Success: dm.passenger_loyalty успешно прошла все проверки (% строк).', row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,3 +1,131 @@
|
|||||||
-- TODO: реализуйте загрузку витрины dm.passenger_loyalty (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка витрины dm.passenger_loyalty: инкрементальный UPSERT по затронутым ключам.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Метод "затронутых ключей": HWM по _load_ts находит затронутые ID пассажиров,
|
||||||
|
-- а затем мы ПЕРЕСЧИТЫВАЕМ всю историю именно для этого круга лиц.
|
||||||
|
-- Это гарантирует точность накопительных агрегатов (total_spent, dates).
|
||||||
|
-- 2. Использование DISTINCT ON (PostgreSQL-специфика): самый лаконичный способ
|
||||||
|
-- найти "самое частое" (моду) в рамках группы.
|
||||||
|
-- 3. Обработка NULL в фактах: фильтрация (passenger_sk IS NOT NULL).
|
||||||
|
-- 4. Агрегация SCD2-измерений: при подсчете уникальных маршрутов (dim_routes)
|
||||||
|
-- нужно агрегировать по BK (route_bk), т.к. один маршрут может иметь несколько SK (версий).
|
||||||
|
|
||||||
|
-- Шаг 1: Находим ID пассажиров, чьи данные изменились или добавились в фактах.
|
||||||
|
CREATE TEMP TABLE tmp_loyalty_affected_keys ON COMMIT DROP AS
|
||||||
|
SELECT DISTINCT passenger_sk
|
||||||
|
FROM dds.fact_flight_sales
|
||||||
|
WHERE _load_ts > (
|
||||||
|
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
|
||||||
|
FROM dm.passenger_loyalty
|
||||||
|
)
|
||||||
|
AND passenger_sk IS NOT NULL;
|
||||||
|
|
||||||
|
-- Шаг 2: Для затронутых лиц считаем ИТОГОВЫЕ агрегаты по ВСЕЙ истории фактов.
|
||||||
|
CREATE TEMP TABLE tmp_passenger_delta ON COMMIT DROP AS
|
||||||
|
WITH base_metrics AS (
|
||||||
|
-- Агрегируем количественные метрики.
|
||||||
|
-- JOIN dds.dim_routes нужен для подсчета УНИКАЛЬНЫХ маршрутов по BK (т.к. dim_routes - SCD2).
|
||||||
|
SELECT
|
||||||
|
f.passenger_sk,
|
||||||
|
COUNT(DISTINCT f.book_ref) AS total_bookings,
|
||||||
|
COUNT(*) AS total_flights,
|
||||||
|
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS total_boarded,
|
||||||
|
SUM(f.price) AS total_spent,
|
||||||
|
COUNT(DISTINCT r.route_bk) AS unique_routes, -- Агрегация по BK (бизнес-ключу) маршрута
|
||||||
|
MIN(cal.date_actual) AS first_flight_date,
|
||||||
|
MAX(cal.date_actual) AS last_flight_date
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
JOIN dds.dim_routes r ON f.route_sk = r.route_sk
|
||||||
|
WHERE f.passenger_sk IN (SELECT passenger_sk FROM tmp_loyalty_affected_keys)
|
||||||
|
GROUP BY f.passenger_sk
|
||||||
|
),
|
||||||
|
fare_modes AS (
|
||||||
|
-- Находим самый частый класс обслуживания для каждого пассажира.
|
||||||
|
-- DISTINCT ON при ничьей выбирает произвольный вариант из топ-результатов.
|
||||||
|
SELECT DISTINCT ON (f.passenger_sk)
|
||||||
|
f.passenger_sk,
|
||||||
|
tar.fare_conditions AS favorite_fare_conditions
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_tariffs tar ON f.tariff_sk = tar.tariff_sk
|
||||||
|
WHERE f.passenger_sk IN (SELECT passenger_sk FROM tmp_loyalty_affected_keys)
|
||||||
|
GROUP BY f.passenger_sk, tar.fare_conditions
|
||||||
|
ORDER BY f.passenger_sk, COUNT(*) DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bm.*,
|
||||||
|
fm.favorite_fare_conditions,
|
||||||
|
p.passenger_id AS passenger_bk,
|
||||||
|
p.passenger_name,
|
||||||
|
(bm.last_flight_date - bm.first_flight_date) AS days_as_customer
|
||||||
|
FROM base_metrics bm
|
||||||
|
JOIN dds.dim_passengers p ON bm.passenger_sk = p.passenger_sk
|
||||||
|
JOIN fare_modes fm ON bm.passenger_sk = fm.passenger_sk;
|
||||||
|
|
||||||
|
-- Шаг 3: UPDATE существующих записей.
|
||||||
|
UPDATE dm.passenger_loyalty AS tgt
|
||||||
|
SET
|
||||||
|
passenger_name = src.passenger_name,
|
||||||
|
total_bookings = src.total_bookings,
|
||||||
|
total_flights = src.total_flights,
|
||||||
|
total_boarded = src.total_boarded,
|
||||||
|
total_spent = src.total_spent,
|
||||||
|
avg_ticket_price = (src.total_spent / NULLIF(src.total_flights, 0))::NUMERIC(10,2),
|
||||||
|
favorite_fare_conditions = src.favorite_fare_conditions,
|
||||||
|
unique_routes = src.unique_routes,
|
||||||
|
first_flight_date = src.first_flight_date,
|
||||||
|
last_flight_date = src.last_flight_date,
|
||||||
|
days_as_customer = src.days_as_customer,
|
||||||
|
updated_at = now(),
|
||||||
|
_load_id = '{{ run_id }}',
|
||||||
|
_load_ts = now()
|
||||||
|
FROM tmp_passenger_delta AS src
|
||||||
|
WHERE tgt.passenger_sk = src.passenger_sk
|
||||||
|
AND (
|
||||||
|
-- Обновляем только если что-то реально изменилось
|
||||||
|
tgt.total_flights IS DISTINCT FROM src.total_flights
|
||||||
|
OR tgt.total_boarded IS DISTINCT FROM src.total_boarded
|
||||||
|
OR tgt.total_spent IS DISTINCT FROM src.total_spent
|
||||||
|
OR tgt.last_flight_date IS DISTINCT FROM src.last_flight_date
|
||||||
|
OR tgt.passenger_name IS DISTINCT FROM src.passenger_name
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 4: INSERT новых пассажиров.
|
||||||
|
INSERT INTO dm.passenger_loyalty (
|
||||||
|
passenger_sk,
|
||||||
|
passenger_bk,
|
||||||
|
passenger_name,
|
||||||
|
total_bookings,
|
||||||
|
total_flights,
|
||||||
|
total_boarded,
|
||||||
|
total_spent,
|
||||||
|
avg_ticket_price,
|
||||||
|
favorite_fare_conditions,
|
||||||
|
unique_routes,
|
||||||
|
first_flight_date,
|
||||||
|
last_flight_date,
|
||||||
|
days_as_customer,
|
||||||
|
_load_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
src.passenger_sk,
|
||||||
|
src.passenger_bk,
|
||||||
|
src.passenger_name,
|
||||||
|
src.total_bookings,
|
||||||
|
src.total_flights,
|
||||||
|
src.total_boarded,
|
||||||
|
src.total_spent,
|
||||||
|
(src.total_spent / NULLIF(src.total_flights, 0))::NUMERIC(10,2),
|
||||||
|
src.favorite_fare_conditions,
|
||||||
|
src.unique_routes,
|
||||||
|
src.first_flight_date,
|
||||||
|
src.last_flight_date,
|
||||||
|
src.days_as_customer,
|
||||||
|
'{{ run_id }}' AS _load_id
|
||||||
|
FROM tmp_passenger_delta AS src
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM dm.passenger_loyalty AS tgt
|
||||||
|
WHERE tgt.passenger_sk = src.passenger_sk
|
||||||
|
);
|
||||||
|
|
||||||
|
ANALYZE dm.passenger_loyalty;
|
||||||
|
|||||||
@@ -1,3 +1,59 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для dm.route_performance
|
-- DQ проверки для витрины dm.route_performance.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Использование PL/pgSQL блоков (DO $$ ...) для кастомных проверок.
|
||||||
|
-- 2. Валидация бизнес-инвариантов после загрузки Full Rebuild.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
row_count INTEGER;
|
||||||
|
duplicate_count INTEGER;
|
||||||
|
invalid_metrics_count INTEGER;
|
||||||
|
null_attributes_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Проверка на наполненность
|
||||||
|
SELECT COUNT(*) INTO row_count FROM dm.route_performance;
|
||||||
|
IF row_count = 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: Таблица dm.route_performance пуста после загрузки.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Проверка на уникальность бизнес-ключа
|
||||||
|
SELECT COUNT(*) INTO duplicate_count
|
||||||
|
FROM (
|
||||||
|
SELECT route_bk FROM dm.route_performance
|
||||||
|
GROUP BY route_bk HAVING COUNT(*) > 1
|
||||||
|
) AS q;
|
||||||
|
IF duplicate_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены дубликаты по route_bk (% шт).', duplicate_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Проверка бизнес-метрик (инварианты)
|
||||||
|
-- Load factor не может быть отрицательным или физически невозможным (например, более 200%)
|
||||||
|
SELECT COUNT(*) INTO invalid_metrics_count
|
||||||
|
FROM dm.route_performance
|
||||||
|
WHERE avg_load_factor < 0 OR avg_load_factor > 2.0;
|
||||||
|
|
||||||
|
IF invalid_metrics_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены некорректные показатели load factor (% строк).', invalid_metrics_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Проверка обязательных атрибутов на NULL
|
||||||
|
SELECT COUNT(*) INTO null_attributes_count
|
||||||
|
FROM dm.route_performance
|
||||||
|
WHERE departure_city IS NULL OR arrival_city IS NULL OR airplane_model IS NULL;
|
||||||
|
|
||||||
|
IF null_attributes_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены NULL-значения в обязательных полях (% строк).', null_attributes_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Проверка: посаженных не может быть больше, чем купивших билет
|
||||||
|
SELECT COUNT(*) INTO invalid_metrics_count
|
||||||
|
FROM dm.route_performance
|
||||||
|
WHERE total_boarded > total_tickets;
|
||||||
|
|
||||||
|
IF invalid_metrics_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'DQ Error: В dm.route_performance total_boarded > total_tickets (% строк).', invalid_metrics_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'DQ Success: dm.route_performance успешно прошла все проверки (% строк).', row_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,3 +1,83 @@
|
|||||||
-- TODO: реализуйте загрузку витрины dm.route_performance (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка витрины dm.route_performance: Full Rebuild.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
--
|
||||||
SELECT 1;
|
-- Учебные цели:
|
||||||
|
-- 1. Демонстрация TRUNCATE + INSERT. Таблица маленькая (~1000 строк),
|
||||||
|
-- пересоздать её с нуля дешевле, чем вычислять дельту.
|
||||||
|
-- 2. Обработка SCD2: агрегируем факты по бизнес-ключу route_bk,
|
||||||
|
-- чтобы собрать данные со всех исторических версий маршрута.
|
||||||
|
-- 3. Денормализация атрибутов из ТЕКУЩЕЙ (актуальной) версии измерения.
|
||||||
|
|
||||||
|
-- Шаг 1: Очистка таблицы (AO Column Store не поддерживает эффективный DELETE/UPDATE).
|
||||||
|
TRUNCATE dm.route_performance;
|
||||||
|
|
||||||
|
-- Шаг 2: Агрегация всех фактов по бизнес-ключу маршрута.
|
||||||
|
-- Используем временную таблицу для удобства и читаемости.
|
||||||
|
CREATE TEMP TABLE tmp_route_metrics ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
r.route_bk,
|
||||||
|
COUNT(DISTINCT f.flight_id) AS total_flights,
|
||||||
|
COUNT(*) AS total_tickets,
|
||||||
|
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS total_boarded,
|
||||||
|
SUM(f.price) AS total_revenue,
|
||||||
|
MIN(cal.date_actual) AS first_flight_date,
|
||||||
|
MAX(cal.date_actual) AS last_flight_date,
|
||||||
|
-- Доля посаженных пассажиров среди всех проданных билетов маршрута
|
||||||
|
-- (упрощение: не взвешено по рейсам)
|
||||||
|
AVG(CASE WHEN f.is_boarded THEN 1 ELSE 0 END::NUMERIC) AS avg_boarding_rate
|
||||||
|
FROM dds.fact_flight_sales f
|
||||||
|
JOIN dds.dim_routes r ON f.route_sk = r.route_sk
|
||||||
|
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
|
||||||
|
GROUP BY r.route_bk;
|
||||||
|
|
||||||
|
-- Шаг 3: Объединяем агрегаты с АКТУАЛЬНЫМИ атрибутами маршрута.
|
||||||
|
-- Учебный комментарий: Благодаря денормализации dim_routes — один JOIN вместо четырёх.
|
||||||
|
INSERT INTO dm.route_performance (
|
||||||
|
route_bk,
|
||||||
|
route_sk,
|
||||||
|
departure_airport_bk,
|
||||||
|
departure_city,
|
||||||
|
arrival_airport_bk,
|
||||||
|
arrival_city,
|
||||||
|
airplane_bk,
|
||||||
|
airplane_model,
|
||||||
|
total_seats,
|
||||||
|
total_flights,
|
||||||
|
total_tickets,
|
||||||
|
total_boarded,
|
||||||
|
total_revenue,
|
||||||
|
avg_ticket_price,
|
||||||
|
avg_boarding_rate,
|
||||||
|
avg_load_factor,
|
||||||
|
first_flight_date,
|
||||||
|
last_flight_date,
|
||||||
|
_load_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
m.route_bk,
|
||||||
|
r_curr.route_sk,
|
||||||
|
r_curr.departure_airport AS departure_airport_bk,
|
||||||
|
r_curr.departure_city,
|
||||||
|
r_curr.arrival_airport AS arrival_airport_bk,
|
||||||
|
r_curr.arrival_city,
|
||||||
|
r_curr.airplane_code AS airplane_bk,
|
||||||
|
r_curr.airplane_model,
|
||||||
|
r_curr.total_seats,
|
||||||
|
m.total_flights,
|
||||||
|
m.total_tickets,
|
||||||
|
m.total_boarded,
|
||||||
|
m.total_revenue,
|
||||||
|
-- Средний чек
|
||||||
|
(m.total_revenue / NULLIF(m.total_tickets, 0))::NUMERIC(10,2),
|
||||||
|
m.avg_boarding_rate::NUMERIC(5,4),
|
||||||
|
-- Load Factor: общее кол-во посадок / (кол-во рейсов * мест в самолете)
|
||||||
|
ROUND(
|
||||||
|
m.total_boarded::NUMERIC / NULLIF(m.total_flights * r_curr.total_seats, 0),
|
||||||
|
4
|
||||||
|
) AS avg_load_factor,
|
||||||
|
m.first_flight_date,
|
||||||
|
m.last_flight_date,
|
||||||
|
'{{ run_id }}' AS _load_id
|
||||||
|
FROM tmp_route_metrics m
|
||||||
|
JOIN dds.dim_routes r_curr ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL;
|
||||||
|
|
||||||
|
ANALYZE dm.route_performance;
|
||||||
|
|||||||
@@ -1,3 +1,99 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для ods.airplanes
|
-- DQ для ODS airplanes.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
|
||||||
SELECT 1;
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text;
|
||||||
|
v_stg_batch_count BIGINT;
|
||||||
|
v_dup_count BIGINT;
|
||||||
|
v_missing_keys_count BIGINT;
|
||||||
|
v_extra_keys_count BIGINT;
|
||||||
|
v_null_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Для snapshot-справочников пустой батч — ошибка.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_stg_batch_count
|
||||||
|
FROM stg.airplanes
|
||||||
|
WHERE _load_id = v_batch_id;
|
||||||
|
|
||||||
|
IF v_stg_batch_count = 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: _load_id=% для stg.airplanes пустой. Проверьте загрузку STG и PXF.',
|
||||||
|
v_batch_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- В ODS не должно быть дублей по бизнес-ключу.
|
||||||
|
SELECT COUNT(*) - COUNT(DISTINCT airplane_code)
|
||||||
|
INTO v_dup_count
|
||||||
|
FROM ods.airplanes;
|
||||||
|
|
||||||
|
IF v_dup_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.airplanes найдены дубликаты airplane_code: %',
|
||||||
|
v_dup_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Все ключи из STG текущего батча должны присутствовать в ODS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_missing_keys_count
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code
|
||||||
|
FROM stg.airplanes
|
||||||
|
WHERE _load_id = v_batch_id
|
||||||
|
) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ods.airplanes AS o
|
||||||
|
WHERE o.airplane_code = s.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_keys_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.airplanes отсутствуют ключи из stg.airplanes (_load_id=%): %',
|
||||||
|
v_batch_id,
|
||||||
|
v_missing_keys_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_extra_keys_count
|
||||||
|
FROM ods.airplanes AS o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code
|
||||||
|
FROM stg.airplanes
|
||||||
|
WHERE _load_id = v_batch_id
|
||||||
|
) AS s
|
||||||
|
WHERE s.airplane_code = o.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_extra_keys_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.airplanes найдены лишние ключи вне stg _load_id=%: %',
|
||||||
|
v_batch_id,
|
||||||
|
v_extra_keys_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обязательные поля в ODS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM ods.airplanes
|
||||||
|
WHERE airplane_code IS NULL
|
||||||
|
OR airplane_code = ''
|
||||||
|
OR model IS NULL
|
||||||
|
OR model = ''
|
||||||
|
OR _load_id IS NULL
|
||||||
|
OR _load_id = ''
|
||||||
|
OR _load_ts IS NULL;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.airplanes найдены NULL/пустые обязательные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: ods.airplanes ок (_load_id=%): stg_batch_rows=%',
|
||||||
|
v_batch_id,
|
||||||
|
v_stg_batch_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
-- TODO: реализуйте загрузку ods.airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка ODS по airplanes: Полная перезагрузка (TRUNCATE + INSERT).
|
||||||
-- Паттерн: TRUNCATE + INSERT (snapshot-справочник на AO-таблице).
|
-- Почему: для справочников-снимков в Greenplum на AO-таблицах
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
-- эффективнее перетереть данные целиком, чем делать медленный UPDATE.
|
||||||
SELECT 1;
|
|
||||||
|
TRUNCATE TABLE ods.airplanes;
|
||||||
|
|
||||||
|
INSERT INTO ods.airplanes (
|
||||||
|
airplane_code,
|
||||||
|
model,
|
||||||
|
range_km,
|
||||||
|
speed_kmh,
|
||||||
|
_load_id,
|
||||||
|
_load_ts
|
||||||
|
)
|
||||||
|
WITH src AS (
|
||||||
|
-- Выбираем последний снимок из STG для текущего батча
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
s.model::json->>'ru' AS model,
|
||||||
|
NULLIF(s.range, '')::INTEGER AS range_km,
|
||||||
|
NULLIF(s.speed, '')::INTEGER AS speed_kmh,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY s.airplane_code
|
||||||
|
ORDER BY s._load_ts DESC, s.event_ts DESC NULLS LAST
|
||||||
|
) AS rn
|
||||||
|
FROM stg.airplanes AS s
|
||||||
|
WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
s.model,
|
||||||
|
s.range_km,
|
||||||
|
s.speed_kmh,
|
||||||
|
'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text,
|
||||||
|
now()
|
||||||
|
FROM src AS s
|
||||||
|
WHERE s.rn = 1;
|
||||||
|
|
||||||
|
ANALYZE ods.airplanes;
|
||||||
|
|||||||
+15
-19
@@ -140,25 +140,21 @@ BEGIN
|
|||||||
v_orphan_arrival_count;
|
v_orphan_arrival_count;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Проверка RI airplane_code → ods.airplanes закомментирована,
|
-- Ссылочная целостность: airplane_code должен существовать в ods.airplanes.
|
||||||
-- т.к. таблица ods.airplanes реализуется студентом.
|
SELECT COUNT(*)
|
||||||
-- После реализации — раскомментируйте этот блок.
|
INTO v_orphan_airplane_count
|
||||||
-- Полную версию см. в ветке solution.
|
FROM ods.routes AS r
|
||||||
--
|
WHERE NOT EXISTS (
|
||||||
-- SELECT COUNT(*)
|
SELECT 1
|
||||||
-- INTO v_orphan_airplane_count
|
FROM ods.airplanes AS a
|
||||||
-- FROM ods.routes AS r
|
WHERE a.airplane_code = r.airplane_code
|
||||||
-- WHERE NOT EXISTS (
|
);
|
||||||
-- SELECT 1
|
|
||||||
-- FROM ods.airplanes AS a
|
IF v_orphan_airplane_count <> 0 THEN
|
||||||
-- WHERE a.airplane_code = r.airplane_code
|
RAISE EXCEPTION
|
||||||
-- );
|
'DQ FAILED: в ods.routes найдены строки с невалидным airplane_code: %',
|
||||||
--
|
v_orphan_airplane_count;
|
||||||
-- IF v_orphan_airplane_count <> 0 THEN
|
END IF;
|
||||||
-- RAISE EXCEPTION
|
|
||||||
-- 'DQ FAILED: в ods.routes найдены строки с невалидным airplane_code: %',
|
|
||||||
-- v_orphan_airplane_count;
|
|
||||||
-- END IF;
|
|
||||||
|
|
||||||
RAISE NOTICE
|
RAISE NOTICE
|
||||||
'DQ PASSED: ods.routes ок (_load_id=%): stg_batch_rows=%',
|
'DQ PASSED: ods.routes ок (_load_id=%): stg_batch_rows=%',
|
||||||
|
|||||||
+125
-3
@@ -1,3 +1,125 @@
|
|||||||
-- TODO: реализуйте проверки качества данных для ods.seats
|
-- DQ для ODS seats.
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
|
||||||
SELECT 1;
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text;
|
||||||
|
v_stg_batch_count BIGINT;
|
||||||
|
v_dup_count BIGINT;
|
||||||
|
v_missing_keys_count BIGINT;
|
||||||
|
v_extra_keys_count BIGINT;
|
||||||
|
v_null_count BIGINT;
|
||||||
|
v_orphan_airplane_count BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Для snapshot-справочников пустой батч — ошибка.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_stg_batch_count
|
||||||
|
FROM stg.seats
|
||||||
|
WHERE _load_id = v_batch_id;
|
||||||
|
|
||||||
|
IF v_stg_batch_count = 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: _load_id=% для stg.seats пустой. Проверьте загрузку STG и PXF.',
|
||||||
|
v_batch_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- В ODS не должно быть дублей по составному бизнес-ключу.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_dup_count
|
||||||
|
FROM (
|
||||||
|
SELECT airplane_code, seat_no
|
||||||
|
FROM ods.seats
|
||||||
|
GROUP BY airplane_code, seat_no
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) AS d;
|
||||||
|
|
||||||
|
IF v_dup_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.seats найдены дубликаты (airplane_code, seat_no): %',
|
||||||
|
v_dup_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Все ключи из STG текущего батча должны присутствовать в ODS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_missing_keys_count
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code, seat_no
|
||||||
|
FROM stg.seats
|
||||||
|
WHERE _load_id = v_batch_id
|
||||||
|
) AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ods.seats AS o
|
||||||
|
WHERE o.airplane_code = s.airplane_code
|
||||||
|
AND o.seat_no = s.seat_no
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_missing_keys_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.seats отсутствуют ключи из stg.seats (_load_id=%): %',
|
||||||
|
v_batch_id,
|
||||||
|
v_missing_keys_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_extra_keys_count
|
||||||
|
FROM ods.seats AS o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT airplane_code, seat_no
|
||||||
|
FROM stg.seats
|
||||||
|
WHERE _load_id = v_batch_id
|
||||||
|
) AS s
|
||||||
|
WHERE s.airplane_code = o.airplane_code
|
||||||
|
AND s.seat_no = o.seat_no
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_extra_keys_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.seats найдены лишние ключи вне stg _load_id=%: %',
|
||||||
|
v_batch_id,
|
||||||
|
v_extra_keys_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Обязательные поля в ODS.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_null_count
|
||||||
|
FROM ods.seats
|
||||||
|
WHERE airplane_code IS NULL
|
||||||
|
OR airplane_code = ''
|
||||||
|
OR seat_no IS NULL
|
||||||
|
OR seat_no = ''
|
||||||
|
OR fare_conditions IS NULL
|
||||||
|
OR fare_conditions = ''
|
||||||
|
OR _load_id IS NULL
|
||||||
|
OR _load_id = ''
|
||||||
|
OR _load_ts IS NULL;
|
||||||
|
|
||||||
|
IF v_null_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.seats найдены NULL/пустые обязательные поля: %',
|
||||||
|
v_null_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ссылочная целостность: airplane_code должен существовать в ods.airplanes.
|
||||||
|
SELECT COUNT(*)
|
||||||
|
INTO v_orphan_airplane_count
|
||||||
|
FROM ods.seats AS s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ods.airplanes AS a
|
||||||
|
WHERE a.airplane_code = s.airplane_code
|
||||||
|
);
|
||||||
|
|
||||||
|
IF v_orphan_airplane_count <> 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'DQ FAILED: в ods.seats найдены строки с невалидным airplane_code: %',
|
||||||
|
v_orphan_airplane_count;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE
|
||||||
|
'DQ PASSED: ods.seats ок (_load_id=%): stg_batch_rows=%',
|
||||||
|
v_batch_id,
|
||||||
|
v_stg_batch_count;
|
||||||
|
END $$;
|
||||||
|
|||||||
+36
-4
@@ -1,4 +1,36 @@
|
|||||||
-- TODO: реализуйте загрузку ods.seats (см. ТЗ в docs/assignment/analyst_spec.md)
|
-- Загрузка ODS по seats: Полная перезагрузка (TRUNCATE + INSERT).
|
||||||
-- Паттерн: TRUNCATE + INSERT (snapshot-справочник на AO-таблице).
|
-- Почему: для справочников-снимков в Greenplum на AO-таблицах
|
||||||
-- Эталонную реализацию можно найти в ветке solution.
|
-- эффективнее перетереть данные целиком, чем делать медленный UPDATE.
|
||||||
SELECT 1;
|
|
||||||
|
TRUNCATE TABLE ods.seats;
|
||||||
|
|
||||||
|
INSERT INTO ods.seats (
|
||||||
|
airplane_code,
|
||||||
|
seat_no,
|
||||||
|
fare_conditions,
|
||||||
|
_load_id,
|
||||||
|
_load_ts
|
||||||
|
)
|
||||||
|
WITH src AS (
|
||||||
|
-- Выбираем последний снимок из STG для текущего батча
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
s.seat_no,
|
||||||
|
s.fare_conditions,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY s.airplane_code, s.seat_no
|
||||||
|
ORDER BY s._load_ts DESC, s.event_ts DESC NULLS LAST
|
||||||
|
) AS rn
|
||||||
|
FROM stg.seats AS s
|
||||||
|
WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.airplane_code,
|
||||||
|
s.seat_no,
|
||||||
|
s.fare_conditions,
|
||||||
|
'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text,
|
||||||
|
now()
|
||||||
|
FROM src AS s
|
||||||
|
WHERE s.rn = 1;
|
||||||
|
|
||||||
|
ANALYZE ods.seats;
|
||||||
|
|||||||
@@ -239,9 +239,8 @@ def test_bookings_to_gp_ods_dag_structure():
|
|||||||
), "airplanes не должен быть upstream для airports"
|
), "airplanes не должен быть upstream для airports"
|
||||||
|
|
||||||
# Барьеры по данным.
|
# Барьеры по данным.
|
||||||
# На main routes не зависит от airplanes (ods.airplanes — студенческая заглушка).
|
|
||||||
# На ветке solution: _assert_reachable(dag, "dq_ods_airplanes", "dq_ods_routes")
|
|
||||||
_assert_reachable(dag, "dq_ods_airports", "dq_ods_routes")
|
_assert_reachable(dag, "dq_ods_airports", "dq_ods_routes")
|
||||||
|
_assert_reachable(dag, "dq_ods_airplanes", "dq_ods_routes")
|
||||||
_assert_reachable(dag, "dq_ods_airplanes", "dq_ods_seats")
|
_assert_reachable(dag, "dq_ods_airplanes", "dq_ods_seats")
|
||||||
_assert_reachable(dag, "dq_ods_routes", "dq_ods_flights")
|
_assert_reachable(dag, "dq_ods_routes", "dq_ods_flights")
|
||||||
_assert_reachable(dag, "dq_ods_flights", "dq_ods_segments")
|
_assert_reachable(dag, "dq_ods_flights", "dq_ods_segments")
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
# На main airplanes/seats — заглушки (SELECT 1;), не содержат TRUNCATE-паттерн.
|
SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats")
|
||||||
# Полный список на ветке solution: ("airports", "airplanes", "routes", "seats")
|
|
||||||
SNAPSHOT_ENTITIES = ("airports", "routes")
|
|
||||||
|
|
||||||
|
|
||||||
def _read(path: str) -> str:
|
def _read(path: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user