19 Commits
Author SHA1 Message Date
ddmitry 6658a3b1aa Merge pull request 'feat(labs): прием справочника через PXF в учебном маршруте' (#16) from docs/9-pxf-airports into main 2026-09-22 18:29:17 +03:00
ddadmin 1e72776254 feat(labs): добавлена лабораторная приема справочника через PXF 2026-09-22 11:26:21 -04:00
ddadmin 1dd5838fb5 docs(labs): добавлены ссылки на программу в solution
Указатели в AGENTS.md и оглавлении ведут к авторской заметке в solution; учебная ветка сохраняет свой состав документации.

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

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

Проверки: make lint; make test (4 passed, 14 skipped); карта;
292 локальные ссылки; маршрут чтения и карта в Chromium.
Живой ETL не запускался.
2026-09-20 15:53:39 -04:00
ddmitry a6fdd43e15 docs: связаны карта DWH, разбор SQL и задания (#6) 2026-09-20 22:14:28 +03:00
ddadmin 20eb7f7b84 docs: уточнен порядок загрузок после SCD2-проверки 2026-09-20 15:13:20 -04:00
ddadmin b7ecfb1bb7 docs: связаны карта DWH, разбор SQL и задания 2026-09-20 14:31:49 -04:00
ddmitry b6e950f52c docs: добавлены карта и руководство по структуре DWH (#5) 2026-09-20 20:23:59 +03:00
ddadmin ec9dd4c1a8 docs(map): ссылки на материалы перенесены в Gitea 2026-09-20 13:20:39 -04:00
ddadmin 6be0442838 docs(map): уточнены заголовки и добавлены образцы легенды 2026-09-20 13:11:44 -04:00
ddadmin b9bcfe7230 docs(map): добавлены карта и руководство по структуре DWH
Объяснены зерно таблиц, связи и путь бронирования до витрины.
Добавлена автономная карта с двумя видами связей и адресами объектов.
Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись.

Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped),
Chromium file:// offline: выбор, связи, сброс, история и узкий экран.

Refs #2
2026-09-20 13:01:57 -04:00
ddadminandClaude Opus 5 77d72b08c6 docs(agents): настройка трекера, меток триажа и доменных доков для скиллов
- AGENTS.md: блок «Agent skills» со ссылками на docs/agents/
- docs/agents/issue-tracker.md: Gitea через tea CLI
- docs/agents/triage-labels.md: стандартные пять меток
- docs/agents/domain.md: один контекст, CONTEXT.md + docs/adr/
- CLAUDE.md: секция «Читабельность языка»

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:59:47 -04:00
ddadmin 98030f8b0d Merge branch 'docs/zoom-ladder': лесенка схем, метод чтения пайплайна и таксономия DQ 2026-07-20 14:58:56 +03:00
ddadminandClaude Fable 5 4a04a2caf6 docs: лесенка схем, метод чтения пайплайна и таксономия DQ
Одна диаграмма отвечает на один вопрос: db_schema.md перестроен в лесенку
из четырёх схем (слои, звезда, сборка факта, витрины), полная lineage-карта
перенесена в конец как справочная. Новые документы: reading_the_pipeline.md
(метод чтения FROM/JOIN load-скрипта, разбор fact_src) и dq_taxonomy.md
(три класса DQ-проверок на реальных скриптах, семантика NULL). Точки входа:
корневой README, docs/README.md, assignment/README.md, qa-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:56:42 +03:00
ddadminandClaude Opus 4.6 eb4cbbe730 docs(main): адаптирован qa-plan для ветки main (пометки заглушек)
- Зачем:
  - qa-plan утверждал «ВСЕ таблицы > 0», что неверно на main с 9 заглушками.
- Что:
  - добавлена секция в intro со списком пустых таблиц на main.
  - SQL-комментарии DDS/DM дополнены пометками заглушек.
  - ожидание разделено на solution (все > 0) и main (эталонные > 0).
- Проверка:
  - make test (4 passed, 14 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:35:48 +03:00
ddadminandClaude Opus 4.6 af27314878 docs(main): онбординг студента — гид, маркетинг, адаптация docs и docstrings
- Зачем:
  - на main docs и DAG-docstrings утверждали «все реализовано», хотя 18 заглушек;
    студент застревал после шага 6 без явного «что дальше».
- Что:
  - README: маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
  - docs/assignment/README: полный гид студента (эталон → ТЗ → заглушки → validate).
  - analyst_spec: DAG-интеграция — «таски уже подключены, менять DAG не нужно».
  - docs ODS/DDS/DM: пометки заглушек, адаптация секций проверки результата.
  - 4 DAG docstrings: эталон vs задания (заглушки).
- Проверка:
  - make test (4 passed, 14 skipped), make lint (clean).
  - grep «все 5 витрин|все реализован» — ложных утверждений без оговорок нет.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:49:40 +03:00
ddadminandClaude Opus 4.6 5b98a5b203 docs(main): очистка docs, адаптация для студентов, починка ссылок
Шаг 5 плана main/solution split:
- Удалены внутренние документы с main: plans/, archive/, PRD,
  assignment_design, pxf_bookings, bookings_tz, benchmarks, TODO.md
- AGENTS.md: убраны упоминания plans/archive, agent-dag-testing
- Починены 19 битых markdown-ссылок во всех оставшихся файлах
- bookings_ods_design: airplanes/seats помечены как студенческие,
  обновлён DAG-граф (routes без зависимости от airplanes)
- bookings_dds_design: обновлено описание DQ факта (student SK)
- bookings_to_gp_dds: обновлена DQ-семантика для main
- qa-plan: уточнено — ods.airplanes/seats пусты by design на main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:14:22 +03:00
ddadminandClaude Opus 4.6 5ca4e6b2ca feat(main): заглушки студенческих файлов, ослабление DQ, адаптация тестов
- fact_flight_sales_dq: student SK (passenger/route/airplane) → NOTICE,
  airport_sk → порог 1% (эталон через ods.routes)
- routes_dq: закомментирован RI airplane_code → ods.airplanes
- ODS DAG: убрана зависимость dq_ods_airplanes → load_ods_routes
- 18 студенческих файлов заменены заглушками (ODS/DDS/DM load+dq)
- test_ods_sql_contract: SNAPSHOT_ENTITIES без airplanes/seats
- test_dags_smoke: убран assert dq_ods_airplanes → dq_ods_routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:14 +03:00
80 changed files with 13004 additions and 6925 deletions

No files matched your search

+21 -3
View File
@@ -12,9 +12,9 @@
- `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
- `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
- *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `plans/` (активные планы), `archive/` (выполненные планы), `assignment/` (задание для студента).
- `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `assignment/` (задание для студента).
- `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
- Нейминг файлов планов: `YYYY-MM-DD_краткое-описание.md` (дата — ISO 8601, `_` отделяет дату от описания, описание в kebab-case, без суффикса `-plan`).
- Полные дизайн-документы (PRD, assignment_design, планы, архив) — в ветке `solution`.
- `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
- `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
@@ -61,7 +61,6 @@
- Есть smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
- Для программной проверки DAG (без браузера) — см. `docs/agent-dag-testing.md`: CLI, REST API, проверка параллельности, запросы в Greenplum.
## Pull Requests
- Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`.
@@ -74,7 +73,26 @@
- `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
## Для агента (особенности аудитории)
При создании и пересмотре лабораторных сначала прочитайте
[развитие лабораторных в ветке solution](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/solution/docs/design/labs_development.md):
аудитория, принятые решения и критерии заданий.
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
- Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`.
## Agent skills
### Issue tracker
Issues живут в Gitea (`git.dementev.space/ddmitry/airflow-greenplum`), работа через `tea` CLI. См. `docs/agents/issue-tracker.md`.
### Triage labels
Стандартный набор: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. См. `docs/agents/triage-labels.md`.
### Domain docs
Один контекст: `CONTEXT.md` и `docs/adr/` в корне репо. См. `docs/agents/domain.md`.
+6
View File
@@ -1 +1,7 @@
@AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+4
View File
@@ -224,10 +224,14 @@ test:
lint:
$(UV) run black --check airflow tests
$(UV) run isort --check-only airflow tests
$(UV) run black --check scripts/check_airports_snapshot.py
$(UV) run isort --check-only scripts/check_airports_snapshot.py
fmt:
$(UV) run black airflow tests
$(UV) run isort airflow tests
$(UV) run black scripts/check_airports_snapshot.py
$(UV) run isort scripts/check_airports_snapshot.py
clean-venv:
python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)"
+27 -9
View File
@@ -1,5 +1,9 @@
# airflow-dwh-gp-lab
> Этот стенд можно использовать самостоятельно, но он также является курсовой работой
> плана обучения [Data Engineering Roadmap](https://github.com/dementev-dev/de-roadmap).
> Нужна помощь? Обратитесь к автору материалов — ментору [@dementev_dev](https://t.me/dementev_dev).
Учебный стенд для лабораторных по Data Engineering: **Airflow** оркестрирует загрузку данных из демо‑БД
**bookings** (Postgres) в **Greenplum**.
@@ -83,17 +87,26 @@ make bookings-init # быстрое восстановление из s
make gp-psql
-- внутри psql:
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.tickets;
SELECT COUNT(*) FROM dds.dim_routes;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.fact_flight_sales;
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance;
-- Остальные DDS/DM-таблицы пока пусты — вы реализуете их в задании.
```
Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`.
Порядок повторных запусков и переходов между слоями описан в
[руководстве по запуску DAG](docs/dag_execution_order.md).
7) Разберитесь в данных и приступайте к заданию:
Стенд работает, данные загружены. Откройте
[руководство о структуре DWH](docs/design/db_schema.md): на примере поездки
оно проведет от билетов и сегментов к факту и витрине. Рядом держите
[интерактивную карту](docs/design/architecture-map.html) — локальный файл
`docs/design/architecture-map.html` нужно открыть в браузере; в Gitea HTML
показывается как исходный текст. Затем переходите к
[учебным заданиям](docs/assignment/README.md): начните с приема справочника
через PXF, после него — загрузки ODS.
## DAG-и в стенде
@@ -102,8 +115,9 @@ SELECT COUNT(*) FROM dm.route_performance;
- `bookings_stg_ddl` — создаёт/обновляет весь STG слой для bookings (9 таблиц: bookings, tickets, airports, airplanes,
routes, seats, flights, segments, boarding_passes; включая внешние `*_ext` через PXF);
- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки.
- `lab_pxf_airports` — готовит общий снимок четырех справочников для [лабораторной PXF](docs/assignment/pxf_airports.md); использует те же SQL и DQ, без генерации транзакций.
- `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings.
- `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет DQ‑проверки.
- `bookings_to_gp_ods` — заменяет снимки справочников и обновляет транзакционные таблицы ODS из STG; выполняет DQ-проверки.
- `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings.
- `bookings_to_gp_dds` — загружает данные из ODS в DDS (SCD1/SCD2 + факт) и выполняет DQ‑проверки.
- `bookings_dm_ddl` — создаёт/обновляет DM-витрины (sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview).
@@ -143,14 +157,18 @@ make clean # полный reset: удалить контейнер
## Документация
- [Учебные задания](docs/assignment/README.md)
- [План тестирования/проверок и негативные кейсы](TESTING.md)
- [Структура DWH: от бронирования до витрины](docs/design/db_schema.md)
- [От карты к SQL загрузки](docs/design/reading_the_pipeline.md)
- [Учебные задания: порядок работы и проверка](docs/assignment/README.md)
- [Интерактивная карта таблиц и связей](docs/design/architecture-map.html)
- [Дополнительные заметки и технические детали](docs/README.md)
- [Детали по STG DAG](docs/bookings_to_gp_stage.md)
- [Детали по ODS DAG](docs/bookings_to_gp_ods.md)
- [Детали по DDS DAG](docs/bookings_to_gp_dds.md)
- [Детали по DM DAG](docs/bookings_to_gp_dm.md)
Для работы над репозиторием: [проверки разработки и стенда](TESTING.md).
## Типичные проблемы и решения
| Проблема | Решение |
+104 -66
View File
@@ -1,80 +1,118 @@
# План тестирования (для студентов)
# Проверка изменений репозитория
Этот документ — пошаговый чек‑лист, как проверить, что всё работает: от «быстрых локальных проверок» до запуска стенда в Docker и просмотра данных в Greenplum. Подходит начинающим: просто выполняйте шаги по порядку.
Этот документ предназначен для работы над кодом, документацией и стендом.
Если вы выполняете учебное задание, начните с
[его самопроверки](docs/assignment/README.md#запуск-и-проверка): там объяснены
`*_dq.sql`, группы `bookings_validate` и порядок восстановления после SCD2-теста.
Если что‑то пошло не так, смотрите раздел «Быстрый reset» ниже.
## Локальные проверки
## 1. Быстрая проверка окружения
- `uv sync` — подтягиваем Python и зависимости из `pyproject.toml`/`uv.lock`.
- Проверяем версию uv: `uv --version` (ожидаем ≥ 0.9).
- Убедитесь, что `docker compose version` доступна и Docker запущен.
Из корня репозитория:
## 2. Локальные автоматические проверки (без Docker)
- `make test` — короткие unit-тесты (`tests/test_greenplum_helpers.py`, `tests/test_dags_smoke.py`).
- Smoke-тесты DAG автоматически `skip`, если Airflow не установлен в venv, поэтому прогонится за миллисекунды.
- `make lint` — black/isort в режиме проверки (после `make fmt` должен проходить без ошибок).
- `make fmt` — автоисправление форматирования; полезно запускать перед пушем.
- (опционально) `uv run pytest -q -k dags_smoke` — только DAG smoke.
```bash
uv sync
make lint
make test
```
## 3. Подготовка Docker-стенда
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные:
- `GP_PORT` — внутренний порт Greenplum в Docker-сети (по умолчанию 5432, менять не нужно); внешний порт для подключения с хоста фиксирован на `5435`, поэтому локальный PostgreSQL на 5432 не помешает.
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
- Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, выполните `make build` перед `make up`.
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`); `greenplum` считается `healthy` только когда поднят и Greenplum, и PXF.
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
`make lint` проверяет Black и isort для `airflow/` и `tests/`.
`make fmt` исправляет форматирование в этих каталогах.
Тесты находятся в трех файлах:
## 4. Smoke тесты DAG в Airflow UI
1. Открыть http://localhost:8080 (admin/admin).
2. (опционально) Зайти в Admin → Connections и убедиться, что DAG’и видят подключения:
- `greenplum_conn` и `bookings_db` задаются через переменные `AIRFLOW_CONN_...` в docker-compose и могут не отображаться в списке, но `airflow connections get greenplum_conn` / `bookings_db` внутри контейнера должны отрабатывать без ошибок.
- [test_ods_sql_contract.py](tests/test_ods_sql_contract.py) проверяет
контракты SQL справочников ODS по исходным файлам.
- [test_dags_smoke.py](tests/test_dags_smoke.py) проверяет структуру DAG.
Если импорт `airflow` не предоставляет `DAG`, проверки пропускаются.
Смотрите причины пропусков: `uv run pytest -q -rs`.
- [test_ods_snapshot_integration.py](tests/test_ods_snapshot_integration.py)
по умолчанию пропускается; отдельный запуск описан ниже.
- DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG):
- предварительно выполнить один раз: `make bookings-init` (быстрое восстановление демобазы `demo` из seed-дампа, ~18 сек) и `make ddl-gp` (создаёт STG/ODS/DDS слои в Greenplum, включая внешние `*_ext` через PXF);
- перед Trigger проверить, что в source реально есть данные (все значения должны быть `> 0`):
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;"`
- `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airplanes_data;"`
- если хотя бы один `COUNT(*) = 0`, не запускать DAG: повторить `make bookings-init`; если после этого `bookings.bookings` всё ещё пустая, выполнить `make bookings-generate-day` и снова проверить `COUNT(*)`;
- важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен (быстрое восстановление из seed-дампа);
- включить DAG `bookings_to_gp_stage` и запустить `Trigger DAG`;
- убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ);
- при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `_load_id` (см. пример запросов в разделе 5).
Успешный pytest с пропущенными smoke-тестами не подтверждает работоспособность
DAG в Airflow. Число выполненных и пропущенных тестов указывайте в отчете.
- (опционально, для менторов/разработчиков) Smoke-тест DAG через Airflow CLI без UI:
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01` — прогоняет `bookings_to_gp_stage` целиком в «off-line» режиме;
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags trigger bookings_to_gp_stage` — создаёт реальный запуск DAG (логи и статус можно смотреть либо через UI, либо командой `airflow tasks list`/`airflow tasks logs` внутри контейнера).
Для документации проверьте относительные ссылки и якоря, имена файлов,
таблиц и задач. Для карты выполните:
## 5. Проверка данных в Greenplum
- `make gp-psql` — запустить psql в контейнере от имени `gpadmin`.
- (опционально) Проверить, что PXF действительно запущен:
- `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
- Команды внутри psql:
- `\dt public.*` — таблицы схему public.
- (после настройки PXF) `SELECT COUNT(*) FROM public.ext_bookings_bookings;` — проверка чтения из демо-БД bookings через PXF.
- (после настройки PXF) `SELECT * FROM public.ext_bookings_bookings LIMIT 5;` — визуальное сравнение с таблицей `bookings.bookings` в исходной БД.
- Завершить `\q`.
```bash
uv run docs/design/architecture-map/render.py --check
git diff --check
```
## 6. Негативные сценарии и fallback
- **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`).
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up`), удостовериться, что загрузка и DQ работают через ENV.
- **PXF и демобаза bookings** (после настройки PXF и выполнения `make ddl-gp`): временно остановить `bookings-db` (`docker compose stop bookings-db`) и попробовать выполнить `SELECT COUNT(*) FROM public.ext_bookings_bookings;` в `make gp-psql` — ожидается ошибка подключения. Затем запустить `bookings-db` (`docker compose start bookings-db`) и убедиться, что запрос снова работает.
Сборщик сверяет карту с DDL, загрузками и ссылками. Действия в браузере
проверяются отдельно по [чек-листу карты](docs/design/architecture-map/README.md#проверка-в-браузере).
## 7. Быстрый reset (если «что-то сломалось»)
- Перезапустить стенд:
- Мягкий вариант (сохранить данные): `make stop`, затем `make up`.
- Полный reset (очистить данные в Docker-томах): `make clean`, затем `make up` (Greenplum/Airflow/bookings будут подняты и инициализированы с нуля).
- Иногда Greenplum не стартует после «грязных» остановок (из‑за старых внутренних файлов). Лечение: всегда делайте `make down` перед повторным `make up`.
## Проверка на стенде
## 8. Снятие метрик и мониторинг
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
- Логи задач: в Airflow UI → конкретный таск → Log.
Подготовьте стенд по [README](README.md) и проверьте его состояние:
## 9. Завершение работы
- `make down` — выключает сервисы и удаляет контейнеры/сети (volumes сохраняются).
- Полный сброс данных (удаляет volumes): `make clean`.
```bash
make up
docker compose ps
```
## Текущий статус (пример успешного прогона)
- `uv run pytest -q` — 14 passed, 9 smoke-тестов DAG пропущены (Airflow не установлен в venv).
- `make lint` — проходит (DAG‑файлы отформатированы black/isort).
- Полный ETL-цикл (STG→ODS→DDS→DM) проверен на стенде 2026-03-09: все DAG-и завершились с Success.
Порты берутся из `.env`: `AIRFLOW_WEB_PORT`, `GP_HOST_PORT`, `BOOKINGS_DB_PORT`.
Для локальных подключений используйте их, а для Greenplum внутри Docker-сети
применяется `GP_PORT` (по умолчанию 5432).
Загрузки используют Airflow Connections из Compose;
подробности находятся в [справочнике стенда](docs/stack.md).
Если изменились `Dockerfile.airflow` или `airflow/requirements.txt`, перед
`make up` выполните `make build`. Инициализация Bookings нужна при создании
стенда: `make bookings-init` пересоздает исходную базу из seed-дампа.
Не используйте ее для обычной проверки SQL на существующих данных.
Выполните DDL и загрузки по [порядку DAG](docs/dag_execution_order.md).
В Airflow откройте Graph и Log задач измененного слоя; в Greenplum
(`make gp-psql`) выполните проверки из его руководства:
[STG](docs/bookings_to_gp_stage.md#проверка-результата),
[ODS](docs/bookings_to_gp_ods.md#проверка-результата),
[DDS](docs/bookings_to_gp_dds.md#проверка-результата),
[DM](docs/bookings_to_gp_dm.md#проверка-результата).
Для проверки чтения через PXF используйте действующую внешнюю таблицу:
```sql
SELECT COUNT(*) FROM stg.bookings_ext;
SELECT * FROM stg.bookings_ext LIMIT 5;
```
Сравнить источник можно через `make bookings-psql`.
При ошибке соединения начните с `docker compose ps` и логов нужного сервиса,
затем проверьте настройки из `docs/stack.md`.
## Дополнительные прогоны
Интеграционный тест снимка ODS требует работающего Greenplum и базы `gp_dwh`:
```bash
RUN_ODS_INTEGRATION=1 uv run pytest -q tests/test_ods_snapshot_integration.py
```
Он создает, затем удаляет служебные таблицы
`public.it_stg_airports_ods` и `public.it_ods_airports_ods`.
Используйте тестовый стенд, где эти имена не заняты нужными данными.
Тест проверяет замену снимка, преобразование названий и DQ готового `airports`;
задания `airplanes` и `seats` он не проверяет.
`make e2e-etl` запускает DDL и два прохода STG → ODS → DDS → DM через API Airflow.
Скрипт использует `localhost:8080`; при другом `AIRFLOW_WEB_PORT` проходите
цепочку вручную. Каждый STG добавляет учебный день. Финальные SQL выводят
счетчики, но не превращают все неверные значения в ошибку завершения скрипта:
результаты нужно прочитать с учетом заглушек `main`.
`make e2e-smoke` начинает с `make clean` и **удаляет все Docker-тома стенда**,
затем инициализирует Bookings и выполняет E2E. Запускайте его только там,
где данные можно потерять. Обычный `make down` сохраняет тома;
`make stop`, затем `make up` подходят для перезапуска с сохранением данных.
[Исторический QA-план](docs/reference/qa-plan.md) сохраняет многодневные,
негативные и граничные сценарии полной реализации. Это материал для
подготовки отдельной диагностики: перед выполнением сверяйте его SQL
с текущей веткой и учитывайте операции очистки данных.
## Что указать в отчете
Укажите коммит, команды и результаты, включая пропуски тестов.
Разделите чтение исходников, проверки ссылок, действия в браузере и реальные
загрузки. Для запуска на стенде сохраните Run ID и логи проверенных задач.
Если ETL или интерфейс не проверялись, укажите это прямо.
-141
View File
@@ -1,141 +0,0 @@
# 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`
- [ ] Смержить chore/bookings-etl → main
- [ ] Общие правки на main (документация, TODO.md)
- [ ] Создать ветку solution (снимок полного эталона)
- [ ] Main-only правки: заглушки, ослабление DQ, адаптация тестов
- [ ] Очистка docs на main (удалить внутренние документы)
- [ ] Верификация main (`make test`, `make lint`)
- [ ] Верификация 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`.
+1 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty,
airport_traffic, monthly_overview.
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
DDL создаёт все 5 витрин. Эталон: sales_report. Остальные — задания.
"""
from datetime import timedelta
+2
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
- для каждой сущности выполняем пару задач load -> dq;
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
Эталон: dim_airports, dim_tariffs, dim_calendar, fact_flight_sales. dim_routes, dim_passengers, dim_airplanes — задания (заглушки).
"""
from datetime import timedelta
+1 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
- все витрины загружаются параллельно (не зависят друг от друга);
- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column).
На данном этапе реализованы все 5 витрин: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
Эталон: sales_report. Остальные 4 витрины — задания (заглушки SELECT 1;).
"""
from datetime import timedelta
+5 -1
View File
@@ -7,6 +7,8 @@ from __future__ import annotations
- весь запуск ODS работает с одним stg_batch_id;
- для каждой сущности выполняем пару задач load -> dq;
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
Эталон: airports, routes и 5 инкрементальных сущностей. airplanes, seats — задания (заглушки).
"""
from datetime import timedelta
@@ -263,7 +265,9 @@ with DAG(
resolve_stg_batch_id >> load_ods_airports >> dq_ods_airports
resolve_stg_batch_id >> load_ods_airplanes >> dq_ods_airplanes
[dq_ods_airports, dq_ods_airplanes] >> load_ods_routes >> dq_ods_routes
# На main routes не зависит от airplanes (ods.airplanes — студенческая заглушка).
# На ветке 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_routes >> load_ods_flights >> dq_ods_flights
+70
View File
@@ -0,0 +1,70 @@
"""Готовый DAG первой лабораторной: общий снимок справочников без генерации дней."""
from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG
with DAG(
dag_id="lab_pxf_airports",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args={
"owner": "airflow",
"retries": 1,
"retry_delay": timedelta(seconds=30),
},
tags=["lab", "bookings", "pxf"],
description="Лабораторная PXF: четыре снимка STG с общим Run ID для ODS",
) as dag:
load_airports_to_stg = PostgresOperator(
task_id="load_airports_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/airports_load.sql",
)
check_airports_dq = PostgresOperator(
task_id="check_airports_dq",
postgres_conn_id="greenplum_conn",
sql="stg/airports_dq.sql",
)
load_airplanes_to_stg = PostgresOperator(
task_id="load_airplanes_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/airplanes_load.sql",
)
check_airplanes_dq = PostgresOperator(
task_id="check_airplanes_dq",
postgres_conn_id="greenplum_conn",
sql="stg/airplanes_dq.sql",
)
load_routes_to_stg = PostgresOperator(
task_id="load_routes_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/routes_load.sql",
)
check_routes_dq = PostgresOperator(
task_id="check_routes_dq",
postgres_conn_id="greenplum_conn",
sql="stg/routes_dq.sql",
)
load_seats_to_stg = PostgresOperator(
task_id="load_seats_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/seats_load.sql",
)
check_seats_dq = PostgresOperator(
task_id="check_seats_dq",
postgres_conn_id="greenplum_conn",
sql="stg/seats_dq.sql",
)
load_airports_to_stg >> check_airports_dq
load_airplanes_to_stg >> check_airplanes_dq
# Готовим один общий батч: DQ маршрутов и мест читает связанные справочники.
[check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq
check_airplanes_dq >> load_seats_to_stg >> check_seats_dq
+47 -33
View File
@@ -1,45 +1,59 @@
# Документация
Этот каталог содержит дополнительные материалы к учебному стенду.
## Знакомство и первое задание
## Быстрый путь (для менти)
После [запуска стенда](../README.md):
- [Быстрый старт и команды](../README.md)
- [Учебные задания](assignment/README.md)
- [План тестирования и проверки](../TESTING.md)
- [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md)
- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md)
- [Учебный DAG: ods -> dds](bookings_to_gp_dds.md)
- [Учебный DAG: dds -> dm](bookings_to_gp_dm.md)
1. [Структура DWH](design/db_schema.md): смысл строк и связей на примере поездки.
2. [Учебные задания](assignment/README.md): прием справочника через PXF,
затем загрузки ODS, измерения и витрины. Каждая работа указывает свои проверки.
## Дизайн (`design/`)
Перед заданиями ODS прочитайте [разбор готового справочника](design/reading_the_pipeline.md#ods-airports).
Сборка факта из того же руководства понадобится перед DDS.
Поля и бизнес-правила расширений находятся в [ТЗ](assignment/analyst_spec.md).
- [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md)
- [Схема БД всех слоёв DWH](design/db_schema.md)
- [Дизайн-документ STG](design/bookings_stg_design.md)
- [Дизайн-документ ODS](design/bookings_ods_design.md)
- [Дизайн-документ DDS](design/bookings_dds_design.md)
- [Дизайн-документ DM](design/bookings_dm_design.md)
- [Архитектурные решения (ADR)](design/architecture_review.md)
- [PRD: стратегия курсовой](design/PRD.md)
- [Дизайн задания](design/assignment_design.md)
## Найти деталь во время работы
## Справочники (`reference/`)
[Интерактивная карта](design/architecture-map.html) ведет от таблицы к ее
источникам, потребителям, SQL и пункту задания.
Откройте локальный файл `docs/design/architecture-map.html` в браузере;
просмотр исходного HTML в Gitea не показывает карту.
[Руководство](design/db_schema.md#как-открыть-карту) объясняет открытие и адресные ссылки.
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
- [PXF в этом проекте (проектная реализация)](reference/pxf_bookings.md)
- [Про время/UTC в bookings](reference/bookings_tz.md)
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
- [Бенчмарк генерации данных](reference/bookings_generation_benchmark.md)
- [QA-план отладки пайплайна](reference/qa-plan.md)
- [Порядок запуска DAG-ов](dag_execution_order.md)
- [End-to-end протокол тестирования](e2e-etl-test-protocol.md)
- [Тестирование DAG-ов через API](agent-dag-testing.md)
| Вопрос | Основной документ |
|---|---|
| В каком порядке запускать слои? | [Порядок запуска DAG](dag_execution_order.md) |
| Что делает загрузка и где искать ошибку? | Руководства [STG](bookings_to_gp_stage.md), [ODS](bookings_to_gp_ods.md), [DDS](bookings_to_gp_dds.md), [DM](bookings_to_gp_dm.md) |
| Какие поля и преобразования нужны в моем задании? | [ТЗ](assignment/analyst_spec.md) |
| Как называются служебные поля и что означают метки времени? | [Соглашения об именах](design/naming_conventions.md) |
| Какие проверки включить в DQ? | [Классы DQ-проверок](reference/dq_taxonomy.md) |
| Как устроены контейнеры, Connections и настройки? | [Справочник стенда](stack.md) |
## Планы (`plans/`)
## Проверки разработки и диагностика
Активные планы работ. После выполнения переносятся в `archive/`.
[TESTING.md](../TESTING.md) описывает проверки изменений репозитория:
pytest, линтеры, проверку карты и прогоны стенда. Для выполнения первого
задания достаточно самопроверки из его инструкции.
## Архив (`archive/`)
Для углубленного разбора доступны
[исторический QA-план](reference/qa-plan.md) с многодневными и негативными
сценариями полной реализации и [заметки о генераторе Bookings](reference/bookings_db_issues.md).
QA-план требует отдельного тестового стенда и сверки с текущими SQL.
Выполненные планы, закрытые ревью. Ссылки внутри файлов могут быть устаревшими.
## Разработка учебных заданий
Перед созданием или пересмотром лабораторных прочитайте
[заметку о развитии программы в ветке solution](https://git.dementev.space/ddmitry/airflow-greenplum/src/branch/solution/docs/design/labs_development.md):
аудитория, принятые решения, предложения по обязательному циклу и общие критерии заданий.
## История разработки
Планы слоев сохранены по прежним адресам:
[STG](design/bookings_stg_design.md), [ODS](design/bookings_ods_design.md),
[DDS](design/bookings_dds_design.md), [DM](design/bookings_dm_design.md).
В них остаются исходные обоснования, варианты SQL и последовательность
создания файлов. В начале каждого плана есть переход к действующему руководству.
Для реализации учебных загрузок используйте ТЗ и готовые SQL-примеры.
Полные материалы проектирования (PRD, дизайн заданий, архитектурные решения)
находятся в ветке `solution`.
-89
View File
@@ -1,89 +0,0 @@
# Тестирование 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'и в правильном порядке и проверит результаты.
+51
View File
@@ -0,0 +1,51 @@
# Доменная документация
Как скиллам читать доменную документацию этого репо перед работой с кодом.
## Что читать перед началом
- **`CONTEXT.md`** в корне репо, или
- **`CONTEXT-MAP.md`** в корне, если он есть: указывает на отдельный `CONTEXT.md` для каждого контекста. Читайте те, что относятся к теме.
- **`docs/adr/`**: читайте ADR, которые касаются области, с которой собираетесь работать. В мультиконтекстных репо также проверяйте `src/<context>/docs/adr/`.
Если каких-то из этих файлов нет — **работайте молча**. Не отмечайте их отсутствие и не предлагайте создать заранее. Скилл `/domain-modeling` (через `/grill-with-docs` и `/improve-codebase-architecture`) создаёт их по мере того, как термины и решения реально фиксируются.
## Структура файлов
Один контекст (этот репо и большинство других):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Несколько контекстов (признак — `CONTEXT-MAP.md` в корне):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← решения уровня системы
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← решения этого контекста
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Используйте словарь глоссария
Когда в выводе называете доменное понятие (в заголовке issue, предложении по рефакторингу, гипотезе, имени теста), берите термин из `CONTEXT.md`. Не съезжайте на синонимы, которых глоссарий избегает.
Если нужного понятия в глоссарии нет — это сигнал: либо вы придумываете слова, которых проект не использует (пересмотрите), либо в глоссарии пробел (отметьте для `/domain-modeling`).
## Отмечайте конфликты с ADR
Если ваш вывод противоречит существующему ADR, скажите об этом явно, а не переопределяйте молча:
> _Противоречит ADR-0007 (event-sourced orders), но стоит пересмотреть, потому что…_
+51
View File
@@ -0,0 +1,51 @@
# Трекер задач: Gitea
Issues и спеки этого репо живут в Gitea:
`https://git.dementev.space/ddmitry/airflow-greenplum`.
Все операции — через CLI [`tea`](https://gitea.com/gitea/tea).
`gh` и `glab` с этим хостом не работают.
`tea` сам определяет репо и логин по `git remote -v`, если запущен внутри клона
(логин `dementev`, пользователь `ddmitry`). Для машиночитаемого вывода
добавляйте `-o json` к любой команде `list`.
## Команды
- **Создать issue**: `tea issues create --title "..." --description "..."`. Для многострочного описания используйте heredoc или `"$(cat file.md)"`. `--labels a,b` вешает метки сразу при создании.
- **Прочитать issue**: `tea issues <номер> --comments`.
- **Список issues**: `tea issues list --state open -o json --fields index,title,state,labels,assignees,body`. Фильтры: `--labels`, `--state all|open|closed`, `--keyword`, `--assignee`.
- **Прокомментировать**: `tea comments add <номер> "..."` (короткая форма: `tea comment <номер> "..."`).
- **Добавить / снять метки**: `tea issues edit <номер> --add-labels "a,b"` / `--remove-labels "a,b"`. Метка должна существовать в репо: `tea labels create --name "..." --color "..." --description "..."`.
- **Закрыть**: `tea issues close <номер>`. Комментарий при закрытии команда не принимает — сначала `tea comments add`, потом `close`.
- **Pull requests**: `tea pulls create`, `tea pulls <номер>`, `tea pulls list`, `tea pulls merge` — тот же набор, что у `tea issues`. Комментарии к PR — через тот же `tea comments add <номер>`.
## PR как источник запросов
**PR как источник запросов: нет.** _(Поставьте `да`, если внешние PR нужно рассматривать как feature request; `/triage` читает этот флаг.)_
Если стоит `да`, PR проходят те же метки и состояния, что и issues, через `tea pulls`:
- **Прочитать PR**: `tea pulls <номер> --comments`; diff — `tea pulls checkout <номер>` и `git diff main...`.
- **Список внешних PR для триажа**: `tea pulls list --state open -o json --fields index,title,author,labels,body`, дальше оставить только PR от авторов, которые не являются участниками репо.
- **Комментарий / метки / закрытие**: `tea comments add`, `tea issues edit --add-labels`/`--remove-labels` (работает и для номеров PR), `tea pulls close`.
В Gitea issues и PR нумеруются в одном пространстве: `#42` может быть и тем, и другим. `tea issues 42` показывает поле `kind`; ещё можно смотреть `tea issues list --kind all`.
## Когда скилл говорит «опубликовать в трекер»
Создать issue в Gitea: `tea issues create`.
## Когда скилл говорит «взять тикет»
Выполнить `tea issues <номер> --comments`.
## Операции wayfinding
Используется `/wayfinder`. **Карта** — одна issue, **дочерние** issues — тикеты.
- **Карта**: одна issue с меткой `wayfinder:map`, в теле — Notes / Decisions-so-far / Fog. `tea issues create --labels wayfinder:map`.
- **Дочерний тикет**: issue со строкой `Part of #<карта>` в начале описания и меткой `wayfinder:<тип>` (`research`/`prototype`/`grilling`/`task`). После взятия в работу тикет назначается на исполнителя.
- **Блокировки**: в Gitea есть встроенные зависимости между issues, но `tea` их не поддерживает. Используйте строку `Blocked by: #<n>, #<n>` в начале описания дочернего тикета. Тикет разблокирован, когда все блокирующие закрыты.
- **Поиск фронтира**: `tea issues list --state open --labels wayfinder:task -o json` (повторить для каждого типа), оставить только детей этой карты, отбросить те, у кого есть открытый блокер в `Blocked by` или назначенный исполнитель; первый по порядку в карте.
- **Взять в работу**: `tea issues edit <n> --add-assignees ddmitry` — первая запись в сессии.
- **Закрыть**: `tea comments add <n> "<ответ>"`, затем `tea issues close <n>`, затем дописать указатель на контекст (суть + ссылка) в Decisions-so-far карты.
+18
View File
@@ -0,0 +1,18 @@
# Метки триажа
Скиллы оперируют пятью ролями триажа. Эта таблица сопоставляет роли с реальными метками в трекере этого репо.
| Роль в mattpocock/skills | Метка в нашем трекере | Значение |
| ------------------------ | --------------------- | ---------------------------------------------- |
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить issue |
| `needs-info` | `needs-info` | Ждём уточнений от автора |
| `ready-for-agent` | `ready-for-agent` | Полностью описано, можно отдавать AFK-агенту |
| `ready-for-human` | `ready-for-human` | Требует реализации человеком |
| `wontfix` | `wontfix` | Делать не будем |
Когда скилл упоминает роль (например, «повесить метку готовности для агента»), берите строку из правой колонки.
Правую колонку можно менять под свой словарь.
В Gitea-репо меток пока нет. При первом `/triage` создайте их:
`tea labels create --name needs-triage --color "#e4e669" --description "..."` и так далее для каждой строки таблицы.
@@ -1,149 +0,0 @@
# Ревью решения (образец для студентов): `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-тесты проверяют хотя бы критические зависимости графа.
@@ -1,205 +0,0 @@
# План: Денормализация 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;
```
@@ -1,220 +0,0 @@
# План ревизии документации
> Статус: **ЧЕРНОВИК 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 |
@@ -1,91 +0,0 @@
# Учебные задания по стенду
Этот документ собирает в одном месте задания для менти.
Он разбит на блоки: от архитектуры 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 → витрины.
Когда будете готовы к этим темам, вернитесь к этому разделу — он станет основой для следующего «модуля» лабораторных заданий.
@@ -1,118 +0,0 @@
# Унификация нейминга служебных полей в 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 механические замены в каждом
@@ -1,88 +0,0 @@
# План: выравнивание качества документации 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".
@@ -1,503 +0,0 @@
# 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 эталонный, зависимости работают штатно.
-999
View File
@@ -1,999 +0,0 @@
# План: Валидационный 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 — источник требований) |
-304
View File
@@ -1,304 +0,0 @@
# Ревью архитектуры слоёв 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, паттерны | Отложено |
+100 -10
View File
@@ -1,19 +1,109 @@
# Учебные задания
## Техническое задание
В стенде уже работает цепочка от Bookings до `dm.sales_report`.
Сначала вы восстановите прием справочника в STG, затем дополните цепочку
загрузками ODS, измерениями DDS и четырьмя витринами DM.
Требования к этим расширениям описаны в [ТЗ](analyst_spec.md).
Основной документ: **[analyst_spec.md](analyst_spec.md)** — ТЗ от аналитика
с описанием всех таблиц, маппингами, бизнес-правилами и подсказками.
## Подготовка
## Эталон для изучения
Пройдите шаги 1–6 [быстрого старта](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
поднимите стенд, создайте таблицы, запустите эталонную цепочку и проверьте данные.
Затем прочитайте [руководство о структуре DWH](../design/db_schema.md).
Открывайте [карту](../design/architecture-map.html) рядом с заданием;
для Gitea есть [инструкция открытия локального HTML](../design/db_schema.md#как-открыть-карту).
Перед началом работы изучите эталонный срез (витрина `dm.sales_report`
и вся её цепочка STG → ODS → DDS → DM):
## Прием справочника: PXF → STG
- SQL-скрипты: `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`
- DAG-файлы: `airflow/dags/`
- [Порядок запуска DAG-ов](../reference/dag_execution_order.md)
Пройдите [«Восстановите прием справочника из источника»](pxf_airports.md).
Вы разберете готовый участок `airports`, восстановите внешнюю таблицу
и загрузку. На повторном запуске и переименовании аэропорта выясните,
как загрузка сохраняет снимки источника.
## Первая загрузка: ODS
После приема справочника реализуйте `ods.airplanes`, затем `ods.seats`.
На карте откройте [модели самолетов](../design/architecture-map.html#node=ods.airplanes)
и [места](../design/architecture-map.html#node=ods.seats): источники STG уже заполнены.
Позже обе таблицы понадобятся `dds.dim_airplanes` для характеристик модели
и расчета ее вместимости.
Таблицы уже созданы, задачи Airflow подключены к SQL-файлам.
В учебных `*_load.sql` и `*_dq.sql` пока стоит `SELECT 1;`.
В заданиях ODS, DDS и DM вы заменяете эти заглушки; менять DAG
и создавать таблицы заново не требуется.
### Готовый пример
Пройдите [разбор stg.airports → ods.airports](../design/reading_the_pipeline.md#ods-airports).
Это близкий пример полного снимка с выбором батча, преобразованиями и дедупликацией.
Для первых загрузок ODS этого разбора достаточно.
### Самостоятельная работа
Откройте [часть 1 ТЗ](analyst_spec.md#часть-1-ods-слой-operational-data-store).
Сверяйте с ней поля и правила каждой таблицы:
| Объект и требования | Файлы для реализации | DDL для сверки |
|---|---|---|
| [ods.airplanes](analyst_spec.md#11-odsairplanes) | [airplanes_load.sql](../../sql/ods/airplanes_load.sql), [airplanes_dq.sql](../../sql/ods/airplanes_dq.sql) | [airplanes_ddl.sql](../../sql/ods/airplanes_ddl.sql) |
| [ods.seats](analyst_spec.md#12-odsseats) | [seats_load.sql](../../sql/ods/seats_load.sql), [seats_dq.sql](../../sql/ods/seats_dq.sql) | [seats_ddl.sql](../../sql/ods/seats_ddl.sql) |
Пример проверок: [airports_dq.sql](../../sql/ods/airports_dq.sql).
Учтите разницу ключей: у модели это `airplane_code`, у места -
`(airplane_code, seat_no)`.
### Запуск и проверка
1. Используйте общий снимок после восстановления источника
в лабораторной PXF. Новый запуск STG для этой работы не нужен.
2. Запустите `bookings_to_gp_ods` целиком. Он выберет согласованный снимок
в `resolve_stg_batch_id`, затем выполнит
`load_ods_airplanes → dq_ods_airplanes → load_ods_seats → dq_ods_seats`.
SQL использует XCom этого запуска, поэтому отдельно от выбора батча
загрузку запускать не стоит.
3. В Greenplum посмотрите строки обеих таблиц: модели, типы числовых полей,
ключи мест и метки загрузки. Повторите ODS без нового запуска STG:
бизнес-строки и количество ключей должны сохраниться без дублей.
4. После реализации обеих таблиц запустите `bookings_validate` и откройте
группу `validate_ods`. Она проверит наличие данных, совпадение ключей
с выбранным STG-батчем, отсутствие дублей и NULL в ключах,
а также общий `_load_id` двух таблиц.
В `bookings_validate` автоматически запускаются все три группы.
Пока готовы только задания ODS, оценивайте `validate_ods`: ошибки DDS и DM
на этом этапе ожидаемы. После выполнения всех заданий должны пройти все группы.
Перед следующей загрузкой дождитесь завершения всего `bookings_validate`
и убедитесь, что `validate_dds.scd2_restore` завершился успешно.
SCD2-тест временно меняет данные маршрута, запускает вашу загрузку
и восстанавливает таблицы из копии. Параллельная загрузка может потерять
свои изменения при восстановлении. Если `scd2_restore` упал, сначала
разберите его лог.
`*_dq.sql` тоже нужно реализовать: успешный `SELECT 1;` ничего не проверяет.
## Следующие этапы
| Этап | Требования | Готовый пример рядом |
|---|---|---|
| DDS: `dim_airplanes`, `dim_passengers` | [Модели](analyst_spec.md#21-ddsdim_airplanes-scd1), [пассажиры](analyst_spec.md#22-ddsdim_passengers-scd1) | [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) |
| DDS: `dim_routes` | [Версии маршрута SCD2](analyst_spec.md#23-ddsdim_routes-scd2) | [Разбор сборки факта](../design/reading_the_pipeline.md#fact-flight-sales) показывает, как используется версия |
| DM: `airport_traffic` → `route_performance` → `monthly_overview` → `passenger_loyalty` | [Часть 3 ТЗ](analyst_spec.md#часть-3-dm-слой-data-marts--витрины) | [sales_report_load.sql](../../sql/dm/sales_report_load.sql) |
Перед DDS разберите [загрузку факта](../design/reading_the_pipeline.md#fact-flight-sales).
После реализации измерений выполните [пересчет факта и витрин](analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверяйте готовый слой своей группой в `bookings_validate`.
## Если застряли
Вернитесь к карточке объекта на карте: там есть DDL, загрузка, DQ и пункт ТЗ.
Для конкретного вопроса используйте [соглашения об именах](../design/naming_conventions.md),
[справочник DQ](../reference/dq_taxonomy.md) и руководства DAG:
[ODS](../bookings_to_gp_ods.md), [DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md).
Полную реализацию можно посмотреть в ветке `solution`.
## Для менторов
Дизайн заданий и педагогическая логика: [docs/design/assignment_design.md](../design/assignment_design.md).
Дизайн заданий и педагогическая логика находятся в ветке `solution`,
в `docs/design/assignment_design.md`.
+93 -32
View File
@@ -13,12 +13,19 @@
>
> SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`.
Программа начинается с [приема справочника через PXF](pxf_airports.md).
Затем переходите к расширениям из этого ТЗ; порядок их запуска и проверки
находится во [входе в задания](README.md#первая-загрузка-ods). В каждом разделе ниже есть
ссылка на объект карты и близкий готовый пример. Карту открывайте
[локально в браузере](../design/db_schema.md#как-открыть-карту).
---
## Рекомендуемый порядок выполнения
> **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes`
> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`.
> тоже эталонный. В первой лабораторной вы временно разбираете и восстанавливаете
> прием `airports`; расширения по этому ТЗ начинаются с ODS: `airplanes` и `seats`.
1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`)
2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения)
@@ -36,16 +43,20 @@
## Общие правила
- **Нейминг полей:** см. `docs/design/naming_conventions.md` — единый источник
- **Нейминг полей:** см. [соглашения об именах](../design/naming_conventions.md) — единый источник
истины для служебных полей (`_load_id`, `_load_ts`, `created_at`, `updated_at`,
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
(например, `sql/stg/airplanes_ddl.sql`, `sql/stg/airplanes_load.sql`).
- **DAG-интеграция:** используйте `PostgresOperator` + путь к SQL-файлу.
Добавьте таски в существующие DAG-файлы соответствующего слоя.
- **DAG-интеграция:** таски для всех заданий уже подключены в DAG-файлах
соответствующего слоя (`PostgresOperator` + путь к SQL-файлу).
Вам нужно только заменить содержимое SQL-файлов — DAG менять не нужно.
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
при повторном запуске (не создавать дубликатов).
- **Шаблон `{{ run_id }}`:** используйте Jinja-шаблон Airflow для `_load_id`.
- **Метки загрузки:** в ODS-снимках `airplanes` и `seats` сохраняйте в `_load_id`
идентификатор исходного STG-батча из `resolve_stg_batch_id`, как в `ods.airports`.
Так проверка сможет сопоставить ODS с тем снимком, из которого он загружен.
В заданиях DDS и DM используйте `'{{ run_id }}'` текущего запуска слоя.
---
@@ -66,11 +77,11 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
`ods.routes` тоже эталонный: факт `fact_flight_sales` использует его для резолвинга
аэропортов вылета/прилёта, независимо от студенческого `dds.dim_routes`.
**Что вам делать:** Изучите эталонные скрипты как образец — именно так написан
«боевой» код загрузки:
- `sql/stg/airports_load.sql` — инкрементальная загрузка (HWM)
- `sql/stg/airplanes_load.sql` — full snapshot с проверкой `_load_id`
- `sql/ods/airports_load.sql` — TRUNCATE+INSERT из STG
После лабораторной PXF разберите [загрузку `ods.airports`](../design/reading_the_pipeline.md#ods-airports).
При необходимости проследить получение снимка откройте
[sql/stg/airports_load.sql](../../sql/stg/airports_load.sql) или
[sql/stg/airplanes_load.sql](../../sql/stg/airplanes_load.sql): оба читают полный
снимок источника. `NOT EXISTS` защищает от повторной вставки ключа в том же `_load_id`.
---
@@ -80,10 +91,14 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
> Справочники (`airplanes`, `seats`) загружаются стратегией TRUNCATE + INSERT
> из последнего согласованного батча STG. (`ods.routes` реализован в эталоне.)
>
> **Аналог для изучения:** `sql/ods/airports_ddl.sql`, `sql/ods/airports_load.sql`
> **Аналог для изучения:** [DDL airports](../../sql/ods/airports_ddl.sql),
> [загрузка airports](../../sql/ods/airports_load.sql) и [ее разбор](../design/reading_the_pipeline.md#ods-airports).
### 1.1. ods.airplanes
[На карте: модель самолета и ее источник](../design/architecture-map.html#node=ods.airplanes).
Готовый пример полного снимка: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Очищенный справочник моделей воздушных судов с правильными типами.
**Источник:** `stg.airplanes`
@@ -94,7 +109,7 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
| `model` | TEXT NOT NULL | Наименование модели | `model` (парсинг JSON: `model::JSON->>'ru'` — если JSON, иначе `model` как есть) |
| `range_km` | INTEGER | Дальность полёта, км | `range::INTEGER` |
| `speed_kmh` | INTEGER | Крейсерская скорость, км/ч | `speed::INTEGER` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
@@ -114,6 +129,9 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 1.2. ods.seats
[На карте: места и связь с моделью](../design/architecture-map.html#node=ods.seats).
Пример выбора снимка и дедупликации: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Карта посадочных мест с корректными типами.
**Источник:** `stg.seats`
@@ -123,7 +141,7 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
| `airplane_code` | TEXT NOT NULL | Код модели (PK, часть 1) | `airplane_code` |
| `seat_no` | TEXT NOT NULL | Номер места (PK, часть 2) | `seat_no` |
| `fare_conditions` | TEXT NOT NULL | Класс обслуживания | `fare_conditions` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
@@ -148,10 +166,14 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
> ключами. SCD1-измерения обновляют атрибуты «на месте». SCD2-измерение
> хранит историю изменений через версионирование.
>
> **Аналог для SCD1:** `sql/dds/dim_airports_ddl.sql`, `sql/dds/dim_airports_load.sql`
> **Аналог для SCD1:** [DDL dim_airports](../../sql/dds/dim_airports_ddl.sql),
> [загрузка dim_airports](../../sql/dds/dim_airports_load.sql).
### 2.1. dds.dim_airplanes (SCD1)
[На карте: характеристики и вместимость модели](../design/architecture-map.html#node=dds.dim_airplanes).
Пример SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение моделей самолётов. Содержит технические характеристики
и рассчитанное общее количество мест (обогащение из `ods.seats`).
@@ -183,7 +205,8 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
в `ods.seats` для данного `airplane_code` (LEFT JOIN).
**Генерация суррогатного ключа:** `MAX(airplane_sk) + ROW_NUMBER()`.
Безопасно при `concurrency=1` в DAG.
Загрузки одной таблицы должны выполняться последовательно. В DDS DAG
параллельные запуски ограничены параметром `max_active_runs=1`.
**Distribution Key:** `airplane_sk`
@@ -191,6 +214,9 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 2.2. dds.dim_passengers (SCD1)
[На карте: пассажир из билетов](../design/architecture-map.html#node=dds.dim_passengers).
Пример обновления атрибутов и вставки новых ключей: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение пассажиров. Извлекается из таблицы билетов — каждый
уникальный `passenger_id` становится строкой измерения.
@@ -229,6 +255,10 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал
### 2.3. dds.dim_routes (SCD2)
[На карте: версии маршрута](../design/architecture-map.html#node=dds.dim_routes).
В [разборе готового факта](../design/reading_the_pipeline.md#fact-joins) видно,
как загрузчик выбирает версию на дату рейса. Алгоритм самой SCD2-загрузки дан ниже.
> **Это ключевой вызов курсовой.** Реализация SCD Type 2 — обязательный навык
> для Data Engineer. Ниже — алгоритм текстом; SQL вы пишете самостоятельно.
> Если застряли — сверьтесь с веткой `solution`.
@@ -301,8 +331,9 @@ md5(concat_ws('|',
фактов на исторические рейсы находил корректный `route_sk`.
- **Последующие версии** (маршрут уже был): `valid_from = CURRENT_DATE`.
6. **SCD1-обновление денормализованных полей:** Обновите `departure_city`,
`arrival_city`, `airplane_model`, `total_seats` для ВСЕХ открытых версий
(даже если `hashdiff` не менялся).
`arrival_city`, `airplane_model`, `total_seats` только у открытых версий
(`valid_to IS NULL`), даже если `hashdiff` не менялся. Новая версия при этом
не создается. Закрытые версии сохраняют значения на момент закрытия.
**Интервалы версий:** полуоткрытые `[valid_from, valid_to)`.
Текущая (актуальная) версия: `valid_to IS NULL`.
@@ -316,10 +347,15 @@ md5(concat_ws('|',
> **Цель:** Построить аналитические витрины поверх DDS.
> Каждая витрина отвечает на конкретный бизнес-вопрос.
>
> **Аналог для изучения:** `sql/dm/sales_report_ddl.sql`, `sql/dm/sales_report_load.sql`
> **Аналог для изучения:** [DDL sales_report](../../sql/dm/sales_report_ddl.sql),
> [загрузка sales_report](../../sql/dm/sales_report_load.sql).
### 3.1. dm.airport_traffic
[На карте: аэропорт за день](../design/architecture-map.html#node=dm.airport_traffic).
Пример пересчета затронутых дат: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
Две роли аэропорта проследите в [готовом факте](../design/reading_the_pipeline.md#fact-joins).
**Бизнес-вопрос:** «Какой пассажиропоток и выручка у каждого аэропорта по дням?»
**Описание:** Ежедневная статистика по каждому аэропорту: сколько рейсов
@@ -358,6 +394,11 @@ md5(concat_ws('|',
и `arrival_airport_sk`. Затем сгруппируйте по `(traffic_date, airport_sk)`.
**Метрики:**
Здесь `SUM(is_boarded)` означает число строк с признаком посадки.
Поле булево; в SQL используйте `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)`,
как в `sales_report_load.sql`.
- `departures_flights` — `COUNT(DISTINCT flight_id)` для роли «вылет»
- `departures_passengers` — `SUM(is_boarded)` для роли «вылет»
- `departures_revenue` — `SUM(price)` для роли «вылет»
@@ -376,6 +417,10 @@ md5(concat_ws('|',
### 3.2. dm.route_performance
[На карте: статистика маршрута](../design/architecture-map.html#node=dm.route_performance).
Соединение факта с измерениями и агрегация показаны в
[sales_report_load.sql](../../sql/dm/sales_report_load.sql); здесь по ТЗ нужен полный пересчет.
**Бизнес-вопрос:** «Какие маршруты самые эффективные? Где высокий load factor,
а где теряем пассажиров?»
@@ -426,9 +471,8 @@ md5(concat_ws('|',
- `avg_boarding_rate` — `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)`
- `avg_load_factor` — `total_boarded / (total_flights * total_seats)`
**Тип хранения Greenplum:** AO Column Store (zstd). Идеален для аналитики:
отличное сжатие, чтение только нужных колонок. AO не поддерживает UPDATE —
поэтому используем Full Rebuild.
**Тип хранения Greenplum:** AO Column Store (zstd), сжатое колоночное хранение.
В этом задании небольшой результат полностью пересоздается при каждом запуске.
**Distribution Key:** `route_bk`
@@ -439,6 +483,10 @@ md5(concat_ws('|',
### 3.3. dm.monthly_overview
[На карте: месяц и модель самолета](../design/architecture-map.html#node=dm.monthly_overview).
Пример отбора затронутых периодов: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
В этом задании период и уровни агрегации определены ниже.
**Бизнес-вопрос:** «Какова помесячная динамика: рейсы, выручка, load factor
в разрезе типов самолётов?»
@@ -478,9 +526,11 @@ md5(concat_ws('|',
**Ключевой приём — Двухуровневая агрегация:**
Чтобы честно посчитать среднюю заполняемость (`avg_load_factor`), нельзя просто
поделить `SUM(boarded)` на `SUM(seats)` — это даёт ошибку (парадокс Симпсона).
Правильный путь:
В этом задании `avg_load_factor` - среднее долей занятых мест по рейсам:
каждый рейс имеет одинаковый вес. Отношение `SUM(boarded) / SUM(seats)`
дает среднее с весом по вместимости и при разной вместимости может отличаться.
Нужный расчет:
1. **Уровень 1 (рейс):** Для каждого `flight_id` посчитайте `load_factor = boarded / total_seats`.
2. **Уровень 2 (месяц):** Возьмите `AVG(load_factor)` по всем рейсам месяца.
@@ -501,6 +551,10 @@ md5(concat_ws('|',
### 3.4. dm.passenger_loyalty
[На карте: история пассажира](../design/architecture-map.html#node=dm.passenger_loyalty).
В [sales_report_load.sql](../../sql/dm/sales_report_load.sql) посмотрите отделение
затронутых дат от полной истории за эти даты. Здесь единица пересчета - пассажир.
**Бизнес-вопрос:** «Кто наши самые лояльные пассажиры? Сколько они летают,
тратят, какой класс предпочитают?»
@@ -567,6 +621,10 @@ md5(concat_ws('|',
## Пересчёт факта после реализации измерений
[Факт на карте](../design/architecture-map.html#node=dds.fact_flight_sales) и
[разбор его INSERT и UPDATE](../design/reading_the_pipeline.md#найдите-запись-результата)
помогут проследить, когда заполняются ключи измерений.
После того, как вы реализуете все DDS-измерения (`dim_airplanes`, `dim_passengers`,
`dim_routes`), нужно пересчитать факт — он загружался ещё без ваших SK:
@@ -595,16 +653,19 @@ FROM dds.fact_flight_sales;
## Валидация
После реализации каждого слоя запустите валидационный DAG `bookings_validate`
в Airflow UI. Он проверит:
После реализации слоя следуйте [порядку запуска и проверки](README.md#запуск-и-проверка).
В `bookings_validate` смотрите группу своего этапа:
- Таблицы существуют и содержат данные
- PK не содержат NULL
- SCD2: корректность `valid_from`/`valid_to`, отсутствие «дыр» в версиях
- Кросс-слойная консистентность (ODS vs STG по кол-ву записей)
- DM-витрины содержат данные за загруженные дни
- `validate_ods` проверяет данные `airplanes` и `seats`, их ключи
и согласованность с выбранным снимком STG.
- `validate_dds` проверяет наличие данных в учебных измерениях, уникальность
пассажиров и сценарий изменения маршрута SCD2 с восстановлением таблиц.
- `validate_dm` проверяет, что четыре учебные витрины содержат строки.
Сообщения об ошибках укажут, что именно не так и что делать дальше.
Этот DAG дополняет ваши `*_dq.sql`. В частности, непустота витрины еще
не подтверждает формулы метрик: проверяйте их по требованиям выше и
[классам DQ](../reference/dq_taxonomy.md). Перед следующей загрузкой дождитесь
всего DAG и успешного `validate_dds.scd2_restore`.
---
+317
View File
@@ -0,0 +1,317 @@
# Восстановите прием справочника из источника
Хранилище получает справочник аэропортов из Postgres. Названия и другие
атрибуты в источнике могут меняться, поэтому загрузка должна сохранять
состояние справочника на момент приема данных.
Вы разберете готовый участок `airports` и напишете его заново: создадите
внешнюю таблицу PXF и загрузку полного снимка в STG. Затем испытаете ее
на повторном запуске и изменении источника. Полученный снимок понадобится
следующему слою — ODS.
## Задача
Ваш участок на [карте](../design/architecture-map.html#node=stg.airports):
`bookings.airports_data → stg.airports_ext → stg.airports`.
| Что написать | Где |
|---|---|
| DDL внешней таблицы `stg.airports_ext` | [airports_ddl.sql](../../sql/stg/airports_ddl.sql) |
| Загрузку полного снимка во внутреннюю `stg.airports` | [airports_load.sql](../../sql/stg/airports_load.sql) |
Внутренняя таблица, JDBC-сервер PXF, DAG и проверки качества уже готовы.
Рядом есть пример для другого справочника:
[DDL airplanes](../../sql/stg/airplanes_ddl.sql) и
[его загрузка](../../sql/stg/airplanes_load.sql). Используйте его, учитывая
состав полей и ключ аэропорта.
Требования к вашей загрузке:
- Источник — `bookings.airports_data` в базе `demo`, ключ — `airport_code`.
- Все шесть полей (`airport_code`, `airport_name`, `city`, `country`,
`coordinates`, `timezone`) попадают в STG без потерь и преобразований.
Внешняя и внутренняя таблицы используют TEXT; JSON и координаты
пока хранятся как текст.
- Строки одного снимка получают `_load_id = '{{ run_id }}'`,
`_load_ts = now()` и `event_ts = now()`. Точного времени изменения
аэропорта в источнике нет, поэтому `event_ts` — время получения снимка.
- Новый запуск сохраняет новый полный снимок, оставляя предыдущие.
Повторное выполнение загрузки в том же запуске на неизменном источнике
не меняет бизнес-данные и не создает дубли ключей внутри снимка.
## Подготовка
Нужен свой стенд после [быстрого старта и знакомства с моделью](README.md#подготовка).
Во время работы не запускайте другие загрузки: они могут изменить данные
посреди опыта. Если запущен `bookings_validate`, дождитесь его завершения.
Сначала освободите участок для своей реализации. Команды ниже удаляют
внешнюю таблицу и **всю историю только `stg.airports`**. Это нужно, чтобы
данные быстрого старта не скрыли отсутствие вашей загрузки.
<details>
<summary>Подготовка файлов и таблиц</summary>
Сохраните свои SQL-файлы в Git или отдельной копии. В `airports_ddl.sql`
удалите блок от `DROP EXTERNAL TABLE` до конца `CREATE EXTERNAL TABLE`;
DDL схемы и внутренней таблицы оставьте. Содержимое `airports_load.sql`
замените на `SELECT 1;`. DAG и DQ менять не нужно.
В Greenplum (`make gp-psql`):
```sql
DROP EXTERNAL TABLE IF EXISTS stg.airports_ext;
TRUNCATE TABLE stg.airports;
SELECT COUNT(*) FROM stg.airports; -- 0
```
Остальные справочники и текущий ODS сохраняются. Пока участок
не восстановлен, ODS запускать не нужно.
</details>
## Соберите загрузку
Начните с внешней таблицы. Добейтесь, чтобы запрос к `stg.airports_ext`
возвращал те же поля и значения, что запрос к `bookings.airports_data`
в Postgres. Для начала достаточно рассмотреть несколько аэропортов.
<details>
<summary>Ориентиры для PXF и применение DDL</summary>
В `LOCATION` укажите таблицу источника, `PROFILE=JDBC` и `SERVER=bookings-db`.
`SERVER` — имя готового [каталога конфигурации PXF](../../pxf/servers/bookings-db/jdbc-site.xml).
Формат чтения — `FORMAT 'CUSTOM' (formatter='pxfwritable_import')`.
Логин и пароль в DDL не нужны. Если меняли учетные данные источника,
согласуйте их с PXF по [справочнику стенда](../stack.md).
Примените свой файл из `make gp-psql`:
```sql
\set ON_ERROR_STOP on
\i /sql/stg/airports_ddl.sql
SELECT * FROM stg.airports_ext ORDER BY airport_code LIMIT 3;
```
В Postgres (`make bookings-psql`) выполните такой же запрос к
`bookings.airports_data`. Сверьте состав и порядок полей с DDL.
Если чтение не работает, начните с сообщения об ошибке и
[диагностики STG](../bookings_to_gp_stage.md#если-загрузка-не-прошла).
</details>
Теперь напишите загрузку снимка. Продумайте, как ваш SQL отличит
повторное выполнение от нового запуска и сохранит историю.
Выполнять загрузку будет готовый DAG `lab_pxf_airports`.
Получите первый снимок и сравните его с источником готовой сверкой.
Она проверяет ключи, все бизнес-поля и служебные метки. Зеленые задачи
в Airflow показывают, что код выполнился; сверка помогает обнаружить
ошибку в самих данных.
<details>
<summary>Первый запуск в Airflow и сверка с Postgres</summary>
Для сверки нужен `uv` с Python 3.11 из
[окружения разработчика](../stack.md#локальное-окружение-разработчика-uv).
В терминале из корня репозитория сохраните исходный справочник:
```bash
mkdir -p data/lab9
uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-before.json
```
Скрипт читает Postgres напрямую, минуя вашу внешнюю таблицу, и не перезаписывает
существующий файл. Эта копия позволит сравнить первый снимок с источником
даже после изменения названия аэропорта.
Откройте Airflow UI по адресу из `.env` (`AIRFLOW_WEB_PORT`), войдите с
`AIRFLOW_USER` и `AIRFLOW_PASSWORD`. Включите `lab_pxf_airports`, нажмите
**Trigger DAG** и дождитесь успеха всех задач. В **Grid** выберите запуск;
его **Run ID** подставьте в команду:
```bash
first_run='вставьте Run ID первого запуска'
uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json
```
Если задача упала, откройте ее **Log**. SQL-файлы доступны контейнеру
из рабочей папки: после правки пересобирать образ не нужно.
`{{ run_id }}` подставляет Airflow, поэтому файл загрузки не следует
выполнять напрямую через `psql`.
</details>
## Испытайте загрузку
### Повторное выполнение
Представьте, что после сбоя пришлось повторить уже выполненную задачу.
Что произойдет с данными при повторном выполнении вашего SQL? За счет
чего в одном снимке не появится вторая копия каждого аэропорта?
Повторите загрузку **в том же запуске** и снова выполните сверку первого
снимка. При ошибке исправьте SQL и повторите опыт на том же Run ID.
<details>
<summary>Как повторить задачу, сохранив Run ID</summary>
В запуске первого снимка выберите `load_airports_to_stg` → **Clear task**.
Снимите **Upstream**, **Downstream**, **Past**, **Future**, **Recursive**
и **Only Failed**, если включены. В **Affected Tasks** должна остаться
только эта задача этого запуска. Нажмите **Clear**, дождитесь успеха,
затем так же повторите `check_airports_dq`.
Новый **Trigger DAG** создал бы другой Run ID. Здесь нужно повторное
выполнение с прежним `_load_id`, чтобы испытать защиту от дублей.
Если загрузка уже записала дубли или неверные значения, исправление SQL
само их не удалит.
Удалите из `stg.airports` строки только этого `_load_id`, затем повторите
исправленную загрузку и DQ в том же запуске. После успешной сверки повторите
их еще раз: теперь вы проверяете защиту от дублей на заполненном снимке.
</details>
### Изменение в источнике
Теперь аэропорт переименовали. До новой загрузки предположите, где будет
видно новое название: в Postgres, во внешней таблице, в первом снимке STG?
Измените русское название одного аэропорта, сохранив ключ и остальные
языки. Для опыта ниже есть готовый SQL, который также сохраняет исходное
название для восстановления.
<details>
<summary>Изменить название в Postgres</summary>
В `make bookings-psql`:
```sql
\set ON_ERROR_STOP on
CREATE TABLE public.lab9_airport_original AS
SELECT airport_code, airport_name
FROM bookings.airports_data ORDER BY airport_code LIMIT 1;
UPDATE bookings.airports_data AS a
SET airport_name = jsonb_set(
b.airport_name, '{ru}', to_jsonb((b.airport_name->>'ru') || ' [lab9]'), false
)
FROM public.lab9_airport_original AS b
WHERE a.airport_code = b.airport_code
RETURNING a.airport_code, b.airport_name AS original, a.airport_name AS changed;
```
Запрос изменяет один аэропорт. Если таблица копии уже существует после
предыдущего опыта, сначала [восстановите источник](#завершение-работы),
сверьте результат и удалите таблицу копии. Затем можно начать новый опыт.
</details>
Найдите этот аэропорт в `stg.airports_ext` и `stg.airports` обычными
SELECT-запросами. Сопоставьте наблюдение со своим предположением.
Запустите `lab_pxf_airports` заново и рассмотрите оба снимка в STG.
Какое название хранится в каждом? Почему одного сравнения количества
строк недостаточно, чтобы заметить переименование?
Сверьте новый снимок с измененным источником, а первый — с исходным.
Это обнаружит, например, загрузку старых данных под новым Run ID.
<details>
<summary>Сверить оба снимка</summary>
В том же терминале, где задан `first_run`:
```bash
uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-after.json
next_run='вставьте Run ID нового запуска'
uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json
uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json
```
Если терминал закрывался, значения Run ID можно снова взять из Airflow.
</details>
## Передайте снимок в ODS
Запустите `bookings_to_gp_ods` без параметров `conf`. Найдите измененный
аэропорт в `ods.airports`: название должно быть новым, а `_load_id` —
совпадать с последним запуском STG. Так вы увидите, что следующий слой
использовал результат вашей загрузки.
ODS выбирает общий батч четырех справочников: `airports`, `airplanes`,
`routes`, `seats`. Поэтому готовый `lab_pxf_airports` загружает все четыре,
используя те же SQL, что основной STG-DAG. Генератор новых дней и
транзакционные таблицы в этом опыте не участвуют.
<details>
<summary>Проверить выбор снимка и данные ODS</summary>
В Log задачи `resolve_stg_batch_id` строка `Используем stg_batch_id=...`
показывает выбранный Run ID. Он должен совпасть с `next_run`.
После завершения DAG выполните:
```bash
uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json --layer ods
```
Сверка учитывает преобразование JSON в русские названия и требует нужный
`_load_id` у всех строк ODS. Если выбран старый батч, посмотрите, завершились
ли все загрузки и DQ последнего запуска `lab_pxf_airports`.
</details>
## Завершение работы
Верните исходное название и получите еще один общий снимок через
`lab_pxf_airports`. Проведите его по цепочке
`bookings_to_gp_ods` → `bookings_to_gp_dds` → `bookings_to_gp_dm`.
В `ods.airports` и `dds.dim_airports` должно вернуться исходное название.
<details>
<summary>Восстановить источник и сверить результат</summary>
В `make bookings-psql`:
```sql
UPDATE bookings.airports_data AS a
SET airport_name = b.airport_name
FROM public.lab9_airport_original AS b
WHERE a.airport_code = b.airport_code
RETURNING a.airport_code, a.airport_name;
```
После нового запуска `lab_pxf_airports` и ODS сравните восстановленный
снимок с исходной копией:
```bash
restored_run='вставьте Run ID запуска STG после восстановления'
uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json
uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json --layer ods
```
Повтор UPDATE восстановления безопасен. После успешной сверки выполните
в Postgres `DROP TABLE public.lab9_airport_original;`.
Если прервались на изменении источника,
продолжите с этого восстановления и нового запуска цепочки.
Для повторения всей лабораторной возьмите новые имена контрольных файлов.
При полной пересборке сохраните SQL-файлы: `make clean` удаляет тома.
Затем снова пройдите быстрый старт со своим решением.
</details>
Снимки в STG остаются историей опыта: первоначальный, с измененным названием
и восстановленный. Последний общий снимок и текущий ODS снова содержат
исходные значения; транзакционные данные остались от быстрого старта.
Результат работы — ваши два SQL-файла и объяснение их поведения: откуда
берется каждый снимок, что защищает его от дублей при повторе и как
изменение источника попадает в следующую загрузку.
Дальше — [загрузки `ods.airplanes` и `ods.seats`](README.md#первая-загрузка-ods).
Они используют уже подготовленный снимок, поэтому заново запускать STG
для начала следующей работы не нужно. Там вы будете преобразовывать поля
полученных данных и заменять текущее состояние в ODS.
+111 -151
View File
@@ -1,184 +1,144 @@
# DAG `bookings_to_gp_dds`: `ods` → `dds` в Greenplum
# Загрузка ODS → DDS: `bookings_to_gp_dds`
Этот DAG — учебный пример загрузки аналитического слоя **DDS** (Star Schema) из текущего состояния **ODS**.
Здесь сосредоточены ключевые паттерны аналитического хранилища: SCD1, SCD2 с hashdiff,
point-in-time join, защитные LEFT JOIN для устойчивости к data quality аномалиям.
DAG наполняет измерения и собирает `dds.fact_flight_sales`.
Одна строка факта соответствует сегменту билета: `(ticket_no, flight_id)`.
У одного билета может быть несколько таких строк.
Сборка показана [на карте](design/architecture-map.html#node=dds.fact_flight_sales)
и в [разборе SQL](design/reading_the_pipeline.md#fact-flight-sales).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- Загружает 6 измерений DDS:
- `dds.dim_calendar` — статическое измерение дат (Full Rebuild);
- `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` — SCD1 UPSERT;
- `dds.dim_routes` — **SCD2** с `hashdiff`, `valid_from`, `valid_to` + денормализация.
- Загружает факт `dds.fact_flight_sales` — инкрементальный UPSERT по зерну `(ticket_no, flight_id)`.
- Для каждой таблицы выполняет пару задач `load → dq`.
- Использует `_load_id = {{ run_id }}`. DDS не требует `stg_batch_id`, потому что читает
текущее состояние ODS.
Дождитесь успешных STG и ODS. Таблицы DDS должны быть созданы через
`bookings_dds_ddl` или общий `make ddl-gp`.
Затем в Airflow UI запустите `bookings_to_gp_dds` целиком.
DDS читает состояние ODS; передавать `stg_batch_id` ему не нужно.
## Что должно быть готово перед запуском
| Объект | Что уже выполняется на `main` |
|---|---|
| `dim_calendar` | Заполнение датами с 2016-01-01 по 2030-12-31, только если таблица пуста |
| `dim_airports` | Обновление изменившихся атрибутов и вставка новых аэропортов с постоянными SK |
| `dim_tariffs` | Добавление новых классов обслуживания из `ods.segments` |
| `fact_flight_sales` | Обновление существующих сегментов и вставка новых пар `(ticket_no, flight_id)` |
| `dim_airplanes`, `dim_passengers`, `dim_routes` | Загрузки и DQ пока содержат `SELECT 1;`; требования находятся в [части 2 ТЗ](assignment/analyst_spec.md#часть-2-dds-слой-detailed-data-store--измерения) |
1) Стенд поднят:
До выполнения заданий три студенческих измерения остаются пустыми.
Готовый факт при этом загружается: отсутствующие ключи измерений сохраняются
как `NULL`, чтобы можно было изучить цепочку до написания собственного SQL.
```bash
make up
```
## Порядок задач
2) STG и ODS уже загружены:
В [DAG](../airflow/dags/bookings_to_gp_dds.py) сначала выполняется
`load_dds_dim_calendar → dq_dds_dim_calendar`. Затем:
- выполнены DAG-и `bookings_to_gp_stage` и `bookings_to_gp_ods`;
- DDL-объекты созданы (`bookings_dds_ddl` или `make ddl-gp`).
1. Параллельно могут загружаться `dim_airports`, `dim_airplanes`, `dim_tariffs`
и `dim_passengers`; у каждого измерения своя следующая DQ-задача.
2. `load_dds_dim_routes` ждет `dq_dds_dim_airports` и `dq_dds_dim_airplanes`,
затем выполняется `dq_dds_dim_routes`.
3. `load_dds_fact_flight_sales` ждет DQ всех пяти измерений после календаря.
4. `dq_dds_fact_flight_sales → finish_dds_summary` завершают запуск.
## Как запустить
Это порядок задач Airflow. Источники строк и поля соединений показаны
на карте отдельно от него.
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DDS впервые — выполните `bookings_dds_ddl`.
3) Запустите `bookings_to_gp_dds`.
## Как работают готовые загрузки
## Граф зависимостей
[dim_calendar_load.sql](../sql/dds/dim_calendar_load.sql) проверяет
`NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1)`. Если есть хотя бы одна
строка, скрипт не вставляет даты. Повторный запуск не пересоздает календарь
и не дополняет частично заполненную таблицу.
[Его DQ](../sql/dds/dim_calendar_dq.sql) проверяет также покрытие дат рейсов ODS.
```
load_dds_dim_calendar → dq_dds_dim_calendar
├─ load_dds_dim_airports → dq_dds_dim_airports ─┐
│ ├─ load_dds_dim_routes
├─ load_dds_dim_airplanes → dq_dds_dim_airplanes ─┘ └─ dq_dds_dim_routes
│ │
├─ load_dds_dim_tariffs → dq_dds_dim_tariffs │
│ │
└─ load_dds_dim_passengers → dq_dds_dim_passengers │
│
все 5 dq_dds_dim_* ─────────────────────────────────────────┘
└─ load_dds_fact_flight_sales
└─ dq_dds_fact_flight_sales
└─ finish_dds_summary
```
[dim_airports_load.sql](../sql/dds/dim_airports_load.sql) сохраняет SK
существующего аэропорта, обновляет изменившиеся атрибуты через
`IS DISTINCT FROM` и вставляет новые бизнес-ключи.
[dim_tariffs_load.sql](../sql/dds/dim_tariffs_load.sql) только добавляет
недостающие значения `fare_conditions`.
Метки измененных строк относятся к текущему запуску DDS;
календарь не содержит `_load_id` и `_load_ts`.
Ключевой момент: `dim_routes` зависит от `dim_airports` и `dim_airplanes` (денормализация),
а факт ждёт завершения **всех** пяти измерений.
В [fact_flight_sales_load.sql](../sql/dds/fact_flight_sales_load.sql)
есть две операции:
## Как это работает внутри (по шагам)
- `UPDATE` меняет цену, место, признак посадки и метки загрузки, если эти
бизнес-поля изменились. SK измерений остаются прежними.
- `INSERT` собирает данные из ODS и измерений для пар
`(ticket_no, flight_id)`, которых еще нет в факте.
### 1) `load_dds_dim_calendar` → `dq_dds_dim_calendar`
Факт не отбирает дельту по HWM. Скрипт читает ODS и сопоставляет бизнес-ключи
с уже записанными строками.
- **SQL:** `sql/dds/dim_calendar_load.sql`, `sql/dds/dim_calendar_dq.sql`
- **Паттерн:** Full Rebuild — каждый запуск пересоздаёт календарь целиком.
Измерение маленькое и детерминированное, дельту считать нет смысла.
Аэропорты вылета и прилета находятся через `ods.routes → dim_airports`.
Из ODS выбирается строка с наибольшим `validity` для `route_no`; сортировка
идет по TEXT, дата рейса в этом отборе не участвует.
`route_sk` определяется отдельно: по дате вылета в полуоткрытом интервале
`[valid_from, valid_to)` измерения `dim_routes`. Из найденной версии берется
код модели для поиска `airplane_sk`.
[Разбор соединений](design/reading_the_pipeline.md#fact-joins) показывает,
какие строки сохраняет `LEFT JOIN` и что происходит при отсутствии измерения.
### 2–5) SCD1-измерения (параллельно после calendar)
## Учебные измерения
| # | Задача | 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` | Самолёты (код, модель, кол-во мест) |
| 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, имя, контакты) |
Начните с [моделей самолетов](assignment/analyst_spec.md#21-ddsdim_airplanes-scd1)
и [пассажиров](assignment/analyst_spec.md#22-ddsdim_passengers-scd1), затем
переходите к [версиям маршрута](assignment/analyst_spec.md#23-ddsdim_routes-scd2).
Поля, hashdiff и алгоритм SCD2 определены в ТЗ.
Паттерн загрузки — SCD1 UPSERT: TEMP TABLE → UPDATE (IS DISTINCT FROM) → INSERT.
У маршрута изменение версионируемых атрибутов создает новую строку.
Описательные поля из других измерений обновляются только у открытых версий
(`valid_to IS NULL`), без новой версии; закрытые версии сохраняют свои значения.
Это правило также записано в [алгоритме ТЗ](assignment/analyst_spec.md#алгоритм-scd2-пошагово).
### 6) `load_dds_dim_routes` → `dq_dds_dim_routes` (SCD2)
После реализации измерений выполните
[пересчет факта и витрин](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Обычный повторный запуск DDS не заполнит SK в старых строках факта,
поскольку его `UPDATE` эти поля не меняет.
- **SQL:** `sql/dds/dim_routes_load.sql`, `sql/dds/dim_routes_dq.sql`
- **Паттерн:** SCD2 — самый нетривиальный паттерн в проекте. Работает в 3 фазы:
## Проверка результата
**Фаза 1. Hashdiff и закрытие старых версий.**
Скрипт считает MD5-хеш от шести бизнес-атрибутов маршрута (`departure_airport`, `arrival_airport`,
`airplane_code`, `days_of_week`, `departure_time`, `duration`).
Если хеш текущей версии в DDS не совпадает с хешем из ODS — старая версия закрывается
(`valid_to = CURRENT_DATE`). Также закрываются маршруты, исчезнувшие из ODS.
**Фаза 2. Вставка новых версий.**
Для изменённых и совершенно новых маршрутов создаётся новая строка.
`valid_from` выставляется в `1900-01-01` для первой версии маршрута и `CURRENT_DATE` для версии 2+.
Суррогатный ключ (`route_sk`) генерируется через `MAX(route_sk) + ROW_NUMBER()`.
> **Важно:** такая генерация SK безопасна только при `max_active_runs=1` (Airflow гарантирует
> последовательный запуск). В боевых системах используют sequence.
**Фаза 3. Обновление денормализованных атрибутов.**
`dim_routes` хранит денормализованные SCD1-атрибуты из `dim_airports` (города)
и `dim_airplanes` (модель, кол-во мест). Если, например, город переименовали —
фаза 3 обновляет **все** версии маршрута (и текущие, и исторические),
при этом `_load_id` и `_load_ts` не перезаписываются (lineage версий сохраняется).
### 7) `load_dds_fact_flight_sales` → `dq_dds_fact_flight_sales`
- **SQL:** `sql/dds/fact_flight_sales_load.sql`, `sql/dds/fact_flight_sales_dq.sql`
- **Зерно:** `(ticket_no, flight_id)` — один билет на один рейс.
- **Паттерн:** инкрементальный UPSERT.
Три учебных приёма в этом скрипте:
**Защитные LEFT JOIN (defensive coding).**
Все JOIN-ы с измерениями — `LEFT JOIN`. DAG гарантирует, что все измерения загружены
и прошли DQ **до** старта факта (жёсткие зависимости в графе). Поэтому в штатном режиме
NULL SK не возникают. LEFT JOIN здесь — защита от data quality аномалий (например, если
в `ods.routes` появится маршрут с несуществующим аэропортом).
DQ-проверки факта отражают эту логику:
- `passenger_sk` и `tariff_sk` — **запрещены** NULL целиком (0 строк);
- route-related FK (`route_sk`, `airport_sk`, `airplane_sk`) и `calendar_sk` —
допускается до **1%** NULL (NOTICE-предупреждение), при превышении — EXCEPTION.
> Это **не** паттерн late-arriving dimensions (опаздывающих измерений) в классическом
> понимании: backfill NULL SK при повторном запуске не реализован.
> В боевых системах для этого используют «строку-заглушку» (unknown member, SK = 0)
> и отдельный процесс backfill.
**Два пути lookup для аэропортов и маршрутов.**
Аэропорты (`departure_airport_sk`, `arrival_airport_sk`) разрешаются через `ods.routes` →
`dim_airports`. Аэропорты вылета/прилёта одинаковы во всех версиях маршрута, поэтому
point-in-time логика не нужна — безопасно брать актуальную версию из ODS.
`route_sk` и `airplane_sk` разрешаются через point-in-time join с SCD2 `dim_routes`:
```sql
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
```
Это гарантирует, что факт привязывается к той версии маршрута, которая была актуальна
на дату рейса.
**UPDATE мутабельных полей.**
UPDATE обновляет только `seat_no`, `price`, `is_boarded` (данные, которые реально
могут измениться — посадка пассажира, корректировка цены). SK измерений не перезаписываются —
они зафиксированы на момент вставки.
### 8) `finish_dds_summary`
Ждёт завершения DQ факта и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` выполните:
```sql
SELECT COUNT(*) FROM dds.dim_calendar;
SELECT COUNT(*) FROM dds.dim_routes;
SELECT COUNT(*) FROM dds.fact_flight_sales;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.dim_tariffs;
-- Проверка: кол-во строк факта ≈ кол-во строк ODS segments
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
(SELECT COUNT(*) FROM ods.segments) AS ods_rows;
(SELECT COUNT(*) FROM ods.segments) AS segment_rows;
-- Проверка SCD2: текущие версии маршрутов (valid_to IS NULL)
SELECT COUNT(*) AS current_versions,
(SELECT COUNT(*) FROM dds.dim_routes) AS total_versions
FROM dds.dim_routes
WHERE valid_to IS NULL;
SELECT ticket_no, flight_id, departure_airport_sk, arrival_airport_sk,
route_sk, airplane_sk, passenger_sk, price, is_boarded
FROM dds.fact_flight_sales
ORDER BY ticket_no, flight_id
LIMIT 10;
```
Ожидаемо: `fact_rows = ods_rows`, `current_versions ≤ total_versions`.
На подготовленном стенде готовые измерения и факт непусты.
`fact_rows` должен точно совпасть с `segment_rows`.
[Готовая DQ факта](../sql/dds/fact_flight_sales_dq.sql) также ищет дубли
по паре ключей и проверяет обязательные поля.
## Типичные ошибки
На учебной ветке `NULL` в `passenger_sk`, `route_sk` и `airplane_sk`
вызывает только `NOTICE`. `NULL` в `tariff_sk` вызывает ошибку;
для строк с отсутствующим аэропортом и для `calendar_sk` допустима доля
не выше 1%. Эти послабления не заменяют проверку заполнения ключей после задания.
- `relation "dds..." does not exist`:
- не применён DDS DDL (`bookings_dds_ddl` или `make ddl-gp`).
- DQ падает на `dim_routes`:
- проверьте согласованность `ods.routes` (дубли/аномальные версии) и перезапустите DAG.
- DQ падает на `fact_flight_sales` по coverage:
- проверьте, что ODS DAG завершился успешно без пропуска задач.
Свои `*_dq.sql` реализуйте по ТЗ и
[справочнику DQ](reference/dq_taxonomy.md). Для дополнительной проверки DDS
используйте `validate_dds` в `bookings_validate`, соблюдая
[порядок запуска и восстановления](assignment/README.md#запуск-и-проверка).
Далее переходите к [DM](bookings_to_gp_dm.md).
## Если загрузка не прошла
- Нет `dds.*`: выполните `bookings_dds_ddl`.
- DQ календаря сообщает о непокрытых датах: сравните даты вылета ODS
с диапазоном и содержимым `dim_calendar`. Повторный запуск загрузки
непустого календаря не заполнит пропуски.
- Число строк факта отличается от ODS: проверьте ключи билета, бронирования
и рейса, затем соединения в `fact_src`. Потеря строки на `INNER JOIN`
и размножение строк из-за нескольких подходящих версий дают разные причины
одного расхождения.
- Ошибка в вашей DQ маршрутов: проверьте открытые версии, интервалы и hashdiff
по ТЗ. Успешная заглушка `SELECT 1;` этих свойств не проверяет.
+85 -143
View File
@@ -1,171 +1,113 @@
# DAG `bookings_to_gp_dm`: `dds` → `dm` в Greenplum
# Загрузка DDS → DM: `bookings_to_gp_dm`
Этот DAG — учебный пример загрузки слоя **DM** (Data Mart / витрины) из текущего состояния **DDS**.
Все 5 витрин загружаются **параллельно** и демонстрируют разные стратегии загрузки —
это ключевая учебная ценность данного DAG.
DAG собирает витрины из факта и измерений DDS. На `main` полностью работает
`dm.sales_report`; остальные четыре загрузки и их DQ содержат `SELECT 1;`.
Успешный запуск с заглушками не означает, что все витрины наполнены.
## Что делает DAG
На [карте sales_report](design/architecture-map.html#node=dm.sales_report)
можно проследить источники полей. Как сегменты билетов превращаются
в строки витрины, объясняет [руководство о структуре](design/db_schema.md#агрегация-фактов-в-витрине-продаж).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
Загружает 5 витрин параллельно, для каждой — пара `load → dq`:
## Перед запуском
| Витрина | Зерно (grain) | Паттерн загрузки |
|---------|---------------|------------------|
| `dm.sales_report` | (flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk) | Инкрементальный UPSERT (HWM по датам) |
| `dm.route_performance` | route_bk | Full Rebuild (TRUNCATE + INSERT) |
| `dm.passenger_loyalty` | passenger_sk | Инкрементальный UPSERT (HWM по затронутым ключам) |
| `dm.airport_traffic` | (traffic_date, airport_sk) | Инкрементальный UPSERT (HWM по датам) |
| `dm.monthly_overview` | (year_actual, month_actual, airplane_sk) | Инкрементальный UPSERT (HWM по месяцам) |
Дождитесь успешного `bookings_to_gp_dds`. Создайте таблицы через
`bookings_dm_ddl`, если еще не запускали его или общий `make ddl-gp`.
Затем запустите `bookings_to_gp_dm` в Airflow UI.
Общий порядок находится в [инструкции запуска DAG](dag_execution_order.md).
## Что должно быть готово перед запуском
Для готовой `sales_report` достаточно готового факта, календаря, аэропортов
и тарифов. Для своих витрин сначала заполните нужные измерения и выполните
[пересчет факта после заданий DDS](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
1) Стенд поднят:
## Что делает sales_report
```bash
make up
```
Зерно витрины: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)`.
Одна строка содержит продажи сегментов за дату вылета, пару аэропортов и тариф.
`tickets_sold = COUNT(*)` считает строки факта в этой группе.
2) STG, ODS и DDS уже загружены:
[sales_report_load.sql](../sql/dm/sales_report_load.sql) работает в три шага:
- выполнены DAG-и `bookings_to_gp_stage`, `bookings_to_gp_ods`, `bookings_to_gp_dds`;
- DDL-объекты созданы (`bookings_dm_ddl` или `make ddl-gp`).
1. Находит даты вылета фактов с `_load_ts > MAX(_load_ts)` витрины.
Для пустой витрины нижняя граница равна `1900-01-01`.
2. Для этих дат пересчитывает агрегаты по всем их фактам во временную таблицу.
В расчет входят и строки, загруженные ранее: например, новая посадка
меняет итог дня, сохраняя остальные продажи этого дня.
3. Обновляет существующие группы при изменении проверяемых полей и вставляет
новые. `_load_id` записанных строк равен текущему `run_id` DM.
## Как запустить
Если после пропуска запуска в DDS накопились факты с более новой `_load_ts`,
следующий запуск включит их даты в пересчет. Изменение только описания
измерения само по себе не выбирает дату: отбор привязан к метке факта.
1) Откройте Airflow UI: http://localhost:8080.
2) Если запускаете DM впервые — выполните `bookings_dm_ddl`.
3) Запустите `bookings_to_gp_dm`.
[sales_report_dq.sql](../sql/dm/sales_report_dq.sql) берет даты из строк витрины,
записанных текущим запуском. Для них проверяет непустоту при наличии фактов,
дубли ключа, обязательные поля, неотрицательную выручку и долю посадок от 0 до 1.
Если витрина в этом запуске не менялась, DQ выводит `NOTICE` и завершает работу.
## Граф зависимостей
## Учебные витрины
```
start_dm
├── load_dm_sales_report → dq_dm_sales_report ─┐
├── load_dm_route_performance → dq_dm_route_performance ─┤
├── load_dm_passenger_loyalty → dq_dm_passenger_loyalty ─┼─ finish_dm_summary
├── load_dm_airport_traffic → dq_dm_airport_traffic ─┤
└── load_dm_monthly_overview → dq_dm_monthly_overview ─┘
```
Ниже указано ожидаемое поведение заданий. Поля и формулы находятся в ТЗ;
загрузку и DQ для каждого объекта нужно написать самостоятельно.
Все 5 веток полностью независимы и работают параллельно.
| Задание | Одна строка | Что нужно пересчитывать |
|---|---|---|
| [airport_traffic](assignment/analyst_spec.md#31-dmairport_traffic) | Дата и аэропорт | Полные итоги затронутых дат, с учетом ролей вылета и прилета |
| [route_performance](assignment/analyst_spec.md#32-dmroute_performance) | Маршрут по `route_bk` | Всю историю при каждом запуске, объединяя версии маршрута |
| [monthly_overview](assignment/analyst_spec.md#33-dmmonthly_overview) | Год, месяц и модель по `airplane_sk` | Затронутые месяцы; сначала метрики рейса, затем месяца |
| [passenger_loyalty](assignment/analyst_spec.md#34-dmpassenger_loyalty) | Пассажир по `passenger_sk` | Всю историю пассажиров, чьи факты изменились |
## Как это работает внутри (по шагам)
Готовый пример отбора затронутых дат находится в `sales_report_load.sql`.
В заданиях меняется единица пересчета: дата, месяц, пассажир или вся таблица.
Следуйте [порядку заданий](assignment/README.md#следующие-этапы), чтобы каждый
следующий расчет опирался на уже освоенный прием.
### 1) `load_dm_sales_report` → `dq_dm_sales_report`
## Порядок задач
- **SQL:** `sql/dm/sales_report_load.sql`, `sql/dm/sales_report_dq.sql`
- **Паттерн:** инкрементальный UPSERT (HWM по датам).
В [DAG](../airflow/dags/bookings_to_gp_dm.py) `start_dm` запускает пять
независимых веток. Каждая имеет вид
`load_dm_<витрина> → dq_dm_<витрина> → finish_dm_summary`.
Финальная задача ждет все пять DQ. Витрины читают DDS и не читают друг друга,
поэтому между их задачами загрузки нет зависимостей.
Витрина агрегирует продажи билетов по дате, аэропортам вылета/прилёта и тарифу.
Инкрементальность работает через HWM: витрина сравнивает свой `MAX(_load_ts)` с `_load_ts`
фактов в DDS и пересчитывает агрегаты только для **затронутых дат**.
## Проверка результата
> Если витрина пуста — `1900-01-01` заберёт всю историю (первичная загрузка).
> Если DAG не запускался несколько дней — при следующем запуске витрина автоматически
> «догонит» всю накопленную дельту.
Учебные приёмы:
- **TEMP TABLE** для однократной агрегации (канон для MPP);
- **NULLIF** для защиты от деления на ноль (`boarding_rate = boarded / NULLIF(sold, 0)`);
- **Денормализация**: города и коды аэропортов тянутся в витрину из измерений.
### 2) `load_dm_route_performance` → `dq_dm_route_performance`
- **SQL:** `sql/dm/route_performance_load.sql`, `sql/dm/route_performance_dq.sql`
- **Паттерн:** Full Rebuild (TRUNCATE + INSERT).
Витрина агрегирует эффективность маршрутов за всю историю. Таблица маленькая (~1000 строк),
поэтому пересоздать её с нуля дешевле, чем вычислять дельту. Дополнительная причина:
таблица хранится в формате **AO Column Store**, который не поддерживает эффективный UPDATE/DELETE.
Трёхшаговый паттерн:
1. **TRUNCATE** — очистка (единственный эффективный способ для AO).
2. **Агрегация** по `route_bk` — факты суммируются через **все исторические версии** маршрута
(route_sk из SCD2), чтобы не терять данные при версионировании.
3. **JOIN** с текущей (актуальной, `valid_to IS NULL`) версией `dim_routes` для денормализации.
> Благодаря денормализации `dim_routes` — один JOIN вместо четырёх
> (аэропорты вылета/прилёта, самолёт уже хранятся в `dim_routes`).
Метрики: `avg_load_factor = total_boarded / (total_flights * total_seats)`,
`avg_ticket_price = total_revenue / total_tickets`.
### 3) `load_dm_passenger_loyalty` → `dq_dm_passenger_loyalty`
- **SQL:** `sql/dm/passenger_loyalty_load.sql`, `sql/dm/passenger_loyalty_dq.sql`
- **Паттерн:** инкрементальный UPSERT по «затронутым ключам».
В отличие от `sales_report` (где инкремент по датам), здесь HWM находит **конкретных пассажиров**
с новыми фактами, а затем пересчитывает для них всю историю. Это гарантирует точность
накопительных агрегатов (`total_spent`, `first/last_flight_date`).
Учебные приёмы:
- **DISTINCT ON** (PostgreSQL-специфика) для нахождения моды (самый частый тариф пассажира);
- **Агрегация SCD2 по BK**: при подсчёте уникальных маршрутов используем `route_bk`,
а не `route_sk`, т.к. один маршрут может иметь несколько версий;
- Фильтрация `passenger_sk IS NOT NULL` — защита от неконсистентных фактов (NULL SK
при data quality аномалиях в измерениях).
### 4) `load_dm_airport_traffic` → `dq_dm_airport_traffic`
- **SQL:** `sql/dm/airport_traffic_load.sql`, `sql/dm/airport_traffic_dq.sql`
- **Паттерн:** инкрементальный UPSERT по датам.
Витрина показывает пассажиропоток аэропортов по дням (вылеты + прилёты).
Учебный приём — **Dual-role dimension через UNION ALL**: один билет превращается
в два «события» (вылет из одного аэропорта и прилёт в другой).
Это позволяет собрать единую статистику аэропорта (departures + arrivals) в одном проходе.
### 5) `load_dm_monthly_overview` → `dq_dm_monthly_overview`
- **SQL:** `sql/dm/monthly_overview_load.sql`, `sql/dm/monthly_overview_dq.sql`
- **Паттерн:** инкрементальный UPSERT по месяцам.
Витрина показывает помесячную статистику по типам самолётов.
Учебный приём — **двухуровневая агрегация**: чтобы честно посчитать `avg_load_factor`,
сначала считаем load factor для каждого рейса (`boarded / total_seats`),
затем берём среднее по месяцу. Прямая агрегация `SUM(boarded) / SUM(seats)` дала бы
искажённый результат (взвешенный по числу билетов, а не рейсов).
> **Ограничение SCD1:** `total_seats` берётся из текущего состояния `dim_airplanes`.
> Если самолёт переоборудовали в прошлом, для точного исторического расчёта
> потребовалось бы SCD2-измерение.
### 6) `start_dm` / `finish_dm_summary`
- `start_dm` — стартовый sentinel, от которого расходятся все 5 параллельных веток.
- `finish_dm_summary` — ждёт завершения всех DQ-задач и логирует сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` посмотрите готовую витрину:
```sql
SELECT COUNT(*) FROM dm.sales_report;
SELECT COUNT(*) FROM dm.route_performance;
SELECT COUNT(*) FROM dm.passenger_loyalty;
SELECT COUNT(*) FROM dm.airport_traffic;
SELECT COUNT(*) FROM dm.monthly_overview;
-- Инвариант sales_report: посаженных не больше, чем продано
SELECT COUNT(*) FROM dm.sales_report WHERE tickets_sold < passengers_boarded;
SELECT flight_date, departure_airport_bk, arrival_airport_bk,
fare_conditions, tickets_sold, passengers_boarded, total_revenue
FROM dm.sales_report
ORDER BY flight_date DESC, departure_airport_bk, arrival_airport_bk,
fare_conditions
LIMIT 10;
-- Нет дублей по бизнес-ключу route_performance
SELECT route_bk, COUNT(*) FROM dm.route_performance GROUP BY route_bk HAVING COUNT(*) > 1;
SELECT COUNT(*) AS invalid_boarding_rows
FROM dm.sales_report
WHERE tickets_sold < passengers_boarded;
```
Ожидаемо: все витрины непусты, инварианты соблюдены, дублей нет.
После первого успешного запуска `sales_report` непуста,
`invalid_boarding_rows = 0`. Возьмите одну строку и найдите на карте,
откуда пришли ее дата, два аэропорта и тариф.
## Типичные ошибки
После реализации своих витрин запустите `bookings_validate` и посмотрите
`validate_dm`. Проверки этой группы требуют непустые таблицы;
правильность метрик должна проверять ваша DQ по ТЗ.
Соблюдайте [правила запуска валидации](assignment/README.md#запуск-и-проверка):
в том же DAG выполняется активный SCD2-тест с восстановлением данных.
- `relation "dm..." does not exist`:
- не применён DM DDL (`bookings_dm_ddl` или `make ddl-gp`).
- DQ падает на `sales_report` по `boarding_rate`:
- проверьте, что DDS загрузился корректно (`dds.fact_flight_sales` непуста).
- DQ падает на `passenger_loyalty` с ошибкой FK:
- проверьте, что `dds.dim_passengers` содержит всех пассажиров из факта.
- `finish_dm_summary` не выполняется:
- одна из DQ-задач упала; найдите в логах Airflow задачу с ошибкой и исправьте.
## Если загрузка не прошла
- Нет `dm.*`: выполните `bookings_dm_ddl`.
- `sales_report` пуста: проверьте наличие факта и соответствий его SK
календарю, аэропортам и тарифам. Витрина соединяет их через `INNER JOIN`.
- Учебная витрина пуста: проверьте замену загрузочной заглушки и наличие
необходимых SK факта после заданий DDS.
- Ошибка метрики: разберите одну группу и сравните результат со строками факта
и формулой в ТЗ. Дополнительные примеры проверок есть в
[справочнике DQ](reference/dq_taxonomy.md).
- `finish_dm_summary` не выполняется: найдите первую упавшую задачу,
откройте ее Log и исправьте причину до следующего запуска слоя.
+102 -139
View File
@@ -1,176 +1,139 @@
# DAG `bookings_to_gp_ods`: `stg` → `ods` в Greenplum
# Загрузка STG → ODS: `bookings_to_gp_ods`
Этот DAG — учебный пример загрузки типизированного слоя **ODS** из уже подготовленного слоя **STG**.
Логика каноничная: **TRUNCATE + INSERT** для snapshot-справочников, **SCD1 UPSERT** для транзакционных
таблиц + DQ-проверки.
DAG приводит данные STG к целевым типам и оставляет одну строку на бизнес-ключ.
Снимки справочников заменяют предыдущее состояние; транзакционные таблицы
получают новые и измененные строки. Смысл ключей показан на
[карте ODS](design/architecture-map.html#node=ods.airports),
а готовый снимок разобран в [stg.airports → ods.airports](design/reading_the_pipeline.md#ods-airports).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- Определяет `stg_batch_id` — последний согласованный батч, по которому все 4 snapshot-справочника
(`airports`, `airplanes`, `routes`, `seats`) уже приехали в STG.
- Загружает 9 таблиц ODS: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`,
`flights`, `segments`, `boarding_passes`.
- Для каждой таблицы выполняет пару задач `load → dq`.
- Snapshot-справочники фильтруются по `stg_batch_id`, транзакционные таблицы — по HWM (`_load_ts`).
- Для snapshot-справочников дополнительно синхронизирует ключи (удаляет из ODS записи,
отсутствующие в выбранном STG-батче).
- Для `flights` дополнительно добирает рейсы из истории `stg.flights`, если на них
ссылаются `stg.segments` (чтобы сохранить ссылочную целостность `segments.flight_id → flights.flight_id`).
Дождитесь успешного `bookings_to_gp_stage`. Создайте ODS через
`bookings_ods_ddl`, если еще не запускали его или общий `make ddl-gp`.
Подготовка стенда описана в [README](../README.md), последовательность слоев
в [порядке запуска DAG](dag_execution_order.md).
## Что должно быть готово перед запуском
На `main` работают `airports`, `routes`, `bookings`, `tickets`, `flights`,
`segments` и `boarding_passes`. Загрузки и DQ для `airplanes` и `seats`
содержат `SELECT 1;`. Успешный DAG с этими заглушками оставит две таблицы пустыми.
Их нужно заполнить в [первом задании](assignment/README.md#первая-загрузка-ods).
1) Стек поднят:
## Запуск и выбор снимка
```bash
make up
```
В Airflow UI запустите `bookings_to_gp_ods` целиком.
Первая задача, `resolve_stg_batch_id`, выбирает последний общий `_load_id`
четырех справочников STG: `airports`, `airplanes`, `routes`, `seats`.
Общие батчи находятся через `INTERSECT`, последний выбирается по времени
загрузки их строк. Результат передается в SQL через XCom.
2) STG-слой создан и заполнен:
- запущен `bookings_stg_ddl` (или `make ddl-gp`);
- хотя бы один раз выполнен DAG `bookings_to_gp_stage`.
3) ODS-таблицы созданы (один из вариантов):
- учебный: запустить DAG `bookings_ods_ddl`;
- шорткат: `make ddl-gp` (создаёт и STG, и ODS).
## Как запустить
1) Откройте Airflow UI: http://localhost:8080.
2) Запустите DAG `bookings_to_gp_ods`.
3) (Опционально) передайте `stg_batch_id` в конфиге запуска:
Resolver проверяет наличие батча в таблицах, а не статус запуска STG в Airflow.
Поэтому сначала дождитесь всех STG DQ. Если нужен определенный снимок,
передайте в Trigger DAG JSON с его реальным `_load_id`:
```json
{"stg_batch_id": "manual__2026-02-22T12:00:00+00:00"}
{"stg_batch_id": "<Run ID успешного запуска STG>"}
```
Если конфиг не передан, DAG автоматически возьмёт последний согласованный snapshot-батч.
Явно переданный идентификатор resolver принимает без проверки согласованности.
Убедитесь, что он есть во всех четырех справочниках; произвольная строка
или Run ID запуска ODS здесь не подойдут.
## Граф зависимостей
## Что выполняет загрузка
```
resolve_stg_batch_id
├─ load_ods_bookings → dq_ods_bookings
│ └─ load_ods_tickets → dq_ods_tickets ──────────────────┐
│ │
├─ load_ods_airports → dq_ods_airports ─┐ │
│ ├─ load_ods_routes │
├─ load_ods_airplanes → dq_ods_airplanes ─┤ └─ dq_ods_routes
│ │ └─ load_ods_flights
│ │ └─ dq_ods_flights ─┐
│ │ │
│ │ dq_ods_flights + dq_ods_tickets
│ │ └─ load_ods_segments
│ │ └─ dq_ods_segments
│ │ └─ load_ods_boarding_passes
│ │ └─ dq_ods_boarding_passes ─┐
│ │ │
│ └─ load_ods_seats │
│ └─ dq_ods_seats ─────────────────────────────────┤
│ │
└────────────────────────────────────────────────────────────────── finish_ods_summary ◀──────────┘
```
| Объекты | Реализация на `main` | Как отбираются данные |
|---|---|---|
| `airports`, `routes` | Готовые `TRUNCATE + INSERT` | Один снимок по `stg_batch_id`, дедупликация по бизнес-ключу |
| `airplanes`, `seats` | Заглушки; требуемый полный снимок описан в [части 1 ТЗ](assignment/analyst_spec.md#часть-1-ods-слой-operational-data-store) | По выбранному `stg_batch_id` после реализации задания |
| `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Готовые `UPDATE + INSERT` через временную таблицу | Новые строки STG по `_load_ts` относительно `MAX(_load_ts)` соответствующей ODS-таблицы |
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_ods_summary`.
В [airports_load.sql](../sql/ods/airports_load.sql) и
[routes_load.sql](../sql/ods/routes_load.sql) сначала очищается таблица,
затем вставляется выбранный снимок. Вместе с заменой исчезают ключи,
которых больше нет в снимке. Это один полный пересчет небольшой AO-таблицы;
отдельного `DELETE` для синхронизации ключей нет.
## Как это работает внутри (по шагам)
Для транзакционных таблиц, например [bookings](../sql/ods/bookings_load.sql),
дельта фиксируется во временной таблице до `UPDATE` и `INSERT`.
Поэтому обе операции читают один набор строк, даже когда обновление меняет
максимальную `_load_ts` в ODS. Дельта может охватывать несколько STG-батчей;
параметр `stg_batch_id` ее не ограничивает.
### 1) `resolve_stg_batch_id` (Python)
[flights_load.sql](../sql/ods/flights_load.sql) дополнительно читает историю
STG для рейсов, на которые ссылаются новые сегменты. Так сегмент может получить
свой рейс, даже если тот пришел раньше текущего инкремента.
Определяет, какой STG-батч использовать для snapshot-справочников.
Если `stg_batch_id` не передан через `dag_run.conf`, ищет последний **согласованный** батч —
`_load_id`, который есть одновременно во всех четырёх snapshot-таблицах
(`stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats`).
Для этого используется **INTERSECT** по `_load_id`.
Метки загрузки имеют два варианта:
> **Зачем согласованность?** Чтобы ODS загружал только те данные, для которых приехали
> ВСЕ связанные справочники. Иначе возможна потеря ссылочной целостности при сборке витрин.
- У снимков `_load_id` сохраняет выбранный STG-батч, `_load_ts = now()`.
- У транзакционных строк обе метки переносятся из STG. Сохраненная `_load_ts`
служит границей следующего инкремента.
### 2–10) Загрузка 9 таблиц: `load_ods_*` → `dq_ods_*`
Названия и смысл служебных полей собраны в
[соглашениях об именах](design/naming_conventions.md).
Каждая пара задач работает одинаково:
## Порядок задач на main
| # | Задача | SQL-файл | Тип загрузки |
|---|--------|----------|--------------|
| 2 | `load_ods_bookings` → `dq_ods_bookings` | `sql/ods/bookings_load.sql`, `sql/ods/bookings_dq.sql` | HWM (инкремент) |
| 3 | `load_ods_tickets` → `dq_ods_tickets` | `sql/ods/tickets_load.sql`, `sql/ods/tickets_dq.sql` | HWM (инкремент) |
| 4 | `load_ods_airports` → `dq_ods_airports` | `sql/ods/airports_load.sql`, `sql/ods/airports_dq.sql` | snapshot по `stg_batch_id` |
| 5 | `load_ods_airplanes` → `dq_ods_airplanes` | `sql/ods/airplanes_load.sql`, `sql/ods/airplanes_dq.sql` | snapshot по `stg_batch_id` |
| 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` |
| 8 | `load_ods_flights` → `dq_ods_flights` | `sql/ods/flights_load.sql`, `sql/ods/flights_dq.sql` | HWM (инкремент) |
| 9 | `load_ods_segments` → `dq_ods_segments` | `sql/ods/segments_load.sql`, `sql/ods/segments_dq.sql` | HWM (инкремент) |
| 10 | `load_ods_boarding_passes` → `dq_ods_boarding_passes` | `sql/ods/boarding_passes_load.sql`, `sql/ods/boarding_passes_dq.sql` | HWM (инкремент) |
В [DAG](../airflow/dags/bookings_to_gp_ods.py) после resolver одновременно
могут стартовать три ветки. В таблице указаны зависимости задач Airflow:
### Два паттерна загрузки
| Загрузка | Что должно завершиться перед ней |
|---|---|
| `load_ods_bookings`, `load_ods_airports`, `load_ods_airplanes` | `resolve_stg_batch_id` |
| `load_ods_tickets` | `dq_ods_bookings` |
| `load_ods_routes` | `dq_ods_airports` |
| `load_ods_seats` | `dq_ods_airplanes` |
| `load_ods_flights` | `dq_ods_routes` |
| `load_ods_segments` | `dq_ods_flights` и `dq_ods_tickets` |
| `load_ods_boarding_passes` | `dq_ods_segments` |
В ODS используются **два разных паттерна** — выбор зависит от типа данных и формата хранения:
Каждый `load_ods_*` ведет к своему `dq_ods_*`.
`finish_ods_summary` ждет `dq_ods_boarding_passes` и `dq_ods_seats`.
На `main` маршруты не ждут студенческий `airplanes`; проверка ссылки
`routes.airplane_code → airplanes` в их DQ отключена.
**Snapshot-справочники** (`airports`, `airplanes`, `routes`, `seats`) — **TRUNCATE + INSERT**:
## Проверка результата
1. **TRUNCATE** — полная очистка таблицы.
2. **INSERT** — вставка всех строк из STG-батча (`_load_id = stg_batch_id`) с дедупликацией
через `ROW_NUMBER()`.
> Почему не UPSERT? Эти таблицы хранятся в формате **AO Row** (`appendonly=true`),
> который не поддерживает эффективный row-level UPDATE (вызывает bloat).
> Для маленьких справочников (~100–300 строк) полная перезагрузка быстрее и чище.
**Транзакционные таблицы** (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) —
**SCD1 UPSERT**:
1. **TEMP TABLE** — собирает дельту (новые/изменённые строки) с дедупликацией внутри батча
через `ROW_NUMBER()`. Временная таблица автоматически удаляется (`ON COMMIT DROP`).
2. **UPDATE** — обновляет существующие строки. Использует `IS DISTINCT FROM` для корректного
сравнения `NULL`-значений (обычный `<>` не обнаружит изменение `NULL → значение`).
3. **INSERT** — добавляет новые строки (которых нет в ODS по бизнес-ключу).
Транзакционные таблицы фильтруются по **HWM** — `WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table)`.
Это сделано, чтобы не потерять инкременты, если STG-DAG запускался несколько раз
до запуска ODS-DAG'а.
### DQ-проверки (одинаковый паттерн)
Каждый `*_dq.sql` — PL/pgSQL-блок (`DO $$...$$`), который проверяет:
- нет дублей по бизнес-ключу в ODS;
- все ключи из STG текущего батча присутствуют в ODS;
- обязательные поля не содержат NULL.
При ошибке — `RAISE EXCEPTION` с понятным текстом. Для инкрементальных таблиц пустой батч допустим.
### 11) `finish_ods_summary`
Ждёт завершения обеих параллельных веток (`dq_ods_boarding_passes` и `dq_ods_seats`)
и логирует краткую сводку.
## Как проверить результат
```bash
make gp-psql
```
В `make gp-psql` посмотрите строки готовых таблиц:
```sql
SELECT airport_code, airport_name, city, _load_id, _load_ts
FROM ods.airports
ORDER BY airport_code
LIMIT 10;
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM ods.tickets;
SELECT COUNT(*) FROM ods.flights;
SELECT COUNT(*) FROM ods.segments;
-- Проверка: в ODS не должно быть дублей по бизнес-ключу
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY 1
GROUP BY book_ref
HAVING COUNT(*) > 1;
```
Ожидаемо: в последнем запросе `0` строк.
После первого полного запуска готовые таблицы непусты; последний запрос
не должен вернуть строк. У `ods.airports` видны русские названия и выбранный
STG-батч. Сравните его с XCom задачи `resolve_stg_batch_id`.
## Типичные ошибки
Готовые DQ проверяют ключи, обязательные поля и связи. Для снимков
[airports_dq.sql](../sql/ods/airports_dq.sql) и
[routes_dq.sql](../sql/ods/routes_dq.sql) ищут и пропущенные, и лишние ключи
относительно выбранного батча. DQ транзакционных таблиц проверяют покрытие
ключей выбранного батча, хотя загрузка читает всю дельту по HWM.
Пустой транзакционный батч допустим, пустой снимок справочника считается ошибкой.
- `stg_batch_id не найден`:
- передайте `stg_batch_id` в `dag_run.conf`, или
- сначала загрузите STG через `bookings_to_gp_stage`.
- Ошибки `relation "ods...." does not exist`:
- не применён ODS DDL (`bookings_ods_ddl` / `make ddl-gp`).
- Ошибки DQ по ссылочной целостности:
- проверьте, что ODS DAG выполнялся с корректным `stg_batch_id` и без пропуска upstream задач.
После реализации `airplanes` и `seats` пройдите
[самопроверку заданий ODS](assignment/README.md#запуск-и-проверка).
Дальше можно переходить к [DDS](bookings_to_gp_dds.md).
## Если загрузка не прошла
- `stg_batch_id не найден`: проверьте, что STG загрузил все четыре справочника.
Одна заполненная `stg.bookings` для выбора снимка недостаточна.
- Нет `ods.*`: выполните `bookings_ods_ddl`.
- Ошибка приведения типа: в логе найдите поле, затем сравните значение
в STG с преобразованием в `*_load.sql` и типом в DDL.
- DQ сообщает о пропущенных ключах: сопоставьте выбранный батч, SQL загрузки
и конкретную связь на карте. Для учебных таблиц проверьте, что заменены
обе заглушки: загрузка и DQ.
+81 -128
View File
@@ -1,156 +1,109 @@
# DAG `bookings_to_gp_stage`: `bookings-db` → `stg` в Greenplum
# Загрузка Bookings → STG: `bookings_to_gp_stage`
Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres**
(`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой
и простыми проверками качества данных.
DAG читает девять таблиц Bookings через внешние таблицы PXF и сохраняет
строки в Greenplum. Весь STG, включая DQ, уже реализован на `main`;
в [первой лабораторной](assignment/pxf_airports.md) вы разберете и восстановите
прием `airports`.
Смысл бронирования, билета и сегмента разобран в
[руководстве о структуре](design/db_schema.md); на карте начните со
[stg.bookings](design/architecture-map.html#node=stg.bookings).
Карту открывайте [локально в браузере](design/db_schema.md#как-открыть-карту).
## Что делает DAG
## Перед запуском
- В источнике (`bookings-db`) генерирует следующий учебный день данных в `bookings.bookings`
(генератор всегда “шагает” вперёд от `max(book_date)`).
- В Greenplum загружает инкремент в `stg.bookings` через внешнюю таблицу `stg.bookings_ext`, используя PXF.
- Сверяет количество строк между источником (за окно инкремента) и загруженным батчем.
- Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF.
- Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля).
- Загружает справочники (full load): `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + DQ.
- Загружает транзакции: `stg.flights` (инкремент), `stg.segments` (инкремент), `stg.boarding_passes` (инкремент через tickets/bookings) + DQ.
Выполните подготовку из [быстрого старта](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
стенд должен работать, база `demo` содержать исходные данные,
а DAG `bookings_stg_ddl` создать внутренние таблицы `stg.*` и внешние `stg.*_ext`.
Альтернатива DDL DAG: `make ddl-gp`, который создает все четыре слоя.
## Что должно быть готово перед запуском
Подключения `bookings_db` и `greenplum_conn` заданы через `AIRFLOW_CONN_...`
в Compose и могут не отображаться в списке Connections.
Порты, учетные данные и настройка PXF описаны в [справочнике стенда](stack.md).
1) Стек поднят:
В Airflow UI (по умолчанию http://localhost:8080) запустите
`bookings_to_gp_stage` через Trigger DAG и дождитесь завершения всех задач.
Каждый полный запуск добавляет следующий учебный день в источник.
Дата запуска Airflow не задает день генерируемых данных.
```bash
make up
```
## Какие строки загружаются
2) Демо‑БД bookings установлена и содержит данные:
[generate_bookings_day](../sql/src/bookings_generate_day_if_missing.sql)
вызывает `continue(...)` до конца следующего дня после `MAX(book_date)`.
Для пустой установленной базы он вызывает `generate(...)`, используя настройки
`bookings.start_date`, `bookings.init_days` и `bookings.jobs`.
Эти настройки задаются при инициализации Bookings.
```bash
make bookings-init
```
Внешняя таблица `*_ext` читает источник; внутренний STG хранит полученные строки.
Бизнес-поля сохраняются как TEXT. `_load_id` равен `run_id` запуска STG,
`_load_ts` фиксирует время записи. Опорное `event_ts` зависит от таблицы:
3) В Greenplum созданы STG‑объекты (внешние `*_ext` через PXF и внутренние таблицы слоя `stg`)
для всех таблиц потока: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`,
`segments`, `boarding_passes` (выберите один вариант):
| Таблицы | Отбор строк | Готовый SQL |
|---|---|---|
| `bookings` | Новые `book_date` | [bookings_load.sql](../sql/stg/bookings_load.sql) |
| `tickets`, `segments`, `boarding_passes` | По `book_date` связанного бронирования через билеты | [tickets](../sql/stg/tickets_load.sql), [segments](../sql/stg/segments_load.sql), [boarding_passes](../sql/stg/boarding_passes_load.sql) |
| `flights` | Новые `scheduled_departure` | [flights_load.sql](../sql/stg/flights_load.sql) |
| `airports`, `airplanes`, `routes`, `seats` | Полный снимок при каждом запуске; `event_ts = now()` | [airports](../sql/stg/airports_load.sql), [airplanes](../sql/stg/airplanes_load.sql), [routes](../sql/stg/routes_load.sql), [seats](../sql/stg/seats_load.sql) |
- учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI;
- технический шорткат: `make ddl-gp`.
У каждой инкрементальной таблицы своя нижняя граница:
`MAX(event_ts)` по предыдущим батчам этой таблицы. Верхней границы нет.
`NOT EXISTS` не дает повторно вставить тот же ключ в текущий `_load_id`.
У справочников сохраняются снимки разных запусков, поэтому повторение
бизнес-ключа между батчами ожидаемо.
4) Airflow Connections:
Такой инкремент не перечитывает все старые строки источника: например,
изменение рейса с датой ниже границы само по себе в новый STG-батч не попадет.
Для проверки повторяемости загрузки учитывайте и генератор:
повторный запуск `generate_bookings_day` снова продвинет источник на день.
- `bookings_db` — подключение к источнику `bookings-db`;
- `greenplum_conn` — подключение к Greenplum.
## Порядок задач
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в `docker-compose.yml`,
поэтому могут не отображаться в UI — для `PostgresOperator` это нормально.
В [DAG](../airflow/dags/bookings_to_gp_stage.py) после каждой загрузки стоит DQ.
Ниже стрелки показывают порядок выполнения задач Airflow.
Связи строк по ключам смотрите на карте.
## Как запустить
1. `generate_bookings_day → load_bookings_to_stg → check_row_counts`.
2. `load_tickets_to_stg → check_tickets_dq`.
3. После билетов параллельно могут выполняться загрузки аэропортов и моделей.
`load_routes_to_stg` ждет `check_airports_dq` и `check_airplanes_dq`;
`load_seats_to_stg` ждет только `check_airplanes_dq`.
4. После проверки маршрутов идет цепочка
`flights → segments → boarding_passes`, у каждой таблицы своя DQ-задача.
5. `finish_summary` ждет `check_boarding_passes_dq` и `check_seats_dq`.
1) Откройте Airflow UI: http://localhost:8080 (admin/admin).
2) Запустите DAG `bookings_to_gp_stage` вручную (Trigger DAG).
3) Дождитесь статуса Success у всех задач.
Инкрементальные DQ допускают пустое окно: выводят `NOTICE` и завершаются успешно.
Пустой источник справочника считается ошибкой. Сверки выполняются для текущего
батча; примеры: [bookings_dq.sql](../sql/stg/bookings_dq.sql) и
[airports_dq.sql](../sql/stg/airports_dq.sql).
## Как это работает внутри (по шагам)
## Проверка результата
1) `generate_bookings_day`
- выполняет `sql/src/bookings_generate_day_if_missing.sql` в `bookings-db`;
- если `bookings.bookings` пустая — вызывает `generate(...)` на `BOOKINGS_INIT_DAYS`;
- иначе — вызывает `continue(...)`, добавляя ровно один следующий день.
2) `load_bookings_to_stg`
- выполняет `sql/stg/bookings_load.sql` в Greenplum;
- берёт строки из `stg.bookings_ext`, которые попадают в новое окно инкремента;
- вставляет их в `stg.bookings`, добавляя тех.колонки:
- `event_ts` (опорная метка времени для инкремента),
- `_load_ts`,
- `_load_id={{ run_id }}`.
3) `check_row_counts`
- выполняет `sql/stg/bookings_dq.sql` в Greenplum;
- считает количество строк в источнике за то же окно инкремента и сравнивает с количеством строк,
вставленных в `stg.bookings` для текущего `_load_id`;
- при расхождении делает `RAISE EXCEPTION` с понятным текстом.
4) `load_tickets_to_stg`
- выполняет `sql/stg/tickets_load.sql` в Greenplum;
- так как в `bookings.tickets` нет явной временной колонки, окно инкремента берётся по `book_date`
из связанной внешней таблицы `stg.bookings_ext` (JOIN по `book_ref`);
- вставляет строки в `stg.tickets`, добавляя `event_ts`, `_load_ts` и `_load_id={{ run_id }}`.
5) `check_tickets_dq`
- выполняет `sql/stg/tickets_dq.sql` в Greenplum;
- проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля;
- при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”.
6) Справочники (full load, параллельно где возможно)
Справочники загружаются “снэпшотом” (все строки) и затем проверяются DQ-скриптом.
Порядок определяется зависимостями данных — **airports** и **airplanes** грузятся **параллельно**,
потому что не зависят друг от друга:
```
check_tickets_dq
├─ load_airports → check_airports_dq ─┐
│ ├─ load_routes → check_routes_dq
└─ load_airplanes → check_airplanes_dq ─┤
└─ load_seats → check_seats_dq
```
- `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`)
- `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`)
- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) — зависит от **airports** и **airplanes** (DQ проверяет ссылочную целостность)
- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) — зависит от **airplanes** (DQ проверяет `airplane_code → airplanes`)
7) Транзакции
- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) — зависит от **routes**
- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **flights**
- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **segments**
Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`.
Обе ветки сходятся на `finish_summary`.
Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно.
Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`).
8) `finish_summary`
- ждёт завершения **обеих** параллельных веток (`check_boarding_passes_dq` и `check_seats_dq`);
- логирует краткую сводку в конце запуска.
## Как проверить результат
```bash
make gp-psql
```
Примеры запросов:
Откройте Greenplum командой `make gp-psql`:
```sql
SELECT COUNT(*) FROM stg.bookings;
SELECT
event_ts,
_load_ts,
_load_id
SELECT book_ref, book_date, event_ts, _load_ts, _load_id
FROM stg.bookings
ORDER BY event_ts DESC
ORDER BY _load_ts DESC, book_ref
LIMIT 10;
```
## Типичные ошибки
После первого успешного запуска `stg.bookings` непуста. Сопоставьте `_load_id`
с Run ID в Airflow и объясните, чем `book_date` отличается от `_load_ts`.
Для просмотра первоисточника используйте `make bookings-psql`.
- `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`.
- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`.
- Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL.
Для технических деталей см. `docs/reference/pxf_bookings.md`.
Далее запускайте [ODS](bookings_to_gp_ods.md).
Общий порядок слоев находится в [порядке запуска DAG](dag_execution_order.md).
## Рекомендации по качеству решения
## Если загрузка не прошла
Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения:
`docs/archive/bookings_stg_code_review.md`.
Откройте Log первой упавшей задачи в Airflow. Если упала DQ, начните с ключа
и батча, названных в сообщении, затем откройте ее SQL из карточки таблицы.
- Нет базы `demo`: пройдите инициализацию из README. `make bookings-init`
пересоздает исходную базу из seed-дампа, поэтому не используйте его для
обычного повторного запуска уже заполненного стенда.
- Нет `stg.*` или `stg.*_ext`: выполните `bookings_stg_ddl`.
- Ошибка PXF или соединения: проверьте `docker compose ps`, логи `greenplum`
и `bookings-db`, затем настройки из [справочника стенда](stack.md).
Повторное применение DDL само по себе не исправляет недоступное подключение.
+53 -26
View File
@@ -1,35 +1,62 @@
# Порядок запуска DAG (Cross-DAG Dependencies)
# Порядок запуска DAG
В этом стенде пайплайны разделены на несколько DAG-ов по слоям DWH (STG, ODS, DDS, DM).
Они настроены с `schedule=None`, так как это учебный проект.
Все загрузочные DAG в стенде запускаются вручную (`schedule=None`).
Завершение одного слоя не запускает следующий автоматически.
Подготовка Docker и источника находится в [быстром старте](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
Чтобы данные корректно прошли от источника до витрин, запускать DAG-и нужно в определённом порядке.
## Создание таблиц
## 1. DDL-скрипты (выполняются один раз)
Перед первой загрузкой выполните по порядку:
Для создания структуры таблиц в аналитических слоях:
1. Запустите `bookings_stg_ddl` — создаст STG-таблицы и внешние `*_ext` через PXF.
2. Запустите `bookings_ods_ddl` — создаст таблицы ODS (типизированные, SCD1).
3. Запустите `bookings_dds_ddl` — создаст таблицы для измерений и фактов в слое DDS.
4. Запустите `bookings_dm_ddl` — создаст таблицы витрин в слое DM.
1. `bookings_stg_ddl` создает STG и внешние таблицы `*_ext` для чтения через PXF.
2. `bookings_ods_ddl` создает типизированные таблицы ODS.
3. `bookings_dds_ddl` создает измерения и факт DDS.
4. `bookings_dm_ddl` создает витрины DM.
*(Технический шорткат: `make ddl-gp` применяет DDL для всех 4 слоёв сразу).*
Альтернатива четырем запускам: `make ddl-gp`.
DDL создает структуру; для наполнения нужны отдельные загрузки.
В заданиях таблицы и задачи уже подготовлены, поэтому перед каждым
повторным запуском загрузки применять DDL не требуется.
## 2. Ежедневная загрузка (ETL)
## Проход данных по слоям
Для прогрузки новой порции данных (или полного перерасчёта) соблюдайте следующую цепочку:
Запускайте следующий DAG после успешного завершения всех задач предыдущего.
Эта последовательность показывает зависимости загрузок; связи строк и зерно
объектов смотрите на [карте](design/architecture-map.html), открытой
[локально](design/db_schema.md#как-открыть-карту).
1. **`bookings_to_gp_stage`**
- Извлекает новые данные из демо-БД PostgreSQL и сохраняет их в `stg`-схему в Greenplum.
- Генерирует `stg_batch_id` для текущей загрузки.
2. **`bookings_to_gp_ods`**
- Берёт последний согласованный `stg_batch_id` из STG-слоя.
- Выполняет нормализацию и SCD1-UPSERT в слой ODS.
3. **`bookings_to_gp_dds`**
- Читает очищенные данные из ODS.
- Обновляет измерения (SCD1, SCD2) и инкрементально догружает новые рейсы в таблицу фактов `dds.fact_flight_sales`.
4. **`bookings_to_gp_dm`**
- Читает новые факты из DDS.
- Обновляет агрегированные витрины (использует HWM-инкрементальность по `_load_ts` или полный перерасчёт).
| Порядок | DAG и руководство | Что меняется в данных |
|---|---|---|
| 1 | [bookings_to_gp_stage](bookings_to_gp_stage.md) | Генератор добавляет учебный день в Bookings; STG получает инкременты транзакций и полные снимки справочников с `_load_id = run_id` STG |
| 2 | [bookings_to_gp_ods](bookings_to_gp_ods.md) | Resolver выбирает общий STG-батч справочников; их снимки заменяются целиком. Транзакционные загрузки читают дельту STG по `_load_ts` и обновляют строки по бизнес-ключу |
| 3 | [bookings_to_gp_dds](bookings_to_gp_dds.md) | Сначала выполняются загрузки измерений, затем факт: вставляются новые пары `(ticket_no, flight_id)`, у существующих обновляются цена, место и признак посадки |
| 4 | [bookings_to_gp_dm](bookings_to_gp_dm.md) | `sales_report` пересчитывает полные агрегаты дат, затронутых изменениями факта; поведение остальных витрин задано в ТЗ |
> **💡 Архитектурная заметка:** В реальном production-окружении (Airflow) эти связи между DAG-ами обычно настраиваются автоматически через `TriggerDagRunOperator`, `ExternalTaskSensor` или механизмы Data-Aware Scheduling (Datasets/Data Assets). В учебных целях мы оставили их ручными, чтобы вы могли проинспектировать каждый слой после его загрузки.
На исходной `main` готовы весь STG, семь таблиц ODS, календарь, аэропорты,
тарифы, факт и `sales_report`. Загрузки и DQ остальных объектов являются
[заданиями](assignment/README.md). Успех этих задач с `SELECT 1;` оставляет
таблицы пустыми. У факта до реализации измерений могут быть `NULL` в
`passenger_sk`, `route_sk` и `airplane_sk`.
## Повторный запуск и проверка задания
Чтобы получить следующий учебный день, повторите цепочку STG → ODS → DDS → DM.
Когда проверяете правку SQL своего слоя на уже загруженных источниках,
начните с этого слоя; новый STG запустит генератор еще раз.
Подробности выбора батча и повторного запуска находятся в руководстве слоя.
После реализации измерений обычный повтор DDS не заполнит старые SK факта.
Выполните [пересчет по ТЗ](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений),
затем обновите DM.
Для самопроверки задания откройте
[«Запуск и проверка»](assignment/README.md#запуск-и-проверка).
`bookings_validate` запускайте отдельно от загрузок: дождитесь всего DAG
и успешного `validate_dds.scd2_restore` перед следующей записью в хранилище.
Тест SCD2 временно меняет данные и восстанавливает их из копии.
В [лабораторной PXF](assignment/pxf_airports.md) снимки справочников готовит
`lab_pxf_airports`: он выполняет только четыре справочника STG и их DQ.
Для передачи снимка и восстановления нижних слоев используйте обычные
DAG ODS → DDS → DM. Следующий учебный день по-прежнему загружается полной
цепочкой, начиная с `bookings_to_gp_stage`.
-244
View File
@@ -1,244 +0,0 @@
# 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». Финализировать.
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
# Исходники карты DWH
Готовый файл - [../architecture-map.html](../architecture-map.html).
Откройте его локально в браузере. Для самой карты сервер и интернет не требуются.
Ссылки на SQL, DAG и Markdown открывают Gitea, поэтому для переходов нужен интернет.
Учебный маршрут находится в [руководстве о структуре](../db_schema.md).
## Редактирование и сборка
- `map.json` - единственный каталог объектов, полей, связей и текстов карточек.
- `template.html` - интерфейс. SVG, карточки, постоянная раскладка и выделение
адаптированы из `clickstream-data-platform/docs/handbook/architecture-map/template.html`.
- `render.py` - сборка автономного HTML и статические проверки. Нужны uv и Python
3.11 или новее; пакеты Python и JavaScript не требуются. JSON заменяет YAML
образца, чтобы сборка обходилась стандартной библиотекой.
Из корня репозитория:
```bash
uv run docs/design/architecture-map/render.py
uv run docs/design/architecture-map/render.py --check
```
Правьте исходники, затем пересобирайте HTML. `--check` сверяет:
имена объектов с DDL/PXF, важные поля и поля связей, статусы загрузок и DQ,
входы готовых SQL со стрелками потока, файлы и разделы по ссылкам,
уникальность идентификаторов и позиций, свежесть HTML.
Проверка входов учитывает именованные таблицы в FROM/JOIN;
смысл соединений нужно сверять при изменении SQL вручную.
`repository_url` в `map.json` задает адрес Gitea с окончанием `/src/`.
В нем нет имени ветки или коммита: Gitea открывает файл из текущей ветки
по умолчанию. Пути в `links` остаются относительными к готовому HTML,
чтобы сборка проверяла файлы и разделы в локальном репозитории;
интерфейс преобразует их в адреса Gitea. Ссылки вида `#node=…` остаются
в локальной карте. Ссылка на руководство в шапке собирается из того же адреса.
Внешние `stg.*_ext` представлены в карточках STG, поскольку это входы чтения,
а не еще одна копия данных. Поля источника сверяются с контрактом PXF.
Для заглушек используются DDL и `docs/assignment/analyst_spec.md`.
Новые предположения о поведении загрузок в карточки не добавляйте.
## Адреса объектов
`../architecture-map.html#node=ods.airplanes` выбирает таблицу.
Дополнение `&view=keys` включает связи по ключам; без него показан поток данных.
Идентификатор равен полному имени таблицы и не зависит от позиции в карте.
Неизвестный адрес возвращает обзор с сообщением. Назад/вперед в браузере
восстанавливают предыдущий выбор и вид связей.
## Проверка в браузере
Статические проверки не заменяют этот проход:
1. Откройте готовый HTML с диска. Убедитесь, что видны пять слоев,
имена таблиц и пометки заданий; ошибок JavaScript нет.
2. Выберите `bookings.tickets`, затем `ods.segments` и `dds.fact_flight_sales`.
Проверьте источники, потребителей и карточки связей. Снимите выделение
повторным выбором, кнопкой "Общий вид" и клавишей Esc.
3. Для выбранных билета, факта и `dds.dim_airplanes` переключите оба вида:
выбор, позиции и прокрутка карты должны сохраниться.
4. У факта откройте обе роли `dim_airports`, путь через `ods.routes`
и выбор версии `dim_routes`. Проверьте отдельные карточки связей.
5. Откройте адрес объекта, обновите страницу, проверьте назад/вперед.
Откройте DDL, загрузку, DQ, DAG и пункт задания из карточки в Gitea.
Проверьте форматирование Markdown, подсветку кода и переход к разделу задания.
Ссылка на таблицу должна открывать локальную карту с тем же выбором.
6. Проверьте выбор клавишами Enter/пробел и вид на узком экране.
На узком экране карта прокручивается, карточка находится под ней.
File diff suppressed because it is too large. Load diff
+173
View File
@@ -0,0 +1,173 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Проверить данные карты и собрать автономный HTML без внешних зависимостей."""
from __future__ import annotations
import argparse
import json
import re
from html import escape
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
OUTPUT = HERE.parent / "architecture-map.html"
MARKER = "/*__MAP_DATA__*/"
NOTICE = (
"<!-- Собрано из architecture-map/map.json и template.html. "
"Команда: uv run docs/design/architecture-map/render.py -->\n"
)
def headings(text: str) -> set[str]:
"""Якоря Markdown для ссылок на существующие разделы документации."""
result = set(re.findall(r'<a\s+id="([^"]+)"', text))
for title in re.findall(r"^#{1,6}\s+(.+)$", text, re.M):
slug = re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
result.add(slug)
return result
def validate(data: dict) -> None:
"""Сверить объекты, поля, статусы, входы готовых SQL и локальные ссылки."""
repository = urlsplit(data["repository_url"])
if (
repository.scheme != "https"
or not repository.netloc
or not repository.path.endswith("/src/")
or repository.query
or repository.fragment
):
raise ValueError("Укажите HTTPS-адрес репозитория Gitea с окончанием /src/")
nodes = {node["id"]: node for node in data["nodes"]}
if len(nodes) != len(data["nodes"]):
raise ValueError("Повторяется идентификатор объекта")
edge_ids = [e["id"] for e in data["edges"]]
if len(set(edge_ids)) != len(edge_ids):
raise ValueError("Повторяется идентификатор связи")
columns, externals, tables = {}, {}, set()
for path in sorted((ROOT / "sql").glob("*/*_ddl.sql")):
text = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
pattern = r"CREATE\s+(EXTERNAL\s+)?TABLE\s+(?:IF NOT EXISTS\s+)?(\w+\.\w+)\s*\((.*?)\n\)"
for external, table, body in re.findall(pattern, text, re.S | re.I):
columns[table] = set(re.findall(r"^\s*(\w+)\s+\w+", body, re.M))
if external:
source = re.search(r"pxf://([^?]+)", text)[1]
columns[source] = columns[table]
externals[table] = source
else:
tables.add(table)
expected = tables | set(externals.values())
if set(nodes) != expected:
raise ValueError(f"Карта и DDL расходятся: {set(nodes) ^ expected}")
parts = {part for n in nodes.values() for part in n["parts"]}
if parts != set(externals):
raise ValueError("В карточках STG должны быть представлены все внешние таблицы")
layers = {layer["id"] for layer in data["layers"]}
positions = set()
for node in nodes.values():
if node["layer"] not in layers:
raise ValueError(f"Неизвестный слой: {node['id']}")
position = (node["layer"], node["order"])
if position in positions:
raise ValueError(f"Объекты перекрываются: {position}")
positions.add(position)
for field in ("what", "grain", "keys", "fields", "fill", "links"):
if not node[field]:
raise ValueError(f"Пустое поле {field}: {node['id']}")
for field in node["fields"]:
if field["name"] not in columns[node["id"]]:
raise ValueError(f"Нет поля {node['id']}.{field['name']} в DDL/PXF")
if node["layer"] == "source":
continue
layer, name = node["id"].split(".")
for role in ("load", "dq"):
path = ROOT / f"sql/{layer}/{name}_{role}.sql"
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")).strip()
stub = sql.upper() == "SELECT 1;"
if stub != (node["status"] == "student"):
raise ValueError(
f"Статус карты не совпадает с {path.relative_to(ROOT)}"
)
if role != "load" or stub:
continue
sources = set(re.findall(r"\b(?:FROM|JOIN)\s+(\w+\.\w+)", sql, re.I))
sources = {externals.get(s, s) for s in sources if s != node["id"]}
sources &= expected
drawn = {
e["from"]
for e in data["edges"]
if e["mode"] == "flow" and e["to"] == node["id"]
}
if sources != drawn:
raise ValueError(
f"Входы SQL и карты расходятся у {node['id']}: {sources ^ drawn}"
)
for edge in data["edges"]:
if edge["from"] not in nodes or edge["to"] not in nodes:
raise ValueError(f"Неизвестный конец связи {edge['id']}")
if edge["mode"] not in ("flow", "keys") or not edge["join"] or not edge["refs"]:
raise ValueError(f"Неполное описание связи {edge['id']}")
for ref in edge["refs"]:
if (
ref["node"] not in nodes
or not set(ref["columns"]) <= columns[ref["node"]]
):
raise ValueError(f"Неизвестные поля связи {edge['id']}: {ref}")
count = 0
for item in [*nodes.values(), *data["edges"]]:
for link in item["links"]:
url = urlsplit(link["url"])
if url.scheme or url.netloc:
raise ValueError(f"Ссылка должна вести в локальный репозиторий: {link}")
path = (OUTPUT.parent / unquote(url.path)).resolve()
if not path.is_relative_to(ROOT) or not path.is_file():
raise ValueError(f"Файл по ссылке не найден: {link['url']}")
if url.fragment and unquote(url.fragment) not in headings(
path.read_text(encoding="utf-8")
):
raise ValueError(f"Раздел по ссылке не найден: {link['url']}")
count += 1
print(
f"Проверены {len(nodes)} объектов, {len(edge_ids)} связей, {count} ссылок; поля, статусы и входы SQL совпадают"
)
def main() -> int:
"""Собрать HTML либо проверить его свежесть вместе с исходными данными."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="проверить без записи")
args = parser.parse_args()
try:
data = json.loads((HERE / "map.json").read_text(encoding="utf-8"))
validate(data)
template = (HERE / "template.html").read_text(encoding="utf-8")
if template.count(MARKER) != 1:
raise ValueError("В шаблоне должен быть ровно один маркер данных")
payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c")
guide_url = urljoin(data["repository_url"], "docs/design/db_schema.md")
result = NOTICE + template.replace(MARKER, payload).replace(
"__GUIDE_URL__", escape(guide_url, quote=True)
)
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != result:
raise ValueError(
"HTML устарел. Выполните uv run docs/design/architecture-map/render.py"
)
print("HTML соответствует исходникам")
else:
OUTPUT.write_text(result, encoding="utf-8")
print(f"Собрана карта: {OUTPUT.relative_to(ROOT)}")
return 0
except (ValueError, KeyError, OSError) as exc:
print(f"Не удалось собрать карту: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Карта DWH авиаперевозок</title>
<style>
:root {
color-scheme: light;
--paper: #f7f7f5;
--panel: #ffffff;
--ink: #272a2d;
--muted: #6b7177;
--line: #8c9298;
--soft: #e6e8e9;
--accent: #b54a2a;
--accent-soft: #f5ded7;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header { padding: 22px 26px 12px; }
h1 { margin: 0 0 5px; font-size: 25px; }
.lead { margin: 0; color: var(--muted); }
.controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 26px 16px;
}
.switcher { display: flex; flex-wrap: wrap; gap: 6px; }
button {
border: 1px solid #aeb3b7;
border-radius: 18px;
background: var(--panel);
color: var(--ink);
padding: 6px 13px;
font: inherit;
cursor: pointer;
}
button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
color: #71301e;
}
button:focus-visible, [tabindex="0"]:focus-visible {
outline: 3px solid #d79078;
outline-offset: 2px;
}
.legend { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-icon { display: block; width: 50px; height: 20px; flex: none; }
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 370px;
gap: 16px;
padding: 0 18px 24px;
align-items: start;
}
.map-scroll {
overflow-x: auto;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
#map { display: block; width: 100%; min-width: 1040px; height: auto; }
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.zone-caption { font-size: 12px; fill: var(--muted); }
.column-guide { stroke: #eef0f1; stroke-width: 1; }
.edge-line {
fill: none;
stroke: var(--line);
stroke-width: 1.8;
marker-end: url(#arrowhead);
transition: opacity .15s, stroke .15s, stroke-width .15s;
}
.edge-line.dashed { stroke-dasharray: 8 6; }
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
.node { cursor: pointer; transition: opacity .15s; }
.node rect, .legend-node rect {
fill: #f1f2f2;
stroke: #858b90;
stroke-width: 1.2;
rx: 7;
transition: fill .15s, stroke .15s, stroke-width .15s;
}
.node text {
fill: var(--ink);
font: 14px ui-monospace, SFMono-Regular, Consolas, monospace;
text-anchor: middle;
pointer-events: none;
}
.node .stream-mark { fill: var(--muted); font: 11px system-ui, sans-serif; }
.is-filtered, .is-context-dim { opacity: .16; }
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
.is-path .edge-line, .edge.is-path .edge-line {
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
}
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
.node.is-selected rect { stroke-width: 3; }
.edge.is-selected .edge-line { stroke-width: 4; }
aside {
min-height: 470px; max-height: calc(100vh - 32px); overflow-y: auto; position: sticky; top: 16px;
padding: 20px;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
@media (max-width: 900px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; max-height: none; position: static; } .map-scroll { max-height: 50vh; overflow: auto; } }
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
.meta { margin: 0 0 18px; color: var(--muted); }
.card-section { margin-top: 17px; }
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
.card-section p { margin: 0; }
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
.card-section li + li { margin-top: 5px; }
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
a { color: #8a351e; text-underline-offset: 2px; }
.hint { color: var(--muted); font-size: 13px; }
.edge[hidden] { display: none; }
.node.student rect, .legend-node.student rect { stroke-dasharray: 5 3; }
.node.student .stream-mark { fill: #875a14; }
.edge.expected .edge-line, .legend-arrow.expected .edge-line { stroke-dasharray: 5 4; }
.node.is-upstream rect, .legend-node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
.node.is-downstream rect, .legend-node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
.node.is-selected rect, .legend-node.is-selected rect { fill: var(--accent-soft); stroke: var(--accent); }
.relation-button { border-radius: 5px; padding: 5px 7px; text-align: left; width: 100%; margin-top: 5px; font-size: 13px; }
.badge { font-size: 13px; padding: 3px 8px; border-radius: 4px; background: #eef1ed; display: inline-block; }
.badge.student { background: #fff1cf; color: #775018; }
.navigation { margin: 8px 0; }
code { overflow-wrap: anywhere; }
summary { cursor: pointer; font-weight: 600; }
#view-hint { margin: 0; max-width: 1050px; }
noscript { display: block; padding: 20px; }
</style>
</head>
<body>
<header>
<h1 id="page-title"></h1>
<p class="lead" id="lead"></p>
<p class="navigation"><a href="__GUIDE_URL__" target="_blank" rel="noopener noreferrer">Руководство: от бронирования до витрины (Gitea)</a> · Учебная ветка main</p>
</header>
<div class="controls">
<div class="switcher" id="switcher" aria-label="Вид связей"></div>
<p id="view-hint" class="hint"></p>
<div class="legend" aria-label="Обозначения стрелок">
<span class="legend-item">
<svg class="legend-icon legend-arrow" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="ready-edge-legend">Готовая загрузка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow expected" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="expected-edge-legend">Загрузка по ТЗ</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow is-path" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span>Выделенные связи</span>
</span>
</div>
<div class="legend" aria-label="Обозначения таблиц">
<span class="legend-item">
<svg class="legend-icon legend-node" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Готовая таблица</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node student" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Учебная заглушка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-upstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="upstream-legend">Источники</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-downstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="downstream-legend">Потребители</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-selected" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Выбранная таблица</span>
</span>
</div>
</div>
<main>
<div class="map-scroll">
<svg id="map" viewBox="0 0 1250 725" role="group" aria-label="Путь данных через слои хранилища"></svg>
</div>
<aside id="details" aria-live="polite" aria-label="Карточка выбранного объекта"></aside>
</main>
<noscript>Для интерактивной карты включите JavaScript. Объяснение модели доступно в <a href="__GUIDE_URL__">руководстве в Gitea</a>.</noscript>
<script>
const MAP = /*__MAP_DATA__*/;
const NS = "http://www.w3.org/2000/svg";
const state = { view: "flow", selected: null };
const nodeById = new Map(MAP.nodes.map(node => [node.id, node]));
const edgeById = new Map(MAP.edges.map(edge => [edge.id, edge]));
const positions = new Map();
const svg = document.getElementById("map");
const details = document.getElementById("details");
const statusLabel = {ready: "Готовая загрузка", student: "Задание · загрузка и DQ — заглушки", source: "Источник · демо-БД"};
function svgElement(name, attributes = {}) {
const element = document.createElementNS(NS, name);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
return element;
}
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? ""; }
function appendRichText(parent, value) {
value.split("`").forEach((part, index) => {
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
if (index % 2) element.textContent = part;
parent.append(element);
});
}
function addSection(title, value) {
if (!value || (Array.isArray(value) && !value.length)) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = title;
section.append(heading);
if (Array.isArray(value)) {
const list = document.createElement("ul");
value.forEach((item) => {
const row = document.createElement("li");
appendRichText(row, item);
list.append(row);
});
section.append(list);
} else {
const paragraph = document.createElement("p");
appendRichText(paragraph, value);
section.append(paragraph);
}
details.append(section);
}
function addLinks(links) {
if (!links?.length) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = "Ссылки";
const list = document.createElement("ul");
links.forEach((item) => {
const row = document.createElement("li");
const link = document.createElement("a");
// Выбор таблицы остается в локальной карте; файлы открываются в Gitea.
link.href = item.url.startsWith("#") ? item.url : new URL(item.url, new URL("docs/design/", MAP.repository_url)).href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = item.label;
row.append(link);
list.append(row);
});
section.append(heading, list);
details.append(section);
}
function renderIntro(message = "") {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = message || "Начните с билета";
details.append(heading);
addSection("Маршрут чтения", "Один билет может включать несколько перелетов. Выберите bookings.tickets, затем проследите сегменты до факта и витрины sales_report.");
addSection("Как исследовать", "Выбор таблицы подсвечивает ее окружение: источники и потребителей в потоке данных, родителей и потомков в связях по ключам. Их положение остается прежним. Соседние связи доступны кнопками в карточке. Повторный выбор, Esc или «Общий вид» снимает выделение.");
addSection("Что показывает карта", "Стрелки описывают данные. Порядок задач Airflow смотрите отдельно по ссылке на DAG в карточке. Пунктирная стрелка означает ожидаемую загрузку из задания.");
addSection("Вход через PXF", "Внешние stg.*_ext показаны внутри карточек STG: они читают Postgres, но сами не хранят копию данных.");
addSection("Ключи", "Все ключи и связи здесь логические. DDL таблиц DWH не объявляет PRIMARY KEY, UNIQUE и FOREIGN KEY. DISTRIBUTED BY задает размещение, а не уникальность.");
addLinks([{label: "Открыть руководство о модели", url: "db_schema.md"}]);
}
function relationButtons(node) {
const section = document.createElement("section");
section.className = "card-section";
const h = document.createElement("h3"); h.textContent = "Связи рядом с таблицей";
section.append(h);
const edges = MAP.edges.filter(e => e.mode === state.view && (e.from === node.id || e.to === node.id));
edges.forEach(edge => {
const b = document.createElement("button"); b.className = "relation-button";
const neighbor = edge.from === node.id ? edge.to : edge.from;
b.textContent = `${edge.from === node.id ? "→" : "←"} ${neighbor}: ${edge.label}`;
b.dataset.edge = edge.id;
b.addEventListener("click", () => selectItem("edge", edge.id));
section.append(b);
});
if (!edges.length) {
const p = document.createElement("p"); p.textContent = "В этом виде рядом нет связей. Переключите вид, чтобы увидеть путь загрузки."; section.append(p);
}
details.append(section);
}
function renderNodeCard(node) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = node.name;
const meta = document.createElement("p"); meta.className = `badge ${node.status}`; meta.textContent = statusLabel[node.status];
details.append(heading, meta);
addSection("Назначение", node.what);
addSection("Одна строка", node.grain);
addSection("Логические ключи", node.keys);
addSection(node.status === "student" ? "Ожидаемый результат по ТЗ" : "Как наполняется", node.fill);
if (node.parts.length) addSection("Вход через PXF", node.parts.map(id => `\`${id}\` — внешняя таблица для чтения Postgres, без локального хранения. Ее определение находится в том же DDL.`));
const fields = document.createElement("details"); fields.className = "card-section";
const summary = document.createElement("summary"); summary.textContent = `Важные поля (${node.fields.length})`;
const list = document.createElement("ul");
node.fields.forEach(field => {
const li = document.createElement("li"); appendRichText(li, "`" + field.name + "` — " + field.meaning); list.append(li);
});
fields.append(summary,list); details.append(fields);
relationButtons(node);
const dag = {stg:"stage",ods:"ods",dds:"dds",dm:"dm"}[node.layer];
addLinks([...node.links, ...(dag ? [{label:"DAG · порядок задач",url:`../../airflow/dags/bookings_to_gp_${dag}.py`}] : []), {label:"Ссылка на эту таблицу",url:`#node=${node.id}&view=${state.view}`}]);
}
function renderEdgeCard(edge) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = edge.label; details.append(heading);
addSection("Таблицы", "`" + edge.from + "` → `" + edge.to + "`");
addSection("Вид связи", edge.mode === "flow" ? (edge.status === "student" ? "Ожидаемый поток по ТЗ; загрузка пока не реализована." : "Чтение и преобразование данных в готовом SQL.") : "Логическое сопоставление строк по ключам. Это не зависимость задач Airflow и не объявленное ограничение БД.");
addSection("Что связывает", edge.join);
addSection("Участвующие поля", edge.refs.map(r => `\`${r.node}\`: ${r.columns.map(c => "`"+c+"`").join(", ")}`));
[edge.from, edge.to].forEach(id => {
const b = document.createElement("button"); b.className = "relation-button"; b.textContent = `Открыть ${id}`;
b.addEventListener("click", () => selectItem("node", id)); details.append(b);
});
addLinks(edge.links);
}
function layoutMap() {
MAP.layers.forEach((layer, index) => {
MAP.nodes.filter(node => node.layer === layer.id).forEach(node => {
positions.set(node.id, {x: 20 + index * 250, y: 72 + node.order * 70, width: 195, height: 48});
});
});
}
function edgePath(edge, offset) {
const a = positions.get(edge.from), b = positions.get(edge.to);
const y1 = a.y + a.height / 2 + offset, y2 = b.y + b.height / 2 + offset;
if (a.x === b.x) {
const x = a.x + a.width, bend = x + 20 + Math.abs(a.y - b.y) * .08 + offset;
return `M ${x} ${y1} C ${bend} ${y1}, ${bend} ${y2}, ${x} ${y2}`;
}
const x1 = a.x + a.width, x2 = b.x, bend = Math.max(18, (x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
}
function drawMap() {
const defs = svgElement("defs");
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
const marker = svgElement("marker", {id, viewBox:"0 0 8 8", refX:"7", refY:"4", markerWidth:"6", markerHeight:"6", orient:"auto"});
marker.append(svgElement("path", {d:"M 0 0 L 8 4 L 0 8 z", fill})); defs.append(marker);
});
svg.append(defs);
MAP.layers.forEach((layer,index) => {
const center = 117.5 + index * 250;
const title = svgElement("text", {x:center, y:27, class:"layer-title"}); title.textContent = layer.title;
const caption = svgElement("text", {x:center, y:47, class:"layer-caption"}); caption.textContent = layer.caption;
svg.append(title,caption);
});
MAP.edges.forEach(edge => {
const siblings = MAP.edges.filter(e => e.from === edge.from && e.to === edge.to && e.mode === edge.mode);
const offset = (siblings.indexOf(edge) - (siblings.length - 1) / 2) * 13;
const group = svgElement("g", {class:`edge ${edge.status === "student" ? "expected" : ""}`, "data-id":edge.id, tabindex:"0", role:"button", "aria-label":`${edge.label}: ${edge.from} → ${edge.to}`});
const d = edgePath(edge,offset);
group.append(svgElement("path", {d,class:"edge-line"}),svgElement("path", {d,class:"edge-hit"}));
bindSelection(group,"edge",edge.id); svg.append(group);
});
MAP.nodes.forEach(node => {
const p = positions.get(node.id);
const g = svgElement("g", {class:`node ${node.status}`, "data-id":node.id, tabindex:"0", role:"button", "aria-label":`${node.name}: ${statusLabel[node.status]}`});
g.append(svgElement("rect",p));
const label = svgElement("text", {x:p.x+p.width/2,y:p.y+21}); label.textContent = node.name;
const mark = svgElement("text", {x:p.x+p.width/2,y:p.y+38,class:"stream-mark"}); mark.textContent = {ready:"готово",student:"задание",source:"источник"}[node.status];
g.append(label,mark); bindSelection(g,"node",node.id); svg.append(g);
});
}
function bindSelection(element,type,id) {
element.addEventListener("click", () => selectItem(type,id));
element.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectItem(type,id); }
});
}
function walk(nodeId,direction) {
const nodes = new Set([nodeId]), edges = new Set(), queue = [nodeId];
while (queue.length) {
const current = queue.shift();
MAP.edges.filter(e => e.mode === state.view).forEach(edge => {
if ((direction === "in" ? edge.to : edge.from) !== current) return;
edges.add(edge.id);
const next = direction === "in" ? edge.from : edge.to;
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
});
}
return {nodes,edges};
}
function updateHighlights() {
let upstream = new Set(), downstream = new Set(), activeEdges = new Set(), activeNodes = new Set();
if (state.selected?.type === "node") {
const a = walk(state.selected.id,"in"), b = walk(state.selected.id,"out");
upstream = a.nodes; downstream = b.nodes;
activeNodes = new Set([...upstream,...downstream]); activeEdges = new Set([...a.edges,...b.edges]);
} else if (state.selected?.type === "edge") {
const edge = edgeById.get(state.selected.id);
activeNodes = new Set([edge.from,edge.to]); activeEdges.add(edge.id);
}
svg.querySelectorAll(".node").forEach(el => {
const id = el.dataset.id, selected = state.selected?.type === "node" && state.selected.id === id;
el.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(id));
el.classList.toggle("is-upstream", upstream.has(id)); el.classList.toggle("is-downstream", downstream.has(id));
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
svg.querySelectorAll(".edge").forEach(el => {
const edge = edgeById.get(el.dataset.id), visible = edge.mode === state.view;
el.toggleAttribute("hidden", !visible); el.setAttribute("tabindex",visible ? "0" : "-1");
el.setAttribute("aria-hidden",String(!visible));
el.classList.toggle("is-context-dim",Boolean(state.selected) && !activeEdges.has(edge.id));
el.classList.toggle("is-path",activeEdges.has(edge.id));
const selected = state.selected?.type === "edge" && state.selected.id === edge.id;
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
}
function renderState(message = "") {
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed",String(b.dataset.view === state.view)));
document.getElementById("upstream-legend").textContent = state.view === "flow" ? "Источники" : "Связанные родители";
document.getElementById("downstream-legend").textContent = state.view === "flow" ? "Потребители" : "Связанные потомки";
document.getElementById("ready-edge-legend").textContent = state.view === "flow" ? "Готовая загрузка" : "Связь по ключам";
document.getElementById("expected-edge-legend").textContent = state.view === "flow" ? "Загрузка по ТЗ" : "Связь с учебной заглушкой";
document.getElementById("view-hint").textContent = state.view === "flow" ? "Стрелка: откуда загрузчик читает данные и куда записывает результат. Календарь генерируется отдельно." : "Стрелка: от родительской строки к связанным строкам. Например, от бронирования к билетам. Это не порядок загрузки и не физические FK.";
if (state.selected?.type === "node") renderNodeCard(nodeById.get(state.selected.id));
else if (state.selected?.type === "edge") renderEdgeCard(edgeById.get(state.selected.id));
else renderIntro(message);
details.scrollTop = 0; updateHighlights();
}
function saveAddress() {
const p = new URLSearchParams();
if (state.selected) p.set(state.selected.type,state.selected.id);
if (state.view !== "flow") p.set("view",state.view);
const hash = p.toString();
if (location.hash.slice(1) !== hash) location.hash = hash;
renderState();
}
function selectItem(type,id) {
const repeated = state.selected?.type === type && state.selected.id === id;
state.selected = repeated ? null : {type,id}; saveAddress();
}
function readAddress() {
const p = new URLSearchParams(location.hash.slice(1));
state.view = p.get("view") === "keys" ? "keys" : "flow";
state.selected = null;
if (nodeById.has(p.get("node"))) state.selected = {type:"node",id:p.get("node")};
else if (edgeById.has(p.get("edge"))) {
const edge = edgeById.get(p.get("edge")); state.view = edge.mode; state.selected = {type:"edge",id:edge.id};
}
renderState(!state.selected && (p.has("node") || p.has("edge")) ? "Объект по ссылке не найден. Выберите таблицу на карте." : "");
}
function renderControls() {
const switcher = document.getElementById("switcher");
[["flow","Поток данных"],["keys","Связи по ключам"]].forEach(([id,label]) => {
const b = document.createElement("button"); b.textContent = label; b.dataset.view = id;
b.addEventListener("click", () => {
state.view = id;
if (state.selected?.type === "edge") state.selected = {type:"node",id:edgeById.get(state.selected.id).to};
saveAddress();
}); switcher.append(b);
});
const clear = document.createElement("button"); clear.textContent = "Общий вид"; clear.id = "clear-selection";
clear.addEventListener("click", () => {state.selected = null; saveAddress();}); switcher.append(clear);
}
document.getElementById("page-title").textContent = MAP.title;
document.getElementById("lead").textContent = MAP.lead;
layoutMap(); drawMap(); renderControls(); readAddress();
window.addEventListener("hashchange",readAddress);
document.addEventListener("keydown",event => {
if (event.key === "Escape" && state.selected) {state.selected = null; saveAddress();}
});
</script>
</body>
</html>
-173
View File
@@ -1,173 +0,0 @@
# Дизайн курсового задания
> Тактические решения по нарезке задания, порядку выполнения и самопроверке.
> Стратегию и контекст см. в [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, обработку типов
+20 -3
View File
@@ -1,4 +1,20 @@
# DDS Layer: план реализации Star Schema для bookings
# История проектирования DDS для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки DDS](../bookings_to_gp_dds.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=dds.fact_flight_sales);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "DDS Layer: план реализации Star Schema для bookings".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Контекст
@@ -569,8 +585,9 @@ UPDATE факта обновляет только **мутабельные по
1. Таблица не пуста
2. Нет дублей по зерну `(ticket_no, flight_id)`
3. Количество строк = `COUNT(*)` из `ods.segments`
4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0
5. **FK маршрута**: NULL в любом из `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк
4. **Обязательные FK**: `tariff_sk IS NULL` = 0. На main `passenger_sk` — NOTICE (dim_passengers — заглушка); на solution — EXCEPTION.
5. **Эталонные FK аэропортов**: `departure_airport_sk`, `arrival_airport_sk` (через `ods.routes`) — порог 1%, EXCEPTION.
**Студенческие 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% строк
7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL
+17 -1
View File
@@ -1,4 +1,20 @@
# План: DM-слой (Data Mart) для учебного стенда Bookings
# История проектирования DM для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки DM](../bookings_to_gp_dm.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=dm.sales_report);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "План: DM-слой (Data Mart) для учебного стенда Bookings".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Context
+26 -5
View File
@@ -1,4 +1,20 @@
# ODS Layer: эталонный учебный план реализации (v3)
# История проектирования ODS для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки ODS](../bookings_to_gp_ods.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=ods.airports);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "ODS Layer: эталонный учебный план реализации (v3)".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Контекст
@@ -512,7 +528,9 @@ ANALYZE ods.bookings;
### 6.4. Поведение при пустом батче
- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) пустой батч допустим;
- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой.
- для snapshot-справочников (`airports`, `routes`) пустой батч считаем ошибкой.
На main `airplanes` и `seats` — студенческие заглушки (load/dq = `SELECT 1;`),
таблицы будут пустыми до реализации студентом.
---
@@ -531,7 +549,8 @@ ANALYZE ods.bookings;
3. **Обязательные поля** не `NULL`/не пустые.
4. **Батч не пустой** для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
4. **Батч не пустой** для эталонных snapshot-справочников (`airports`, `routes`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
На main `airplanes_dq.sql` и `seats_dq.sql` — студенческие заглушки.
5. **Ссылочная целостность** в ODS:
- `tickets.book_ref -> bookings.book_ref`
@@ -617,7 +636,8 @@ tests/test_dags_smoke.py (+ smoke для 2 новых DAG)
resolve_stg_batch_id
├-> load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets ──────────────────┐
├-> load_ods_airports -> dq_ods_airports ─┐ │
├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes │
├-> load_ods_airplanes -> dq_ods_airplanes ─┐
│ ├-> load_ods_routes -> dq_ods_routes │
└-> └-> load_ods_seats -> dq_ods_seats │
│
dq_ods_routes -> load_ods_flights -> dq_ods_flights │
@@ -631,7 +651,8 @@ resolve_stg_batch_id
Зависимости (по FK):
- `tickets` после `bookings` (FK: `book_ref`);
- `routes` после `airports` и `airplanes` (FK: `departure_airport`, `arrival_airport`, `airplane_code`);
- `routes` после `airports` (FK: `departure_airport`, `arrival_airport`).
На ветке `solution` также зависит от `airplanes` (FK: `airplane_code`);
- `seats` после `airplanes` (FK: `airplane_code`);
- `flights` после `routes` (FK: `route_no`);
- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`);
+19 -4
View File
@@ -1,8 +1,24 @@
# Дизайн STG для bookings в Greenplum
# История проектирования STG для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки STG](../bookings_to_gp_stage.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=stg.bookings);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "Дизайн STG для bookings в Greenplum".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## 1. Цель и общий контур
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md)).
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings`.
- Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum.
- В этом документе описываем часть `src (bookings-db) → STG (Greenplum)`. STG — входной слой; далее данные обрабатываются в ODS → DDS → DM (см. соответствующие design-документы).
@@ -127,8 +143,7 @@ DDL определён в `sql/stg/bookings_ddl.sql` и подключается
## 5. Связь с остальными документами
- [`docs/reference/bookings_tz.md`](../reference/bookings_tz.md) — как готовится и генерируется источник `bookings-db`.
- [`docs/reference/pxf_bookings.md`](../reference/pxf_bookings.md) — детали настройки PXF и внешней таблицы для чтения из `bookings-db`.
- Детали настройки PXF и часовых поясов — в ветке `solution` (`docs/reference/`).
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`).
Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5).
+182 -280
View File
@@ -1,315 +1,217 @@
# Схема БД DWH (Bookings → Greenplum)
# Как устроено хранилище авиаперевозок
> **Статус:** Все слои реализованы в ветке `solution` (STG, ODS, DDS, DM).
> На ветке `main` студенческие таблицы ODS (`airplanes`, `seats`), DDS-измерения
> (`dim_airplanes`, `dim_passengers`, `dim_routes`) и студенческие DM-витрины
> (`airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`)
> — заглушки (`SELECT 1;`). Данные появятся после реализации заданий.
Разберем одну поездку: как бронирование связано с билетами и перелетами,
какие строки попадут в факт и как сложится выручка по направлениям.
Найти эти строки в структуре хранилища поможет [интерактивная карта](architecture-map.html).
Карта хранит подробности объектов и связей, а это руководство задает маршрут чтения.
Архитектура хранилища данных (DWH) для учебного проекта Airflow + Greenplum.
Источник — демо-БД `bookings` (Postgres). Документ даёт цельный взгляд «сверху»;
детали реализации — в дизайн-документах слоёв.
## Как открыть карту
---
Из локальной копии репозитория откройте `docs/design/architecture-map.html`
в браузере: через файловый менеджер или командой из корня проекта:
## Полная схема потоков данных (Data Lineage)
```mermaid
graph LR
%% Стили
classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef stg fill:#fff9c4,stroke:#fbc02d,stroke-width:2px;
classDef ods fill:#e0f2f1,stroke:#00695c,stroke-width:2px;
classDef dim fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
classDef fact fill:#ffccbc,stroke:#bf360c,stroke-width:4px;
classDef dm fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
%% 1. Source
subgraph Source_Postgres [Source: Postgres Bookings]
direction TB
SRC_Airports[airports_data]:::source
SRC_Airplanes[airplanes_data]:::source
SRC_Routes[routes]:::source
SRC_Seats[seats]:::source
SRC_Bookings[bookings]:::source
SRC_Tickets[tickets]:::source
SRC_Flights[flights]:::source
SRC_Segments[segments]:::source
SRC_Boarding[boarding_passes]:::source
end
%% 2. STAGING (Load 1-to-1, AO-Row)
subgraph STG_Layer [Layer: STG Staging]
direction TB
STG_Airports[stg.airports]:::stg
STG_Airplanes[stg.airplanes]:::stg
STG_Routes[stg.routes]:::stg
STG_Seats[stg.seats]:::stg
STG_Bookings[stg.bookings]:::stg
STG_Tickets[stg.tickets]:::stg
STG_Flights[stg.flights]:::stg
STG_Segments[stg.segments]:::stg
STG_Boarding[stg.boarding_passes]:::stg
end
%% Links Source to STG
SRC_Airports --> STG_Airports
SRC_Airplanes --> STG_Airplanes
SRC_Routes --> STG_Routes
SRC_Seats --> STG_Seats
SRC_Bookings --> STG_Bookings
SRC_Tickets --> STG_Tickets
SRC_Flights --> STG_Flights
SRC_Segments --> STG_Segments
SRC_Boarding --> STG_Boarding
%% 3. ODS (3NF, Clean, Type)
subgraph ODS_Layer [Layer: ODS Operational Core]
direction TB
ODS_Airports[ods.airports]:::ods
ODS_Airplanes[ods.airplanes]:::ods
ODS_Routes[ods.routes]:::ods
ODS_Seats[ods.seats]:::ods
ODS_Bookings[ods.bookings]:::ods
ODS_Tickets[ods.tickets]:::ods
ODS_Flights[ods.flights]:::ods
ODS_Segments[ods.segments]:::ods
ODS_Boarding[ods.boarding_passes]:::ods
end
%% Links STG to ODS
STG_Airports --> ODS_Airports
STG_Airplanes --> ODS_Airplanes
STG_Routes --> ODS_Routes
STG_Seats --> ODS_Seats
STG_Bookings --> ODS_Bookings
STG_Tickets --> ODS_Tickets
STG_Flights --> ODS_Flights
STG_Segments --> ODS_Segments
STG_Boarding --> ODS_Boarding
%% 4. DDS (Star Schema)
subgraph DDS_Layer [Layer: DDS Star Schema]
direction TB
DIM_Calendar[dds.dim_calendar]:::dim
DIM_Airports[dds.dim_airports]:::dim
DIM_Airplanes[dds.dim_airplanes]:::dim
DIM_Tariffs[dds.dim_tariffs]:::dim
DIM_Passengers[dds.dim_passengers]:::dim
DIM_Routes[dds.dim_routes SCD2]:::dim
FACT_Sales[dds.fact_flight_sales]:::fact
end
%% ODS to DDS Dimensions
ODS_Airports --> DIM_Airports
ODS_Airplanes --> DIM_Airplanes
ODS_Seats -.->|total_seats| DIM_Airplanes
ODS_Segments -.->|DISTINCT| DIM_Tariffs
ODS_Tickets -->|Unique passengers| DIM_Passengers
ODS_Routes -->|SCD2 hashdiff| DIM_Routes
DIM_Airports -.->|cities| DIM_Routes
DIM_Airplanes -.->|model, seats| DIM_Routes
%% ODS to Fact
ODS_Segments -->|Main stream| FACT_Sales
ODS_Tickets -->|book_ref, passenger_id| FACT_Sales
ODS_Bookings -->|book_date| FACT_Sales
ODS_Flights -->|schedule, route_no| FACT_Sales
ODS_Routes -->|dep/arr airports| FACT_Sales
ODS_Boarding -->|LEFT JOIN seat_no| FACT_Sales
%% Dimensions to Fact
DIM_Calendar -->|calendar_sk| FACT_Sales
DIM_Airports -->|dep/arr _sk| FACT_Sales
DIM_Airplanes -->|airplane_sk| FACT_Sales
DIM_Tariffs -->|tariff_sk| FACT_Sales
DIM_Passengers -->|passenger_sk| FACT_Sales
DIM_Routes -->|route_sk| FACT_Sales
%% 5. DM (Vitrines)
subgraph DM_Layer [Layer: DM Data Marts]
direction TB
DM_Sales[dm.sales_report]:::dm
DM_Traffic[dm.airport_traffic]:::dm
DM_Route[dm.route_performance]:::dm
DM_Monthly[dm.monthly_overview]:::dm
DM_Loyalty[dm.passenger_loyalty]:::dm
end
%% DDS to DM
FACT_Sales --> DM_Sales
FACT_Sales --> DM_Traffic
FACT_Sales --> DM_Route
FACT_Sales --> DM_Monthly
FACT_Sales --> DM_Loyalty
DIM_Airports -.-> DM_Sales
DIM_Airports -.-> DM_Traffic
DIM_Tariffs -.-> DM_Sales
DIM_Tariffs -.-> DM_Loyalty
DIM_Calendar -.-> DM_Sales
DIM_Calendar -.-> DM_Traffic
DIM_Calendar -.-> DM_Route
DIM_Calendar -.-> DM_Monthly
DIM_Calendar -.-> DM_Loyalty
DIM_Routes -.-> DM_Route
DIM_Routes -.-> DM_Monthly
DIM_Routes -.-> DM_Loyalty
DIM_Airplanes -.-> DM_Monthly
DIM_Passengers -.-> DM_Loyalty
```bash
xdg-open docs/design/architecture-map.html
```
---
На Windows можно открыть этот же файл в Проводнике. Сервер, интернет и работающий
Docker для карты не нужны: все ее данные включены в HTML.
Ссылки на SQL, DAG, задание и это руководство открывают файлы в Gitea:
документы — с форматированием, код — с подсветкой синтаксиса.
Для этих переходов нужен интернет. Файлы берутся из текущей ветки репозитория
по умолчанию.
## Сводка объектов по слоям
**В Gitea ссылка на HTML обычно показывает исходный текст, а не карту.**
Сначала откройте локальный HTML. Адресная ссылка вида
`architecture-map.html#node=dds.fact_flight_sales` сразу выбирает таблицу;
ее можно открыть из локального просмотра этого руководства или добавить
часть после `#` к адресу уже открытой карты.
### STG (Staging)
## Бронирование, билет и перелет
Сырые данные из источника, все бизнес-поля как TEXT. Хранение: AO Row (zstd).
Служебные поля: `event_ts`, `_load_ts`, `_load_id`.
Ниже **учебный пример**, придуманный для объяснения. Это не выгрузка из стенда.
Короткие обозначения `T1`, `F101`, `R1` заменяют настоящие номера.
| Таблица | Источник (PXF) | Ключ | Стратегия загрузки | Distribution |
|---------|---------------|------|-------------------|--------------|
| `stg.bookings` | `bookings.bookings` | `book_ref` | Инкремент (book_date) | `book_ref` |
| `stg.tickets` | `bookings.tickets` | `ticket_no` | Инкремент (через bookings) | `book_ref` |
| `stg.flights` | `bookings.flights` | `flight_id` | Инкремент (scheduled_departure) | `flight_id` |
| `stg.segments` | `bookings.segments` | `(ticket_no, flight_id)` | Инкремент (через tickets) | `ticket_no` |
| `stg.boarding_passes` | `bookings.boarding_passes` | `(ticket_no, flight_id)` | Инкремент (через tickets/bookings) | `ticket_no` |
| `stg.airports` | `bookings.airports_data` | `airport_code` | Full snapshot | `airport_code` |
| `stg.airplanes` | `bookings.airplanes_data` | `airplane_code` | Full snapshot | `airplane_code` |
| `stg.routes` | `bookings.routes` | `(route_no, validity)` | Full snapshot | `route_no` |
| `stg.seats` | `bookings.seats` | `(airplane_code, seat_no)` | Full snapshot | `airplane_code` |
Аня и Борис купили билеты в одном бронировании `B1` от 30 августа.
Аня летит 1 сентября из Москвы через Петербург в Казань. Борис летит
тем же первым рейсом до Петербурга. Все сегменты - Economy.
Детали: [`bookings_stg_design.md`](bookings_stg_design.md)
| Таблица источника | Пример строк | Что различают строки |
|---|---|---|
| `bookings.bookings` | `B1`, сумма `12000` | Одно бронирование на всю покупку |
| `bookings.tickets` | `T1 → B1, Аня`; `T2 → B1, Борис` | Два билета, по одному на пассажира |
| `bookings.segments` | `(T1, F101), 3000`; `(T1, F102), 5000`; `(T2, F101), 4000` | Три оплаченных перелета |
| `bookings.flights` | `F101 → R1`, 1 сентября; `F102 → R2`, 1 сентября | Два конкретных рейса |
| `bookings.routes` | `R1: SVO → LED`; `R2: LED → KZN` | Маршруты и варианты их расписания |
### ODS (Operational Data Store)
Бронирование объединяет покупку. Билет принадлежит пассажиру и может включать
несколько сегментов. Сегмент соединяет билет с конкретным рейсом и хранит цену
этого перелета. Поэтому `ticket_no` недостаточно для поиска сегмента:
нужна пара `(ticket_no, flight_id)`.
Очищенные данные с корректными типами. Справочники: AO Row (TRUNCATE+INSERT).
Транзакции: Heap (SCD1 UPSERT). Служебные поля: `_load_id`, `_load_ts`,
`event_ts` (для транзакционных таблиц).
Рейс и маршрут тоже различаются. `flight_id` обозначает один вылет,
а `route_no` повторяется у рейсов в разные дни. В `routes` у одного номера
могут быть разные варианты расписания; строку различает пара
`(route_no, validity)`.
| Таблица | Стратегия | Storage | Ключевые преобразования |
|---------|-----------|---------|------------------------|
| `ods.bookings` | SCD1 UPSERT (HWM) | Heap | `total_amount` TEXT → NUMERIC |
| `ods.tickets` | SCD1 UPSERT (HWM) | Heap | `outbound` TEXT → BOOLEAN |
| `ods.flights` | SCD1 UPSERT (HWM) | Heap | TEXT → INTEGER, TIMESTAMP WITH TIME ZONE |
| `ods.segments` | SCD1 UPSERT (HWM) | Heap | `price` TEXT → `amount` NUMERIC |
| `ods.boarding_passes` | SCD1 UPSERT (HWM) | Heap | `boarding_no` TEXT → INTEGER |
| `ods.airports` | TRUNCATE+INSERT | AO Row | JSON → отдельные поля (`airport_name`, `city`) |
| `ods.airplanes` | TRUNCATE+INSERT | AO Row | `range`/`speed` TEXT → INTEGER |
| `ods.routes` | TRUNCATE+INSERT | AO Row | `days_of_week` → INTEGER[], `scheduled_time` → TIME |
| `ods.seats` | TRUNCATE+INSERT | AO Row | Без преобразований |
Откройте [билеты](architecture-map.html#node=bookings.tickets&view=keys)
в виде **"Связи по ключам"**. Выделены бронирование, сегменты и связанные с ними
талоны. Выберите связь билета с сегментами: в карточке виден `ticket_no`.
Для связи сегмента с талоном понадобятся оба поля ключа, иначе талон
первого рейса можно ошибочно присоединить ко второму.
Детали: [`bookings_ods_design.md`](bookings_ods_design.md)
<a id="как-устроен-поток-целиком"></a>
### DDS (Detailed Data Store — Star Schema)
## Путь данных от источника до витрин
Измерения с суррогатными ключами. Факт в центре звезды.
SK генерация: `MAX(sk) + ROW_NUMBER()` (безопасно при `concurrency=1`).
Оставьте билет выбранным и переключитесь на **"Поток данных"**.
Таблицы останутся на местах, выбранный билет сохранится; теперь стрелки
показывают чтение данных загрузчиками. Синий цвет отмечает источники выбранной
таблицы, зеленый - потребителей, оранжевый - сам выбор. Несвязанные объекты
остаются видны приглушенными. Повторный выбор, **"Общий вид"** или `Esc`
возвращает обзор.
#### Измерения
Проследите путь
[сегмента](architecture-map.html#node=bookings.segments) через STG и ODS к факту:
| Измерение | BK | SK | SCD | Storage | Источник |
|-----------|----|----|-----|---------|----------|
| `dim_calendar` | `date_actual` | `calendar_sk` | Static | AO Row | Генерация (2016–2030) |
| `dim_airports` | `airport_bk` | `airport_sk` | SCD1 | Heap | `ods.airports` |
| `dim_airplanes` | `airplane_bk` | `airplane_sk` | SCD1 | Heap | `ods.airplanes` + `ods.seats` (total_seats) |
| `dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 | AO Row | `ods.segments` (DISTINCT) |
| `dim_passengers` | `passenger_id` | `passenger_sk` | SCD1 | Heap | `ods.tickets` (дедупликация) |
| `dim_routes` | `route_bk` | `route_sk` | **SCD2** | Heap | `ods.routes` + `dim_airports` + `dim_airplanes` |
- Источник → STG: PXF читает Postgres через внешние `stg.*_ext`.
Эти входы описаны в карточках STG и сами не хранят копию. Загрузчики
сохраняют строки в `stg.*` с бизнес-полями в TEXT и метками загрузки.
- STG → ODS: У строки появляются нужные типы. Например,
`stg.segments.price` становится `ods.segments.amount` типа NUMERIC.
ODS хранит текущее состояние строки по бизнес-ключу.
- ODS → DDS → DM: Сегмент обогащается ключами измерений в факте,
затем участвует в агрегатах витрины.
#### Факт
Стрелка данных не означает очередность задач Airflow. Например, календарь
генерируется без чтения ODS, хотя в DDS DAG задачи других измерений ждут
проверки календаря. Порядок выполнения смотрите по ссылке **"DAG · порядок задач"**
в карточке. Поля, SQL и проверки раскрываются там же, после выбора таблицы.
`dds.fact_flight_sales` — зерно: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`).
<a id="как-собирается-факт"></a>
| FK | Lookup-путь | Примечание |
|----|-------------|------------|
| `calendar_sk` | `dim_calendar` | Дата вылета |
| `departure_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт вылета (эталонный справочник) |
| `arrival_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт прилёта (эталонный справочник) |
| `airplane_sk` | `dim_routes` → `dim_airplanes` | Самолёт (point-in-time SCD2) |
| `tariff_sk` | `dim_tariffs` | Тариф |
| `passenger_sk` | `dim_passengers` | Пассажир |
| `route_sk` | `dim_routes` | Версия маршрута (SCD2, point-in-time) |
## Сборка факта продаж
Метрики: `price` (NUMERIC), `is_boarded` (BOOLEAN).
Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`.
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales).
В нашем примере в нем будет **три строки**, по одной на каждый сегмент.
Важные значения до выполнения студенческих заданий выглядят так:
Детали: [`bookings_dds_design.md`](bookings_dds_design.md)
| ticket_no | flight_id | price | departure_airport_sk | arrival_airport_sk | route_sk | airplane_sk | passenger_sk |
|---|---|---:|---|---|---|---|---|
| T1 | F101 | 3000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
| T1 | F102 | 5000 | SK аэропорта LED | SK аэропорта KZN | NULL | NULL | NULL |
| T2 | F101 | 4000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
### DM (Data Marts — Витрины)
SK здесь обозначены словами: конкретные числа назначает загрузка измерений.
`calendar_sk` указывает на 1 сентября, `tariff_sk` - на Economy.
`book_ref = B1` повторяется во всех трех строках; `book_date` - 30 августа.
Цена хранится на уровне сегмента, а сумма `12000` из бронирования в факт
не переносится. Иначе ее можно было бы ошибочно посчитать трижды.
Аналитические витрины поверх DDS. Каждая отвечает на конкретный бизнес-вопрос.
Начальный поток - `ods.segments`. Соединения с билетами, бронированиями
и рейсами - `INNER JOIN`: отсутствие подходящей строки исключит сегмент
из вставки факта. Это потеря покрытия, которую должна обнаружить DQ.
Посадочный талон добавляется через `LEFT JOIN` по паре `(ticket_no, flight_id)`.
Нет талона - сегмент сохраняется, `seat_no` остается `NULL`, `is_boarded = false`.
В этом стенде признак посадки означает именно наличие талона.
| Витрина | Бизнес-вопрос | Зерно | Стратегия | Storage |
|---------|--------------|-------|-----------|---------|
| `dm.sales_report` | Выручка и boarding rate по направлениям/тарифам/дням | (flight_date, dep_sk, arr_sk, tariff_sk) | UPSERT (HWM) | Heap |
| `dm.airport_traffic` | Пассажиропоток аэропортов по дням | (traffic_date, airport_sk) | UPSERT (HWM) | Heap |
| `dm.route_performance` | Эффективность маршрутов за всё время | route_bk | Full Rebuild | AO Column (zstd) |
| `dm.monthly_overview` | Помесячная динамика по типам самолётов | (year, month, airplane_sk) | UPSERT (HWM) | Heap |
| `dm.passenger_loyalty` | Профиль лояльности пассажиров | passenger_sk | UPSERT (затронутые ключи) | Heap |
<a id="откуда-берутся-два-аэропорта"></a>
Детали: [`bookings_dm_design.md`](bookings_dm_design.md)
### Аэропорты вылета и прилета
---
В карточке факта откройте связи **"Коды двух аэропортов"**,
**"Аэропорт вылета"** и **"Аэропорт прилета"**:
1. Рейс дает `route_no`. Из `ods.routes` берется одна строка с наибольшим
`validity` на этот номер. В ODS поле `validity` хранится как TEXT;
этот выбор не использует дату рейса.
2. `departure_airport` и `arrival_airport` дают два кода аэропорта.
3. Один `dds.dim_airports` присоединяется дважды по `airport_bk`, в ролях
вылета и прилета. Получаются два разных поля SK в факте.
Этот путь работает без студенческого `dim_routes`. Отсутствующий аэропорт
в факте - аномалия данных, а не ожидаемый результат невыполненного задания.
SQL использует `LEFT JOIN`, поэтому строка с таким пропуском сохранится;
допустимость пропуска отдельно оценивает [DQ факта](../../sql/dds/fact_flight_sales_dq.sql).
<a id="какая-версия-маршрута-нужна-рейсу"></a>
### Версия маршрута на дату рейса
Связь **"Версия на дату рейса"** ведет к `dds.dim_routes` по `route_bk = route_no`
и дате `scheduled_departure::DATE`. Дата должна попасть в полуоткрытый
интервал `[valid_from, valid_to)`; `NULL` в конце означает открытую версию.
Рейс на границе двух версий относится к новой.
Из выбранной версии берется `route_sk`, а ее `airplane_code` нужен для
поиска `airplane_sk` в `dim_airplanes`. Поэтому одного заполненного справочника
моделей недостаточно: без подходящей версии маршрута `airplane_sk` не появится.
<a id="что-кормит-витрины"></a>
## Агрегация фактов в витрине продаж
Перейдите к [dm.sales_report](architecture-map.html#node=dm.sales_report).
Зерно меняется: день вылета × два аэропорта × класс обслуживания.
Три строки примера дадут две строки витрины:
| Дата вылета | Направление | Класс | tickets_sold | total_revenue |
|---|---|---|---:|---:|
| 1 сентября | SVO → LED | Economy | 2 | 7000 |
| 1 сентября | LED → KZN | Economy | 1 | 5000 |
`tickets_sold` здесь считает **сегменты**. Один билет может участвовать несколько раз.
Если сложить его по направлениям, получится 3 при двух билетах.
`total_revenue` складывается из цен сегментов, поэтому итог равен 12000.
Доля посадок рассчитывается по `is_boarded`.
Готовая витрина использует календарь, две роли аэропорта и тариф через
`INNER JOIN`: факт без совпадения в одном из этих измерений не попадет в агрегат.
Поля `route_sk`, `airplane_sk`, `passenger_sk` ей не требуются, поэтому
эталон работает до выполнения заданий. Остальные витрины видны рядом
пунктирными рамками: в их карточках описан требуемый результат по ТЗ.
## Модели самолетов и места: расширение цепочки
Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes).
У него два источника: `ods.airplanes` дает характеристики модели,
а `ods.seats` - места, по которым считается вместимость.
Строка измерения описывает **модель**, например тип самолета, а не отдельный борт.
`seat_no` уникален только вместе с `airplane_code`.
На `main` STG уже заполняется, а загрузки `ods.airplanes`, `ods.seats`
и `dds.dim_airplanes` пока содержат `SELECT 1;`. Карточки помечены
**"Задание"** и ведут к соответствующим пунктам ТЗ. Ожидаемые поля
известны из DDL, но готовой загрузки этих объектов еще нет.
## Ключевые договорённости
- **Нейминг полей**: [`naming_conventions.md`](naming_conventions.md)
- **DQ-проверки**: SQL-скрипты с `RAISE EXCEPTION` (не отдельный DQ-слой).
На ветке `main` студенческие DQ-скрипты (`airplanes_dq.sql`, `seats_dq.sql`,
student dims/DM) — заглушки (`SELECT 1;`) без проверок.
- **Инкремент STG**: для `tickets` опорная дата — из `bookings.book_date`
- **Point-in-time JOIN**: факт ↔ `dim_routes` по `[valid_from, valid_to)`
- **Суррогатные ключи**: `MAX(sk) + ROW_NUMBER()` (не SERIAL — GP-специфика)
- Ключи на карте логические. В DDL таблиц DWH нет ограничений
`PRIMARY KEY`, `UNIQUE` и `FOREIGN KEY`; уникальность и связи проверяют SQL
и DQ. `DISTRIBUTED BY` отвечает за размещение строк.
- `_load_id` и `_load_ts` описывают загрузку. `event_ts` - ее опорное время;
у снимков справочников в STG оно равно времени получения снимка.
Подробности - в [соглашениях об именах](naming_conventions.md).
- До реализации измерений допустимы `NULL` в `passenger_sk`, `route_sk`
и `airplane_sk`. Ключи фиксируются **при вставке** факта: повторный запуск
меняет цену и сведения о посадке, но не заполняет старые SK.
После выполнения заданий следуйте разделу
["Пересчет факта после реализации измерений"](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
- В карточке заглушки описан результат из актуального ТЗ. Если ТЗ не определяет
тип соединения или поведение при пропуске строки, карта отмечает этот предел,
не выдает предположение за работающий SQL.
---
## Куда идти дальше
## Обучающие материалы
Начните с [приема справочника через PXF](../assignment/pxf_airports.md),
затем переходите к [заданиям ODS](../assignment/README.md#первая-загрузка-ods).
Для точечного вопроса
о таблице возвращайтесь на карту: там находится каталог объектов,
их источников, потребителей, SQL и DQ.
### Глоссарий
| Термин | Объяснение |
|--------|-----------|
| **Зерно факта (Fact Grain)** | Минимальная единица в факте. Здесь — один сегмент билета. |
| **Суррогатный ключ (SK)** | Технический INT-ключ, генерируемый в DWH. |
| **Бизнес-ключ (BK)** | Ключ из источника (`airport_code`, `passenger_id`). |
| **Star Schema** | Факт в центре, измерения вокруг (без snowflake-подтаблиц). |
| **SCD Type 1** | Перезапись атрибутов без истории. |
| **SCD Type 2** | Версионирование: `valid_from`/`valid_to`, `hashdiff`. |
| **AO Row/Column** | Append-Only хранение (Row или Column). Не поддерживает UPDATE. |
| **Heap** | Стандартное хранение с поддержкой UPDATE/DELETE. |
| **HWM (High Water Mark)** | Отсечка по `MAX(_load_ts)` для инкрементальной загрузки. |
### На что обратить внимание
**Обогащение измерений:**
`seats` + `airplanes` → `dim_airplanes` — пример обогащения (total_seats).
**«Майнинг» измерений из транзакций:**
`tickets` → `dim_passengers` — в источнике нет таблицы «Пассажиры». Извлекаем
уникальных пассажиров из билетов с дедупликацией.
**Нормализация:**
`segments.fare_conditions` → `dim_tariffs` — выносим строковый атрибут в отдельный
справочник для компактного INT-ключа в факте.
**Почему нет `dim_bookings`:**
`bookings` — транзакция, не справочник. `book_ref` и `book_date` хранятся
как degenerate keys в факте.
**Point-in-time JOIN (SCD2):**
Факт присоединяется к той версии маршрута, которая действовала на дату вылета:
`scheduled_departure::DATE >= valid_from AND (valid_to IS NULL OR ... < valid_to)`.
---
## Связанные документы
- [`bookings_stg_design.md`](bookings_stg_design.md) — дизайн STG
- [`bookings_ods_design.md`](bookings_ods_design.md) — дизайн ODS (SCD1, batch contract, DQ)
- [`bookings_dds_design.md`](bookings_dds_design.md) — дизайн DDS (Star Schema, SCD2)
- [`bookings_dm_design.md`](bookings_dm_design.md) — дизайн DM (5 витрин)
- [`naming_conventions.md`](naming_conventions.md) — нейминг полей
- [`../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/README.md).
Для разбора ошибки загрузки откройте руководство нужного слоя:
[STG](../bookings_to_gp_stage.md), [ODS](../bookings_to_gp_ods.md),
[DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md).
[Индекс документации](../README.md) ведет к справочникам, проверкам разработки
и истории проектирования.
+30 -15
View File
@@ -6,25 +6,24 @@
## Зачем этот документ
Чтобы имена полей не «плыли» между слоями, DAG и SQL-скриптами:
- студенты видят один и тот же словарь во всех задачах;
- новые реализации (ODS/DDS/DM) не расходятся с тем, как уже учили на `dwh-modeling`;
- ревью становится проще: сразу видно, где отклонение от стандарта.
Этот справочник задает имена служебных полей. Состав колонок конкретной
таблицы проверяйте по ее DDL, а требования к учебной загрузке - по
[ТЗ](../assignment/analyst_spec.md).
## 1. Базовые правила
- Имена колонок: `snake_case`, на английском.
- Бизнес-ключи источника не переименовываем без необходимости (`book_ref`, `ticket_no`, `route_no`).
- Булевы поля начинаются с `is_` (`is_outbound`, `is_boarded`).
- Денежные/количественные поля называем явно (`total_amount`, `segment_amount`, `range_km`).
- Денежные и количественные поля называем по смыслу (`total_amount`, `amount`, `range_km`).
## 2. Каноничные служебные поля
| Поле | Тип (рекомендация) | Смысл | Где применять |
|---|---|---|---|
| `_load_id` | `TEXT NOT NULL` | Идентификатор загрузки/батча | STG/ODS (новые объекты), при необходимости DDS |
| `_load_ts` | `TIMESTAMP NOT NULL` | Когда запись попала в слой | STG/ODS (новые объекты) |
| `event_ts` | `TIMESTAMP` | Когда событие произошло в источнике (effective time) | ODS/DDS при событийной природе данных |
| `_load_id` | `TEXT NOT NULL` | Идентификатор запуска или исходного STG-батча; зависит от слоя | STG, ODS, DDS и DM, кроме календаря |
| `_load_ts` | `TIMESTAMP NOT NULL` | Метка загрузки; в транзакционных ODS сохраняется время STG | STG, ODS, DDS и DM, кроме календаря |
| `event_ts` | `TIMESTAMP` | Опорное время источника или получения снимка | Все таблицы STG и транзакционные ODS |
| `created_at` | `TIMESTAMP NOT NULL` | Когда строка создана в таблице слоя | DDS/DM, где есть lifecycle строки |
| `updated_at` | `TIMESTAMP NOT NULL` | Когда строка обновлена в таблице слоя | DDS/DM, где есть UPDATE |
| `valid_from` | `DATE NOT NULL` (базовый трек) | Начало действия версии SCD2 | DDS SCD2 |
@@ -35,26 +34,42 @@
- Бизнес-ключ измерения: суффикс `_bk` (`customer_bk`, `airport_bk`).
- Суррогатный ключ измерения: суффикс `_sk` (`customer_sk`, `airport_sk`).
- В существующих таблицах есть бизнес-ключи без `_bk`: `passenger_id`
у пассажиров, `fare_conditions` у тарифов, `date_actual` у календаря.
Используйте имена из DDL и ТЗ; переименовывать их в задании не нужно.
## 4. Правило времени (важно для обучения)
- `event_ts` (effective time) и `_load_ts` (load time) — разные сущности, не смешиваем.
- Если `event_ts` отсутствует в источнике, используем `_load_ts` как fallback и явно документируем это в SQL/доке.
- Для snapshot-справочников (airports, airplanes, routes, seats) в STG нет бизнес-события с точным временем: `event_ts` заполняется через `now()` при загрузке. Это намеренно и задокументировано в каждом load-скрипте.
- В STG `event_ts` задает опору инкремента: `book_date` у бронирований,
билетов, сегментов и посадочных талонов; `scheduled_departure` у рейсов.
Например, у талона это дата бронирования, а не `boarding_time`.
- У снимков `airports`, `airplanes`, `routes`, `seats` в STG
`event_ts = now()`: точного времени изменения записи в источнике здесь нет.
- `_load_ts` относится к загрузке. Какой именно слой ее поставил, указано ниже.
Правила отбора строк описаны в руководствах [STG](../bookings_to_gp_stage.md)
и [ODS](../bookings_to_gp_ods.md).
## 5. Применение по слоям
### STG
- Используем канон `_load_id`, `_load_ts`, `event_ts` для всех таблиц.
- Все таблицы содержат `_load_id = run_id` запуска STG, `_load_ts = now()`
и `event_ts` по правилам выше.
### ODS
- Используем канон `_load_id`, `_load_ts`, `event_ts`.
- Базовый эталон ODS в этом стенде: SCD Type 1 (current state + UPSERT).
- Снимки `airports`, `airplanes`, `routes`, `seats`: `_load_id` выбранного
STG-батча, `_load_ts = now()`, без `event_ts`. Загрузка заменяет снимок целиком.
- Транзакционные таблицы: `_load_id`, `_load_ts` и `event_ts` переносятся из STG.
Сохраненная `_load_ts` служит границей инкремента для `UPDATE + INSERT`.
### DDS
### DDS и DM
- `_load_id` измененной или вставленной строки относится к текущему запуску
своего слоя, `_load_ts` фиксирует время записи.
- Календарь статический: у `dim_calendar` нет меток загрузки.
- `created_at` и `updated_at` используем там, где они предусмотрены DDL.
Например, у факта и полностью пересчитываемой `route_performance` их нет.
- Для SCD2 используем:
- `valid_from`, `valid_to`, `hashdiff`, `created_at`, `updated_at`.
- Интервалы считаем как `[valid_from, valid_to)`, current-версия: `valid_to IS NULL`.
+132
View File
@@ -0,0 +1,132 @@
# От карты к SQL загрузки
На [карте](architecture-map.html) найдите таблицу, которую хотите заполнить,
и откройте ее SQL загрузки. Проследите, какие строки скрипт читает,
что меняет и по какому ключу записывает результат.
Смысл строк и связей разобран в [руководстве о структуре DWH](db_schema.md).
Для заданий на снимки ODS достаточно примера `ods.airports` ниже.
К сборке факта вернитесь перед заданиями DDS.
Адресные ссылки на карту работают в локальном просмотре;
если читаете в Gitea, используйте [инструкцию открытия карты](db_schema.md#как-открыть-карту).
<a id="ods-airports"></a>
## Справочник: stg.airports → ods.airports
Откройте [ods.airports на карте](architecture-map.html#node=ods.airports)
и связь [Типизация и загрузка ODS](architecture-map.html#edge=flow-stg.airports-ods.airports-13).
Здесь один источник: `stg.airports`. В STG накапливаются снимки разных запусков,
а в ODS нужна одна строка на аэропорт из выбранного снимка.
Рядом откройте [airports_load.sql](../../sql/ods/airports_load.sql).
Пройдите скрипт по порядку:
1. `TRUNCATE TABLE ods.airports` очищает предыдущий снимок.
Следующий `INSERT` заменяет его целиком, поэтому исчезнувший из нового
снимка аэропорт исчезнет и из ODS.
2. В CTE `src` найдите `FROM stg.airports` и фильтр `_load_id`:
```sql
WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
```
Задача `resolve_stg_batch_id` в [ODS DAG](../../airflow/dags/bookings_to_gp_ods.py)
выбирает общий батч четырех справочников STG или берет `stg_batch_id`
из параметров запуска. Этот фильтр оставляет один снимок из истории STG.
3. Выражения `airport_name::json->>'ru'`, `city::json->>'ru'`
и `country::json->>'ru'` извлекают русские названия. Остальные бизнес-поля
переносятся без преобразования.
4. `ROW_NUMBER()` нумерует строки внутри `airport_code` по `_load_ts DESC`,
затем `event_ts DESC NULLS LAST`. Финальный `WHERE s.rn = 1`
оставляет одну строку на код.
5. Сопоставьте колонки `INSERT INTO ods.airports (...)` с финальным `SELECT`.
В `_load_id` сохраняется идентификатор выбранного STG-батча,
в `_load_ts` записывается время загрузки ODS. Поля и типы результата
заданы в [airports_ddl.sql](../../sql/ods/airports_ddl.sql).
6. `ANALYZE` обновляет статистику таблицы для планировщика запросов.
Проверки находятся отдельно: [airports_dq.sql](../../sql/ods/airports_dq.sql)
сравнивает множества ключей STG-батча и ODS, ищет дубли и пустые обязательные поля.
Повторная загрузка того же снимка должна сохранить бизнес-строки без дублей;
время `_load_ts` при этом обновится.
Теперь переходите к [первой самостоятельной загрузке](../assignment/README.md#первая-загрузка-ods).
<a id="пример-fact_flight_sales"></a>
<a id="fact-flight-sales"></a>
## Факт: сегмент билета и его измерения
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales)
и [fact_flight_sales_load.sql](../../sql/dds/fact_flight_sales_load.sql).
Одна строка факта соответствует паре `(ticket_no, flight_id)`.
В [примере поездки](db_schema.md#сборка-факта-продаж) два билета с тремя
сегментами дают три строки факта.
<a id="метод"></a>
<a id="fact-joins"></a>
### Найдите источник новых строк
В файле сначала идет `UPDATE`, а затем CTE `fact_src` и `INSERT`.
Начните с `fact_src`: здесь виден полный набор источников новых строк.
`FROM ods.segments AS seg` задает зерно, а `seg.amount AS price`
переносит цену сегмента в факт.
| Участок `fact_src` | Что добавляется к сегменту | Связь на карте |
|---|---|---|
| `JOIN ods.tickets`, затем `JOIN ods.bookings` | Бронирование, пассажир и дата покупки | [Билет и пассажир](architecture-map.html#edge=fact-1), [дата бронирования](architecture-map.html#edge=fact-2) |
| `JOIN ods.flights` | Дата вылета и номер маршрута | [Рейс, дата и маршрут](architecture-map.html#edge=fact-3) |
| `LEFT JOIN` подзапроса из `ods.routes` | Коды аэропортов из строки с наибольшим `validity` на `route_no` | [Коды двух аэропортов](architecture-map.html#edge=fact-4) |
| Два `LEFT JOIN dds.dim_airports` как `dep` и `arr` | `departure_airport_sk` и `arrival_airport_sk` по двум кодам | [Вылет](architecture-map.html#edge=fact-7), [прилет](architecture-map.html#edge=fact-8) |
| `LEFT JOIN dds.dim_routes`, затем `dds.dim_airplanes` | Версия маршрута на дату рейса и SK ее модели самолета | [Версия маршрута](architecture-map.html#edge=fact-5), [модель](architecture-map.html#edge=fact-9) |
| `LEFT JOIN dds.dim_calendar`, `dds.dim_tariffs`, `dds.dim_passengers` | SK по дате вылета, классу обслуживания и `passenger_id` | [Дата](architecture-map.html#edge=fact-6), [тариф](architecture-map.html#edge=fact-10), [пассажир](architecture-map.html#edge=fact-11) |
| `LEFT JOIN ods.boarding_passes` по обоим полям ключа | `seat_no`; наличие талона задает `is_boarded` | [Посадка и место](architecture-map.html#edge=fact-12) |
Если билет, бронирование или рейс не найдены, `INNER JOIN` исключит сегмент
из вставки. `LEFT JOIN` сохранит его при отсутствии совпадения: например,
без талона получатся `seat_no = NULL` и `is_boarded = false`.
Обратите внимание на два разных пути от маршрута:
- Аэропорты берутся через готовый `ods.routes`. Отбор `ORDER BY validity DESC`
работает по полю TEXT и не использует дату рейса.
- `route_sk` ищется в студенческом `dim_routes` по дате вылета
в интервале `[valid_from, valid_to)`. Из этой версии берется `airplane_code`
для поиска `airplane_sk`.
До выполнения заданий `route_sk`, `airplane_sk` и `passenger_sk` могут быть
`NULL`. Путь аэропортов от студенческих измерений не зависит.
Объяснение этой части модели: [аэропорты и версия маршрута](db_schema.md#аэропорты-вылета-и-прилета).
### Найдите запись результата
Финальный `INSERT` берет поля из `fact_src`. Условие `NOT EXISTS`
проверяет пару `(ticket_no, flight_id)` и пропускает уже записанные строки.
Вернитесь к `UPDATE` в начале файла: он меняет только цену, место,
признак посадки и метки загрузки у существующих строк.
Ключи измерений он не пересчитывает.
Поэтому после реализации измерений нужен
[пересчет факта по ТЗ](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверки результата находятся в [fact_flight_sales_dq.sql](../../sql/dds/fact_flight_sales_dq.sql).
## Как проверить свой слой
Перед записью SQL найдите свой объект на карте и откройте его пункт ТЗ.
Сформулируйте, что означает одна строка результата, по каким полям соединяются
источники и что произойдет при отсутствии совпадения. Затем сверяйте
`INSERT` с DDL, а преобразования и `*_dq.sql` с требованиями задания.
Близкие готовые примеры для следующих этапов:
- SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) обновляет
изменившиеся атрибуты через `IS DISTINCT FROM` и вставляет новые бизнес-ключи.
- DM: [sales_report_load.sql](../../sql/dm/sales_report_load.sql) находит
затронутые даты по `_load_ts` факта, пересчитывает за них полные агрегаты
во временную таблицу и выполняет `UPDATE` и `INSERT`.
[Порядок заданий](../assignment/README.md#следующие-этапы) ведет к требованиям
каждого этапа. Имена служебных полей проверяйте по
[соглашениям](naming_conventions.md), состав DQ - по
[справочнику проверок](../reference/dq_taxonomy.md).
-184
View File
@@ -1,184 +0,0 @@
# Протокол сквозного (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"`) интерпретируются как идентификаторы колонок.
@@ -1,342 +0,0 @@
# 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) |
-3
View File
@@ -1,3 +0,0 @@
# Планы работ
Активные планы. После выполнения переносятся в [`archive/`](../archive/).
@@ -1,75 +0,0 @@
# Бенчмарк генерации 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 не требуется для текущих объёмов данных.
-29
View File
@@ -1,29 +0,0 @@
# Временное ТЗ по блоку 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.
+68
View File
@@ -0,0 +1,68 @@
# Классы DQ-проверок
DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
класса. Один `*_dq.sql` может содержать проверки нескольких классов.
Сверяйте их с полями и правилами своего объекта в [ТЗ](../assignment/analyst_spec.md).
Как запустить DQ и проверить задание, описано в
[инструкции самопроверки](../assignment/README.md#запуск-и-проверка).
На `main` учебные DQ содержат `SELECT 1;` и начнут проверять данные
после вашей реализации.
## 1. Универсальные структурные проверки
Они проверяют форму таблицы: обязательные ключи заполнены, ключ уникален, а
таблица не пуста там, где по контракту ожидаются строки. Так можно быстро поймать
сломанное зерно или незавершённую загрузку без знания бизнес-смысла данных.
Реальные примеры:
- [`sql/dds/dim_airports_dq.sql`](../../sql/dds/dim_airports_dq.sql) требует
непустую таблицу, проверяет уникальность `airport_sk` и `airport_bk`, а также
обязательные поля.
- [`sql/ods/bookings_dq.sql`](../../sql/ods/bookings_dq.sql) ищет дубли
`book_ref` и `NULL` или пустые значения в обязательных полях. Пустой
инкрементальный батч здесь допустим.
- [`sql/ods/tickets_dq.sql`](../../sql/ods/tickets_dq.sql) проверяет уникальность
`ticket_no`, обязательные поля и отсутствие билетов без `bookings.book_ref`.
## 2. Проверки бизнес-правил
Они проверяют ограничения предметной области, которых не видно из структуры
таблицы. Такая проверка нужна, чтобы заметить правдоподобную по типам, но
невозможную по смыслу строку.
В [`sql/dm/sales_report_dq.sql`](../../sql/dm/sales_report_dq.sql) для дат
текущего запуска проверяется, что `tickets_sold >= passengers_boarded`,
`boarding_rate` лежит между `0` и `1`, а `total_revenue` неотрицательна.
## 3. Межслойные сверки
Они отвечают на вопрос: согласован ли слой N+1 со слоем N для нужного батча?
Сверка находит потерянные или лишние строки на границе загрузки.
Реальные примеры используют разные способы сверки:
- [`sql/ods/airports_dq.sql`](../../sql/ods/airports_dq.sql) сравнивает множества
`airport_code`: ищет пропущенные и лишние ключи относительно текущего STG-батча.
Это сверка ключей, а не простое сравнение числа строк.
- [`sql/stg/airplanes_dq.sql`](../../sql/stg/airplanes_dq.sql) сравнивает число
строк во внешней `stg.airplanes_ext` и в текущем батче `stg.airplanes`.
- [`sql/dds/fact_flight_sales_dq.sql`](../../sql/dds/fact_flight_sales_dq.sql)
требует равенства числа строк факта и `ods.segments`, потому что зерно факта —
один сегмент билета.
## `NULL` — не всегда ошибка
В [`fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
измерения присоединяются через `LEFT JOIN`. Факт может прийти раньше измерения,
поэтому `route_sk`, `airplane_sk` или `passenger_sk` временно бывают `NULL`. На
ветке `main` это также ожидаемо, пока студент не реализовал эти измерения. Такой
`NULL` сохраняет строку факта и не должен сам по себе останавливать пайплайн.
`seat_no` тоже может быть `NULL`: у билета уже есть сегмент, но записи
`boarding_passes` ещё нет. Обязательный ключ, напротив, должен быть заполнен.
Например, `NULL` в `airport_sk` измерения или в `(ticket_no, flight_id)` факта
нарушает контракт таблицы.
Архитектурное решение о том, где живут DQ-проверки, зафиксировано в разделе
[«Ключевые договорённости»](../design/db_schema.md#ключевые-договорённости).
-201
View File
@@ -1,201 +0,0 @@
# 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 уже запущен.
+20 -13
View File
@@ -1,13 +1,19 @@
# План отладки эталонного пайплайна
# История QA: план отладки полной цепочки
> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает
> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём
> готовить ветку main для студентов.
>
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
Этот план сохраняет сценарии первоначальной проверки полной реализации:
повторную загрузку, несколько учебных дней и граничные случаи.
Он предназначен для подготовки углубленной диагностики на отдельном тестовом
стенде. Ожидания и SQL ниже нужно сверять с текущей веткой; учебная `main`
содержит заглушки и не обязана проходить проверки полной реализации.
Для своего задания используйте [самопроверку](../assignment/README.md#запуск-и-проверка).
Действующие команды разработки и границы автоматических проверок описаны
в [TESTING.md](../../TESTING.md), устройство DQ - в [справочнике](dq_taxonomy.md).
Сценарии ниже очищают DWH и изменяют данные. `make bookings-init`
пересоздает базу Bookings, а `make dwh-truncate` удаляет строки хранилища.
Выполняйте их только на стенде, где данные можно потерять.
Параметры доступа к Airflow берите из `.env`.
---
@@ -77,7 +83,7 @@ UNION ALL SELECT 'stg.seats', COUNT(*) FROM stg.seats
UNION ALL SELECT 'stg.boarding_passes', COUNT(*) FROM stg.boarding_passes
ORDER BY 1;
-- ODS: все 9 таблиц не пустые
-- ODS: эталонные таблицы не пустые (на main ods.airplanes и ods.seats пусты by design)
SELECT 'ods.bookings' AS tbl, COUNT(*) FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
@@ -89,7 +95,7 @@ UNION ALL SELECT 'ods.seats', COUNT(*) FROM ods.seats
UNION ALL SELECT 'ods.boarding_passes', COUNT(*) FROM ods.boarding_passes
ORDER BY 1;
-- DDS: все 7 таблиц не пустые
-- DDS: все 7 таблиц не пустые (на main dim_airplanes, dim_passengers, dim_routes пусты by design)
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_airplanes', COUNT(*) FROM dds.dim_airplanes
@@ -99,7 +105,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
ORDER BY 1;
-- DM: все 5 витрин не пустые
-- DM: все 5 витрин не пустые (на main только sales_report непуста, остальные 4 — заглушки)
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.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
@@ -108,7 +114,8 @@ UNION ALL SELECT 'dm.monthly_overview', COUNT(*) FROM dm.monthly_overview
ORDER BY 1;
```
**Ожидание:** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
**Ожидание (solution):** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
**Ожидание (main):** Эталонные таблицы > 0; студенческие (см. список выше) = 0 до реализации заданий.
#### B. Сквозная сверка количеств (STG → ODS)
+1 -1
View File
@@ -82,7 +82,7 @@ docker compose ps # проверить health
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
Подробнее: `docs/reference/pxf_bookings.md`.
Подробнее: см. ветку `solution` (`docs/reference/pxf_bookings.md`).
## Airflow: свой образ
+158
View File
@@ -0,0 +1,158 @@
"""Независимая сверка лабораторной PXF: Postgres → сохраненный файл → STG/ODS."""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIELDS = ("airport_code", "airport_name", "city", "country", "coordinates", "timezone")
JSON_FIELDS = {"airport_name", "city", "country"}
def read_rows(service: str, query: str) -> list[dict]:
"""Читает через psql в контейнере; настройки берет из окружения Compose."""
if service == "bookings-db":
command = (
'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -X -qAt '
'-v ON_ERROR_STOP=1 -h 127.0.0.1 -U "$POSTGRES_USER" -d demo'
)
else:
command = (
'PGPASSWORD="$GREENPLUM_PASSWORD" exec '
"/usr/local/greenplum-db/bin/psql -X -qAt -v ON_ERROR_STOP=1 "
'-h 127.0.0.1 -U "$GREENPLUM_USER" -d "$GREENPLUM_DATABASE_NAME"'
)
result = subprocess.run(
["docker", "compose", "exec", "-T", service, "sh", "-c", command],
input=f"SELECT row_to_json(r) FROM ({query}) r;\n",
text=True,
capture_output=True,
cwd=ROOT,
check=True,
)
return [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
def compare_snapshot(
expected: list[dict], actual: list[dict], batch_id: str, layer: str
) -> list[str]:
"""Проверяет ключи, все бизнес-поля и метки; порядок строк не важен."""
errors = []
if not expected:
return ["Контрольный источник пуст. Сначала восстановите Bookings."]
source = {row["airport_code"]: row for row in expected}
if len(source) != len(expected) or None in source or "" in source:
return ["В контрольном источнике пустые или повторяющиеся ключи."]
keys = [row["airport_code"] for row in actual]
if len(keys) != len(set(keys)):
errors.append("Повторяются airport_code внутри снимка.")
missing = source.keys() - set(keys)
extra = set(keys) - source.keys()
if missing:
errors.append(
f"Не получены ключи: {sorted(missing)[:5]} (всего {len(missing)})."
)
if extra:
errors.append(f"Лишние ключи: {sorted(map(str, extra))[:5]}.")
for row in actual:
key = row["airport_code"]
if row.get("_load_id") != batch_id:
errors.append(
f"{key}: _load_id не равен {batch_id}; проверьте выбор батча."
)
timestamps = ("_load_ts", "event_ts") if layer == "stg" else ("_load_ts",)
if any(not row.get(field) for field in timestamps):
errors.append(f"{key}: не заполнены метки времени {timestamps}.")
if key not in source:
continue
for field in FIELDS:
wanted, received = source[key][field], row[field]
if field in JSON_FIELDS:
# Сравниваем весь JSON в STG, включая языки, кроме русского.
wanted = json.loads(wanted) if wanted is not None else None
if layer == "ods":
wanted = wanted.get("ru") if wanted is not None else None
elif received is not None:
try:
received = json.loads(received)
except (ValueError, TypeError):
errors.append(f"{key}: {field} не содержит исходный JSON.")
continue
if received != wanted:
errors.append(f"{key}: отличается {field}.")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
export = actions.add_parser(
"export", help="Сохранить источник напрямую из Postgres"
)
export.add_argument(
"file", type=Path, help="Новый JSON-файл для контрольного источника"
)
check = actions.add_parser("check", help="Сверить снимок с сохраненным источником")
check.add_argument("batch_id", help="Run ID нужного запуска STG")
check.add_argument("file", type=Path, help="Контрольный JSON-файл из export")
check.add_argument("--layer", choices=("stg", "ods"), default="stg")
args = parser.parse_args()
try:
if args.action == "export":
fields = ", ".join(f"{field}::text AS {field}" for field in FIELDS)
rows = read_rows(
"bookings-db",
f"SELECT {fields} FROM bookings.airports_data ORDER BY airport_code",
)
if not rows:
raise ValueError(
"Источник пуст. Пройдите инициализацию Bookings из README."
)
rows.sort(key=lambda row: row["airport_code"])
# Не даем случайно заменить контроль A данными измененного источника B.
with args.file.open("x", encoding="utf-8") as output:
json.dump(rows, output, ensure_ascii=False, indent=2)
output.write("\n")
print(f"Источник: {len(rows)} аэропортов сохранено в {args.file}.")
return 0
expected = json.loads(args.file.read_text(encoding="utf-8"))
# Run ID передается SQL-литералом, а не частью команды shell.
batch_literal = "'" + args.batch_id.replace("'", "''") + "'"
query = f"SELECT * FROM {args.layer}.airports"
if args.layer == "stg":
query += f" WHERE _load_id = {batch_literal}"
actual = read_rows("greenplum", query)
errors = compare_snapshot(expected, actual, args.batch_id, args.layer)
if errors:
print("Сверка не прошла. Проверьте SQL загрузки и выбранный Run ID:")
for error in errors[:10]:
print(f"- {error}")
return 1
print(
f"Сверка пройдена: {args.layer}.airports, _load_id={args.batch_id}, "
f"{len(actual)} ключей; все бизнес-поля и метки совпадают с требованиями."
)
return 0
except subprocess.CalledProcessError as error:
print("Не удалось прочитать БД. Проверьте docker compose ps и настройки .env.")
print(error.stderr.strip())
return 1
except FileExistsError:
print(
"Контрольный файл уже существует. Сохраните его; для нового опыта выберите другой путь."
)
return 1
except (OSError, ValueError, KeyError, TypeError) as error:
print(
f"Не удалось выполнить сверку: {error}. Проверьте путь и формат контрольного файла."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+3 -83
View File
@@ -1,83 +1,3 @@
-- DQ для DDS dim_airplanes.
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 $$;
-- TODO: реализуйте проверки качества данных для dds.dim_airplanes
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4 -96
View File
@@ -1,96 +1,4 @@
-- Загрузка DDS dim_airplanes: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
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;
-- TODO: реализуйте загрузку dds.dim_airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -100
View File
@@ -1,100 +1,3 @@
-- DQ для DDS dim_passengers.
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 $$;
-- TODO: реализуйте проверки качества данных для dds.dim_passengers
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4 -67
View File
@@ -1,67 +1,4 @@
-- Загрузка DDS dim_passengers: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
-- Это избавляет от дублирования сложной оконной функции в 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;
-- TODO: реализуйте загрузку dds.dim_passengers (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -157
View File
@@ -1,157 +1,3 @@
-- DQ для DDS dim_routes (SCD2).
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 $$;
-- TODO: реализуйте проверки качества данных для dds.dim_routes (SCD2)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4 -144
View File
@@ -1,144 +1,4 @@
-- Загрузка DDS dim_routes: SCD2 с hashdiff.
-- Учебный комментарий: Используем TEMP TABLE для подготовки дельты.
-- Это избавляет от дублирования логики 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;
-- TODO: реализуйте загрузку dds.dim_routes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD2 с hashdiff — самый нетривиальный паттерн в проекте.
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+34 -16
View File
@@ -7,7 +7,8 @@ DECLARE
v_dup_count BIGINT;
v_null_passenger BIGINT;
v_null_tariff BIGINT;
v_null_route_related BIGINT;
v_null_airport BIGINT;
v_null_student BIGINT;
v_null_calendar BIGINT;
v_null_required BIGINT;
BEGIN
@@ -62,15 +63,17 @@ BEGIN
v_dup_count;
END IF;
-- Ссылочная целостность: passenger_sk.
-- Учебный комментарий: passenger_sk будет NULL, пока вы не реализуете dim_passengers.
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
SELECT COUNT(*)
INTO v_null_passenger
FROM dds.fact_flight_sales
WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без passenger_sk: %',
RAISE NOTICE
'DQ INFO: студенческий SK (passenger) NULL: %. После реализации dim_passengers: TRUNCATE fact → перезагрузка.',
v_null_passenger;
END IF;
@@ -86,27 +89,42 @@ BEGIN
v_null_tariff;
END IF;
-- Route-related FK: допустимо при аномалиях, фейлим если > 1%.
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
SELECT COUNT(*)
INTO v_null_route_related
INTO v_null_airport
FROM dds.fact_flight_sales
WHERE route_sk IS NULL
OR departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL
OR airplane_sk IS NULL;
WHERE departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL;
IF v_null_route_related > 0 THEN
IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
IF v_null_airport > 0 THEN
IF v_null_airport * 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;
'DQ FAILED: NULL airport_sk: % (>1%%)',
v_null_airport;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)',
v_null_route_related;
'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
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;
-- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%.
SELECT COUNT(*)
INTO v_null_calendar
+3 -49
View File
@@ -1,49 +1,3 @@
-- DQ проверки для витрины dm.airport_traffic.
--
-- Учебные цели:
-- 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 $$;
-- TODO: реализуйте проверки качества данных для dm.airport_traffic
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -131
View File
@@ -1,131 +1,3 @@
-- Загрузка витрины dm.airport_traffic: инкрементальный UPSERT по датам.
--
-- Учебные цели:
-- 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;
-- TODO: реализуйте загрузку витрины dm.airport_traffic (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -52
View File
@@ -1,52 +1,3 @@
-- DQ проверки для витрины dm.monthly_overview.
--
-- Учебные цели:
-- 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 $$;
-- TODO: реализуйте проверки качества данных для dm.monthly_overview
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -167
View File
@@ -1,167 +1,3 @@
-- Загрузка витрины dm.monthly_overview: инкрементальный UPSERT по месяцам.
--
-- Учебные цели:
-- 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;
-- TODO: реализуйте загрузку витрины dm.monthly_overview (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -51
View File
@@ -1,51 +1,3 @@
-- DQ проверки для витрины dm.passenger_loyalty.
--
-- Учебные цели:
-- 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 $$;
-- TODO: реализуйте проверки качества данных для dm.passenger_loyalty
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -131
View File
@@ -1,131 +1,3 @@
-- Загрузка витрины dm.passenger_loyalty: инкрементальный UPSERT по затронутым ключам.
--
-- Учебные цели:
-- 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;
-- TODO: реализуйте загрузку витрины dm.passenger_loyalty (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -59
View File
@@ -1,59 +1,3 @@
-- DQ проверки для витрины dm.route_performance.
--
-- Учебные цели:
-- 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 $$;
-- TODO: реализуйте проверки качества данных для dm.route_performance
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -83
View File
@@ -1,83 +1,3 @@
-- Загрузка витрины dm.route_performance: Full Rebuild.
--
-- Учебные цели:
-- 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;
-- TODO: реализуйте загрузку витрины dm.route_performance (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -99
View File
@@ -1,99 +1,3 @@
-- DQ для ODS airplanes.
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 $$;
-- TODO: реализуйте проверки качества данных для ods.airplanes
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4 -39
View File
@@ -1,39 +1,4 @@
-- Загрузка ODS по airplanes: Полная перезагрузка (TRUNCATE + INSERT).
-- Почему: для справочников-снимков в Greenplum на AO-таблицах
-- эффективнее перетереть данные целиком, чем делать медленный UPDATE.
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;
-- TODO: реализуйте загрузку ods.airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: TRUNCATE + INSERT (snapshot-справочник на AO-таблице).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+19 -15
View File
@@ -140,21 +140,25 @@ BEGIN
v_orphan_arrival_count;
END IF;
-- Ссылочная целостность: airplane_code должен существовать в ods.airplanes.
SELECT COUNT(*)
INTO v_orphan_airplane_count
FROM ods.routes AS r
WHERE NOT EXISTS (
SELECT 1
FROM ods.airplanes AS a
WHERE a.airplane_code = r.airplane_code
);
IF v_orphan_airplane_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в ods.routes найдены строки с невалидным airplane_code: %',
v_orphan_airplane_count;
END IF;
-- Проверка RI airplane_code → ods.airplanes закомментирована,
-- т.к. таблица ods.airplanes реализуется студентом.
-- После реализации — раскомментируйте этот блок.
-- Полную версию см. в ветке solution.
--
-- SELECT COUNT(*)
-- INTO v_orphan_airplane_count
-- FROM ods.routes AS r
-- WHERE NOT EXISTS (
-- SELECT 1
-- FROM ods.airplanes AS a
-- WHERE a.airplane_code = r.airplane_code
-- );
--
-- IF v_orphan_airplane_count <> 0 THEN
-- RAISE EXCEPTION
-- 'DQ FAILED: в ods.routes найдены строки с невалидным airplane_code: %',
-- v_orphan_airplane_count;
-- END IF;
RAISE NOTICE
'DQ PASSED: ods.routes ок (_load_id=%): stg_batch_rows=%',
+3 -125
View File
@@ -1,125 +1,3 @@
-- DQ для ODS seats.
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 $$;
-- TODO: реализуйте проверки качества данных для ods.seats
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4 -36
View File
@@ -1,36 +1,4 @@
-- Загрузка ODS по seats: Полная перезагрузка (TRUNCATE + INSERT).
-- Почему: для справочников-снимков в Greenplum на AO-таблицах
-- эффективнее перетереть данные целиком, чем делать медленный UPDATE.
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;
-- TODO: реализуйте загрузку ods.seats (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: TRUNCATE + INSERT (snapshot-справочник на AO-таблице).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+80
View File
@@ -0,0 +1,80 @@
"""Проверка должна отвергать ошибки, которые не видны по количеству строк."""
import importlib.util
from copy import deepcopy
from pathlib import Path
import pytest
spec = importlib.util.spec_from_file_location(
"check_airports_snapshot",
Path(__file__).resolve().parents[1] / "scripts/check_airports_snapshot.py",
)
checker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(checker)
SOURCE = [
{
"airport_code": "AAA",
"airport_name": '{"en": "Airport", "ru": "Аэропорт"}',
"city": '{"ru": "Город"}',
"country": '{"ru": "Страна"}',
"coordinates": "(1,2)",
"timezone": "Europe/Moscow",
}
]
def snapshot():
"""Снимок с одним ключом и обязательными метками загрузки."""
rows = deepcopy(SOURCE)
rows[0].update(
_load_id="B", _load_ts="2026-09-22 12:00:00", event_ts="2026-09-22 12:00:00"
)
return rows
def test_accepts_complete_snapshot_with_json_key_order_difference():
rows = snapshot()
rows[0]["airport_name"] = '{"ru":"Аэропорт","en":"Airport"}'
assert checker.compare_snapshot(SOURCE, rows, "B", "stg") == []
def test_accepts_different_row_order_from_segments():
"""Порядок выдачи строк Greenplum не меняет содержимое снимка."""
expected = deepcopy(SOURCE) + deepcopy(SOURCE)
expected[1]["airport_code"] = "BBB"
rows = snapshot() + snapshot()
rows[1]["airport_code"] = "BBB"
assert checker.compare_snapshot(expected, rows[::-1], "B", "stg") == []
@pytest.mark.parametrize(
("field", "value"),
[
("airport_code", "BBB"),
("airport_name", '{"ru":"Старое имя","en":"Airport"}'),
("country", '{"ru":"Другая страна"}'),
("coordinates", "(2,1)"),
("_load_id", "A"),
("event_ts", None),
],
)
def test_rejects_wrong_values_despite_equal_row_count(field, value):
rows = snapshot()
rows[0][field] = value
assert checker.compare_snapshot(SOURCE, rows, "B", "stg")
def test_rejects_duplicate_and_empty_snapshot():
rows = snapshot()
assert checker.compare_snapshot(SOURCE, rows + rows, "B", "stg")
assert checker.compare_snapshot(SOURCE, [], "B", "stg")
def test_ods_requires_both_transformation_and_selected_batch():
rows = snapshot()
rows[0].update(airport_name="Аэропорт", city="Город", country="Страна")
assert checker.compare_snapshot(SOURCE, rows, "B", "ods") == []
rows[0]["_load_id"] = "A"
assert checker.compare_snapshot(SOURCE, rows, "B", "ods")
+18 -1
View File
@@ -66,6 +66,22 @@ def test_bookings_stg_ddl_dag_structure():
_assert_reachable(dag, "apply_stg_bookings_ddl", task_id)
def test_lab_pxf_airports_uses_snapshot_sql_and_reference_dependencies():
"""Опыт читает четыре справочника; генератор и транзакции в него не входят."""
dag = _load_dag("airflow.dags.lab_pxf_airports")
assert len(dag.tasks) == 8
for entity in ("airports", "airplanes", "routes", "seats"):
load = dag.get_task(f"load_{entity}_to_stg")
dq = dag.get_task(f"check_{entity}_dq")
assert load.sql == f"stg/{entity}_load.sql"
assert dq.sql == f"stg/{entity}_dq.sql"
assert load.postgres_conn_id == dq.postgres_conn_id == "greenplum_conn"
_assert_direct_edge(dag, load.task_id, dq.task_id)
_assert_direct_edge(dag, "check_airports_dq", "load_routes_to_stg")
_assert_direct_edge(dag, "check_airplanes_dq", "load_routes_to_stg")
_assert_direct_edge(dag, "check_airplanes_dq", "load_seats_to_stg")
def test_bookings_to_gp_stage_dag_structure():
"""Проверка структуры DAG bookings_to_gp_stage."""
dag = _load_dag("airflow.dags.bookings_to_gp_stage")
@@ -239,8 +255,9 @@ def test_bookings_to_gp_ods_dag_structure():
), "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_airplanes", "dq_ods_routes")
_assert_reachable(dag, "dq_ods_airplanes", "dq_ods_seats")
_assert_reachable(dag, "dq_ods_routes", "dq_ods_flights")
_assert_reachable(dag, "dq_ods_flights", "dq_ods_segments")
+3 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats")
# На main airplanes/seats — заглушки (SELECT 1;), не содержат TRUNCATE-паттерн.
# Полный список на ветке solution: ("airports", "airplanes", "routes", "seats")
SNAPSHOT_ENTITIES = ("airports", "routes")
def _read(path: str) -> str: